Compare commits

..
561 Commits
Author SHA1 Message Date
zarzet d3f9388ba8 fix(ci): align APK targets with supported ABIs 2026-08-02 01:42:50 +07:00
zarzet c04050459a Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps.json
2026-08-02 00:52:44 +07:00
zarzet e761dd604f docs(release): add 4.8.5 changelog 2026-08-01 19:39:09 +07:00
zarzet bad48ea0fd feat(settings): reveal nested search results 2026-08-01 19:23:58 +07:00
zarzet f7a0c8e993 feat(settings): search nested options 2026-08-01 19:08:35 +07:00
zarzet 7972fd64a4 chore(deps): update Flutter and Go dependencies 2026-08-01 18:46:11 +07:00
zarzet 7f4ace6d1b feat(metadata): show current cover dimensions 2026-08-01 18:34:25 +07:00
zarzet d4b617f49a feat(metadata): add embedded cover resize options 2026-08-01 18:24:27 +07:00
zarzet ed60cafb90 fix(search): expose go to album for sparse tracks 2026-08-01 18:01:16 +07:00
zarzet 19753ca005 fix(database): make playback session migration idempotent 2026-08-01 17:39:45 +07:00
zarzet 611f7377f6 fix(metadata): keep header details legible in light theme 2026-08-01 16:10:31 +07:00
zarzet 645a324a5f feat(navigation): add go to album actions 2026-08-01 16:06:08 +07:00
zarzet 6a3a85f7c9 fix(player): refresh lyrics after automatic track changes 2026-08-01 15:38:40 +07:00
zarzet b3bbe93c1e fix(metadata): preserve WAV and AIFF tags 2026-08-01 15:33:20 +07:00
zarzet 15b1b757fd fix(analysis): guard unsupported AC-4 codec 2026-08-01 15:02:20 +07:00
zarzet dc58353c07 fix(lyrics): reject empty embedded payloads 2026-08-01 15:02:03 +07:00
zarzet 3e41aa0eee fix(metadata): support extension custom search 2026-07-30 15:36:31 +07:00
zarzet d85c8f6a17 fix(analysis): detect full-band spectral cutoff 2026-07-30 15:14:36 +07:00
zarzet 5357e14b06 fix(ui): refine track action spacing 2026-07-30 14:42:26 +07:00
zarzet 49d0bd6a99 fix(ui): align track play button hitboxes 2026-07-30 14:16:21 +07:00
zarzet 5228b0030b fix(player): restore lyrics after cold start 2026-07-30 13:48:05 +07:00
zarzet 66928e259d fix(session): enforce provider retry modes 2026-07-30 13:31:54 +07:00
zarzet 113ad7ed9b fix(session): block challenged session generations 2026-07-30 13:09:42 +07:00
zarzet 177a176218 fix(session): honor canonical gateway error contracts 2026-07-30 12:59:48 +07:00
zarzet 169a811162 fix(session): coordinate verification across parallel downloads 2026-07-30 11:39:37 +07:00
zarzet 76f7b9c025 fix(selection): keep modal sheets above selection bar 2026-07-30 11:22:59 +07:00
zarzet 12f2b12570 style(settings): restore connected group layout 2026-07-29 21:03:24 +07:00
zarzet 04a8700a65 fix(player): restore playback position reliably 2026-07-29 21:03:11 +07:00
zarzet 43a8d08851 fix(download): make picker drag dismissal natural 2026-07-29 19:59:09 +07:00
zarzet 6f94aae0cc test(ui): cover search and player action consistency 2026-07-29 19:31:27 +07:00
zarzet d4bed9c643 feat(player): polish actions and synced lyrics 2026-07-29 19:29:59 +07:00
zarzet 24b9d7ba80 fix(player): preserve audio quality across track changes 2026-07-29 19:29:19 +07:00
zarzet cc93c5020d feat(settings): polish search and navigation layout 2026-07-29 19:27:30 +07:00
zarzet af28bbd492 chore(release): bump version to 4.8.5 2026-07-29 19:27:07 +07:00
zarzet 1458b74d5b test(ui): cover shared design system contracts 2026-07-29 17:13:50 +07:00
zarzet 6e60f33fd0 feat(settings): add searchable settings index 2026-07-29 17:13:33 +07:00
zarzet 4ee7558bca feat(collections): unify selection-aware screens 2026-07-29 17:13:21 +07:00
zarzet 78670a6ea3 feat(l10n): localize settings search 2026-07-29 17:13:01 +07:00
zarzet 7230dd3df8 refactor(ui): standardize error and empty states 2026-07-29 17:12:46 +07:00
zarzet eed48a5fec fix(a11y): improve compact control readability 2026-07-29 17:12:30 +07:00
zarzet 1443be6a4f feat(details): add adaptive cover palettes 2026-07-29 17:12:15 +07:00
zarzet dc4c75363c refactor(extensions): share extension rows 2026-07-29 17:11:51 +07:00
zarzet 309777cc71 refactor(settings): unify settings components 2026-07-29 17:11:34 +07:00
zarzet d765320f10 refactor(selection): share bottom bar behavior 2026-07-29 17:11:21 +07:00
zarzet 40034d184f refactor(library): unify track cards 2026-07-29 17:10:57 +07:00
zarzet dbdbf0ca41 refactor(ui): standardize modal sheets 2026-07-29 17:10:42 +07:00
zarzet fa0c7b20b5 refactor(ui): unify sliver page headers 2026-07-29 17:10:24 +07:00
zarzet c6f7b22e43 feat(theme): add shared design tokens 2026-07-29 17:10:07 +07:00
zarzet 834a8dde89 chore(repo): improve contributor experience 2026-07-29 14:16:10 +07:00
zarzet 1e973d5ecb style(dart): apply repository formatting 2026-07-29 14:14:42 +07:00
zarzet 5b45ef80bf fix(android): stop forcing display mode at startup 2026-07-29 13:48:41 +07:00
zarzet e7fbaf7b75 fix(download): suffix only colliding quality variants 2026-07-29 02:43:35 +07:00
zarzet 1f55523b64 fix(metadata): fetch maximum cover for online autofill 2026-07-29 02:42:57 +07:00
zarzet 452d9a76cb fix(metadata): verify maximum cover candidates 2026-07-29 02:42:46 +07:00
zarzet 8dcf7fa998 fix(storage): recover from unwritable download folders 2026-07-29 02:03:09 +07:00
zarzet 662558e3e6 fix(ui): sanitize user-facing errors 2026-07-29 01:43:04 +07:00
zarzet b1c18b95e7 fix(analysis): detect effective spectral cutoff 2026-07-28 21:15:06 +07:00
zarzet 1bf61884cd chore(deps): update FFmpeg and notifications 2026-07-28 20:52:57 +07:00
zarzet d7710e2a38 chore(release): bump version to 4.8.1 2026-07-28 20:23:46 +07:00
zarzet b2e4548204 fix(library): retain history during active downloads 2026-07-28 18:12:55 +07:00
zarzet 7eb0ed2fdb fix(metadata): dismiss autofill preview and surface feedback 2026-07-28 17:39:58 +07:00
zarzet 1390376f09 fix(album): overlay selection bar above navbar 2026-07-28 17:25:14 +07:00
zarzet 650c360eb9 fix(metadata): align source picker with edit sheet 2026-07-28 17:15:27 +07:00
zarzet d1db2cdcc5 fix(analysis): isolate metrics from active downloads 2026-07-28 16:04:44 +07:00
zarzet 6c89a1e177 feat(album): batch download selected tracks 2026-07-28 15:48:45 +07:00
zarzet fb538e9517 feat(metadata): choose extension for online autofill 2026-07-28 15:18:10 +07:00
zarzet 209eb5f10e fix(download): always alert for verification pauses 2026-07-28 14:21:42 +07:00
zarzet 385afe290a fix(download): reject mismatched provider tracks 2026-07-28 14:15:44 +07:00
zarzet b4f239c692 fix(download): honor explicitly selected provider 2026-07-28 14:11:14 +07:00
zarzet 7bf6f32802 fix(extensions): serialize verification callbacks 2026-07-28 14:09:06 +07:00
zarzet 6368d1feee fix(library): retain cached rows across refresh 2026-07-28 12:51:50 +07:00
zarzet 7f277f7a88 fix(library): backfill local audio bitrates 2026-07-28 12:51:28 +07:00
zarzet 4c33f112ad fix(library): keep rows visible during refresh 2026-07-28 01:50:54 +07:00
zarzet 80d9d87870 perf(metadata): avoid SAF copies during quality probes 2026-07-28 01:50:41 +07:00
zarzet 26570792a9 fix(library): persist lossless bitrate metadata 2026-07-28 00:50:20 +07:00
zarzet ad08b95c0f perf(android): skip unused download widget work 2026-07-28 00:50:00 +07:00
zarzet 454ef53977 feat(library): add quality label display modes 2026-07-27 17:25:39 +07:00
zarzet e4cd52f29f feat(library): highlight high-bitrate quality badges 2026-07-27 16:58:58 +07:00
zarzet af6ca12e85 fix(download): swap local track variant actions 2026-07-27 14:31:39 +07:00
zarzet 9b4a960324 fix(home): keep search visible during provider changes 2026-07-27 14:24:57 +07:00
zarzet 1cdf4b4547 fix(library): retain records when deletion fails 2026-07-27 14:24:39 +07:00
zarzet 73846c40ca fix(download): complete quality variants reliably 2026-07-27 14:22:24 +07:00
zarzet 856e2cbb9e fix(audio): wait for complete analyzer metrics 2026-07-27 14:19:52 +07:00
zarzet 3b901461f6 feat(library): show a trailing fade on the filter chips row
Extend ScrollEdgeFade with a horizontal axis and wrap the All/Albums/
Singles/Playlists chips so a right-edge fade signals the row scrolls
when chips overflow the screen.
2026-07-27 02:34:13 +07:00
zarzet ef3e019a3a feat(library): playlists filter view and configurable default view
Add a fourth Playlists chip/page to the Library filter pager, rendered
as an album-style grid of playlist cells (search filters by name, page
provider short-circuits since playlists come from libraryCollections).
Add a defaultLibraryView setting (last used / all / albums / singles /
playlists) in Library settings, applied on startup and whenever the
shell switches back to the Library tab.
2026-07-27 02:12:35 +07:00
zarzet eaafeef1bb fix(library): keep track count readable next to filter actions
Share one count header row: count text keeps its intrinsic width and the
Filters/Create playlist buttons scale down on narrow widths instead of
truncating the count to an ellipsis.
2026-07-27 01:50:49 +07:00
zarzet 50375b751e feat(player): animate track changes and open queue with a swipe up
Crossfade+scale the artwork and fade+slide title/artist on track change.
Make the player page non-scrollable and route vertical gestures instead:
swipe up on the content or bottom tab strip opens the queue sheet, swipe
down still forwards to the route's drag-to-dismiss.
2026-07-27 01:50:47 +07:00
zarzet 2a1c9e0904 fix(store): center add-repository state and keep submit button above keyboard
Use SliverFillRemaining(hasScrollBody: false) so the page scrolls as one
unit when the keyboard opens, pad for the nav bar so the form is centered
in the visible area, and set scrollPadding so focusing the URL field also
reveals the Add Repository button.
2026-07-27 01:50:33 +07:00
zarzet 0278fad278 feat(tutorial): fit pages on short viewports and fix page indicator visibility
Scale vertical gaps and icon by viewport height so pages fit with little
or no scrolling, add ScrollEdgeFade as a scroll cue when they still
overflow, and use onSurfaceVariant for inactive page dots (the previous
surfaceContainerHighest was invisible on AMOLED black).
2026-07-27 01:50:31 +07:00
zarzet efe703d60d feat(setup): make language list scrollability obvious and center step content
Add ScrollEdgeFade (bottom gradient while more content remains) plus an
always-visible scrollbar on the language list, and bias step centering
upward by the header height so content sits at the optical screen center.
2026-07-27 01:50:17 +07:00
zarzet 55be8fc827 fix(download): restore fast SQL-only duplicate check so huge playlists queue again 2026-07-27 01:43:56 +07:00
zarzet c42a1be76c feat(tablet): cap wide-layout margins at 80dp so content uses the screen 2026-07-27 01:39:22 +07:00
zarzet ac56e76b62 feat(appearance): manual toggle to force backdrop blur on lower device tiers 2026-07-27 01:39:16 +07:00
zarzet 9da3607932 feat(queue): long-press menu with move up/down for queued items 2026-07-27 01:24:14 +07:00
zarzet e8a3fc58c7 feat(metadata): show average bitrate for lossless files in the track screen 2026-07-27 01:20:38 +07:00
zarzet 52b1147275 feat(library): add missing-ISRC metadata filter 2026-07-27 01:15:51 +07:00
zarzet 8fe2662c0a fix(queue): keep cancelled downloads retryable across every cancel path and app restarts 2026-07-27 01:13:23 +07:00
zarzet 23f18ab276 fix(queue): give completion-bridge cells swipe navigation into the library list 2026-07-27 01:08:32 +07:00
zarzet c9b1898cc0 refactor(metadata): split cover cache, display helpers, and options menu into part files 2026-07-27 00:39:44 +07:00
zarzet e8290a5eb0 refactor(extensions): split manifest models and provider-priority logic into part files 2026-07-27 00:34:55 +07:00
zarzet d0dca1a101 refactor(library): split row models and queue union SQL into part files 2026-07-27 00:32:42 +07:00
zarzet 5ee7f733cf refactor(analysis): split models, info card, and spectrogram into part files 2026-07-27 00:30:25 +07:00
zarzet 6424647472 refactor(android): split replaygain journal, snapshots, and network policy out of DownloadService 2026-07-27 00:28:12 +07:00
zarzet 4539b3fd67 refactor(android): split media, ffmpeg, and SAF publish helpers out of NativeDownloadFinalizer 2026-07-27 00:25:34 +07:00
zarzet bd2bd85c9e refactor(go): split per-format scanners and incremental scan out of library_scan.go 2026-07-27 00:21:04 +07:00
zarzet 1b861f9ae8 refactor(go): split result/overlay helpers and output builders out of extension_fallback.go 2026-07-27 00:20:04 +07:00
zarzet 8a95f684d2 refactor(go): split deezer models, cache upkeep, and browse endpoints out of deezer.go 2026-07-27 00:18:45 +07:00
zarzet 358154c39e refactor(go): split lyrics provider config, LRC codec, and search matching out of lyrics.go 2026-07-27 00:16:18 +07:00
zarzet bfd44738e2 refactor(queue): fold duplicated final-embed blocks into one helper 2026-07-27 00:09:50 +07:00
zarzet 0b053d59b1 refactor(queue): decompose single-item god-method into _DownloadRun pipeline class 2026-07-27 00:06:35 +07:00
zarzet 21bc31e0b6 refactor(queue): move single-item download pipeline into a part file 2026-07-26 23:45:22 +07:00
zarzet ac90e7f517 refactor(android): split SAF scan and URI IO helpers out of MainActivity 2026-07-26 23:30:26 +07:00
zarzet 4ab6388e18 chore(analysis): enable unreachable_from_main lint 2026-07-26 23:19:36 +07:00
zarzet 5412037ac8 refactor(artist): split section builders and dialogs into a part file 2026-07-26 23:10:18 +07:00
zarzet ea172ba17b refactor(go): split package validation and VM lifecycle out of extension_manager.go 2026-07-26 23:05:30 +07:00
zarzet b81bd5e8aa refactor(history): split models and startup maintenance into part files 2026-07-26 22:56:57 +07:00
zarzet 00d03de2b1 refactor(home): split explore, recent, import, and search-results into part files 2026-07-26 22:51:55 +07:00
zarzet 49bfefb750 refactor(queue): split progress, verification, and connectivity into part files 2026-07-26 22:47:15 +07:00
zarzet 9dc44ecd88 refactor(go): split Ogg parsing and cover-art extraction out of audio_metadata.go 2026-07-26 22:39:58 +07:00
zarzet 2dbf8f6f9b refactor(go): split M4A/MP4 tagging out of metadata.go 2026-07-26 22:38:08 +07:00
zarzet 2ce793d68f perf(ui): scope scan rebuilds, aspect-bound MediaQuery, lazy duplicate list 2026-07-26 22:25:07 +07:00
zarzet cbee637501 perf(history): coalesce index bumps and batch per-row exists lookups 2026-07-26 22:25:04 +07:00
zarzet 1cda88cafc perf(metadata): cache MusicBrainz ISRC lookups in the finalize path 2026-07-26 22:15:04 +07:00
zarzet 6fcfb67af2 perf(go): cut redundant signed-session writes, progress-callback churn, tombstone growth 2026-07-26 22:14:58 +07:00
zarzet 183744c41e feat(android): home-screen download queue widget
A RemoteViews widget (no new dependencies) shows the active track,
artist, queue count, and a coarse progress bar, with tap-to-open.
DownloadService pushes updates only on discrete events — track or
status changes and 25% progress steps, never per byte — matching the
battery discipline; the last state persists so launcher refreshes
render after process death. Colors use Material You system tokens on
API 31+ with static light/dark fallbacks below.
2026-07-26 19:21:58 +07:00
zarzet 97b87472fe feat(metadata): manual MusicBrainz lookup in the tag editor
The Go MusicBrainz genre and album-artist ISRC lookups only ran
automatically at download time. The tag editor's auto-fill section
gains a Fetch from MusicBrainz button that bridges both exports in one
channel call (sparing the 1 req/s budget) and fills the fields as
editable suggestions; nothing is written until Save.
2026-07-26 19:21:32 +07:00
zarzet fa7ef84b39 fix(extensions): anchor URL handler patterns to the host
MatchesURL treated patterns as substrings of the whole URL, so
"spotify.com" matched any URL embedding it in a query parameter and a
hostile query string could route a link to the wrong handler. Patterns
now anchor to the URL host (exact domain or subdomain, optional path
prefix); "scheme:" patterns anchor to the URI front. With several
matching handlers, FindURLHandler now breaks the tie via the user's
metadata provider priority instead of Go's random map order.
2026-07-26 19:14:31 +07:00
zarzet 42c266eafc fix(extensions): enforce minAppVersion and requiredRuntimeFeatures on load
Both manifest gates were parsed but only checked by the Store UI, so
manual .sflx installs and directory loads of incompatible extensions
failed cryptically at runtime despite SIGNED_SESSION_GUIDE promising a
clear refusal. validateExtensionLoad now rejects them on every install
path, and ensureRuntimeReady re-checks so a gated package cannot be
enabled anyway. Unknown runtime features and too-new feature contract
versions fail with explicit messages; an empty app version (tests)
skips the version gate.
2026-07-26 19:14:29 +07:00
zarzet 74615a60b5 refactor: consolidate lyrics builders, cancel registries, and priority plumbing 2026-07-26 19:08:22 +07:00
zarzet 8d077ae2da feat(player): add repeat off/all/one modes
The internal player handles setRepeatMode: repeat-one replays the
current track, repeat-all wraps the queue (and keeps a single-track
shuffle queue alive). The mode is broadcast in playback state,
persisted with the playback session, and toggleable from the Now
Playing transport row and the up-next sheet; a small shuffle toggle
joins the transport row for symmetry.
2026-07-26 19:00:50 +07:00
zarzet 11fb4365a2 feat(queue): offer one-tap retry when connectivity returns
The connectivity listener now stays alive while network-failed items
remain and, on reconnect, shows a debounced snackbar counting them
with a Retry action wired to retryAllFailed scoped to network
failures. Also regenerates localizations for the recent feature
strings.
2026-07-26 19:00:49 +07:00
zarzet 424ca5d77e feat(ux): unify added-to-queue snackbars and add fetch retry
Every single-track enqueue now shows the shared added-to-queue
snackbar with a View action into Library (six call sites through one
helper). URL fetch failures in Home and via share intent gain a Retry
action; unrecognized-URL errors deliberately keep none since retrying
is deterministic.
2026-07-26 19:00:31 +07:00
zarzet 69830f6d46 refactor(go): consolidate duplicated track maps, selectors, and small helpers 2026-07-26 18:57:44 +07:00
zarzet cb8064a317 refactor(dart): consolidate duplicated lookup, filename, and search-provider helpers 2026-07-26 18:52:26 +07:00
zarzet a21d5bcb33 feat(library): ISRC duplicate detection beyond FLAC with review sheet
The download-time ISRC index now also parses mp3, m4a, ogg, and opus
tags through the existing native readers instead of skipping everything
but .flac. Library settings gain a Review duplicates sheet that groups
history and local-library rows sharing an ISRC via SQL over the
attached databases (no filesystem walk, SAF-safe), shows quality
badges, and offers keep-best and per-copy delete with confirmation.
2026-07-26 18:49:58 +07:00
zarzet 50c8f0e454 refactor(ffmpeg): remove dead conversion cluster and speculative decryption fields 2026-07-26 18:44:08 +07:00
zarzet 97758908ca refactor(library): drop unwired DB pagination providers 2026-07-26 18:44:06 +07:00
zarzet 04a71d0211 refactor: remove orphaned bridge, database, and queue helpers 2026-07-26 18:43:53 +07:00
zarzet d4f0b84615 feat(track): add Open on... platform links sheet
Track options gain an Open on... entry listing every streaming platform
song.link resolves for the track, served by a new generic
GetTrackPlatformLinks with its own memory cache beside the availability
cache (same request budget). The sheet is data-driven from the platform
map so the app core stays service-agnostic. Completes the bridge
wrappers already landed in 901fa34d; also carries the en strings for
the duplicate review sheet landing next.
2026-07-26 18:42:41 +07:00
zarzet 901fa34d80 refactor(bridge): remove dead cross-layer platform bridge methods 2026-07-26 18:32:40 +07:00
zarzet d38a368f63 chore(about): credit blackgold8282 (Korean) and BrandQc (French) as translators 2026-07-26 18:12:40 +07:00
zarzet 984beb92e8 feat(l10n): merge Crowdin updates, enable Korean at 98%
Merge origin/l10n_main (PR #428) with a per-key three-way resolution:
Crowdin values win only where main did not change the key since the
last sync, so the unused-key removal, store->repo wording sweep, and
ICU plural fixes survive. Korean is restored at 98% completion and
mapped again in crowdin.yml and both language pickers; ar/hi/nl/zh-CN/
zh-TW stay dropped (37% and below).
2026-07-26 18:12:25 +07:00
zarzet 5ca04a8534 docs(extensions): document the .sflx package suffix 2026-07-26 17:58:37 +07:00
zarzet b31f20d2bb perf(assets): instance GoogleSansFlex to the wght axis only
The app never sets FontVariation, so only the wght axis Flutter maps
from FontWeight is reachable; pin opsz/wdth/GRAD/ROND/slnt at their
defaults with fontTools varLib.instancer. Rendering is unchanged and
the asset shrinks from 4.0MB to 271KB. Glyph set and cmap are
untouched.
2026-07-26 17:58:35 +07:00
zarzet d67510fb36 chore(altstore): describe extension-based providers instead of Amazon 2026-07-26 17:58:33 +07:00
zarzet d3ab52d65d chore(l10n): unmap dropped locales from Crowdin, add uk to setup picker
Follow-up to the arb removal: unmap ar/hi/ko/nl/zh-CN/zh-TW in
crowdin.yml so crowdin pull does not recreate them, trim the dead
entries from both language pickers, and add the missing uk entry to
the setup-screen picker.
2026-07-26 17:58:31 +07:00
zarzet 3f576562ef feat(library): M3U8 playlist import and export
The playlist import picker now also accepts .m3u/.m3u8; EXTINF entries
feed the same Deezer enrichment pipeline as CSV. Collection screens
(wishlist, loved, playlists) gain an export action that resolves each
track to its local file, writes SAF entries relative to the download
tree root so external players and DAP copies resolve them, skips
tracks without a file, and shares the .m3u8 via the system sheet.
2026-07-26 17:58:12 +07:00
zarzet c497449549 feat(library): M3U8 playlist import and export
The playlist import picker now also accepts .m3u/.m3u8; EXTINF entries
feed the same Deezer enrichment pipeline as CSV. Collection screens
(wishlist, loved, playlists) gain an export action that resolves each
track to its local file, writes SAF entries relative to the download
tree root so external players and DAP copies resolve them, skips
tracks without a file, and shares the .m3u8 via the system sheet.
2026-07-26 17:56:38 +07:00
zarzet 54dcdb92dd feat(player): restore playback session after process death
Queue, current index, position, and shuffle flag are snapshotted to a
new app_state playback_session table on pause, track change, and queue
mutation (never on position ticks). On launch the session is restored
paused into the mini player; the first play seeks to the saved
position. Plain file paths that no longer exist are dropped, an
explicit stop or mini-player dismiss clears the snapshot, and the
installation-restore guard wipes it alongside the download queue.
2026-07-26 17:45:50 +07:00
zarzet c9470ef751 feat(queue): add download-next action on queued items
Queued rows get a skip-next button that reinserts the item ahead of all
other queued items, so the next free slot picks it up. During an active
native worker run the current batch keeps its order; the new order
applies from the next run.
2026-07-26 17:39:28 +07:00
zarzet 48f01ba146 fix(extensions): isolate invalid registry checksums 2026-07-26 12:29:28 +07:00
zarzet 5f5186a23e fix(reenrich): preserve release identity in batch actions 2026-07-26 12:28:00 +07:00
zarzet b93ebc1e8d docs(extensions): document file download recovery options 2026-07-26 12:26:15 +07:00
zarzet c4abbf8743 fix(verification): recover signed-session bootstrap failures 2026-07-26 11:09:18 +07:00
Zarz Eleutherius 9b35c6dc42 New translations app_en.arb (Korean)
[ci skip]
2026-07-26 05:38:10 +07:00
Zarz Eleutherius 1c95109b7b New translations app_en.arb (Korean)
[ci skip]
2026-07-26 04:15:24 +07:00
zarzet 08d648cfa8 fix(reenrich): allow correcting stale release metadata 2026-07-26 01:28:25 +07:00
zarzet 40dd9bb68f fix(download): avoid unsafe automatic range resume 2026-07-26 01:28:09 +07:00
zarzet aa0eecb27e docs(extensions): add current development guide 2026-07-26 01:15:48 +07:00
zarzet b956c57652 refactor(ios): isolate and test extension callbacks 2026-07-26 01:15:22 +07:00
zarzet fbe0674b04 test(go): close output handle before cleanup 2026-07-26 01:14:58 +07:00
zarzet 1705fb2a81 feat(extensions): verify decentralized package integrity 2026-07-26 01:14:35 +07:00
zarzet a5f2760023 refactor(android): extract native finalization policies 2026-07-26 01:14:14 +07:00
zarzet 20081b14bd refactor(dart): extract queue search and media policies 2026-07-26 01:13:52 +07:00
Zarz Eleutherius d4c38b7550 New translations app_en.arb (Korean)
[ci skip]
2026-07-25 18:41:06 +07:00
Zarz Eleutherius c681b891eb New translations app_en.arb (Korean)
[ci skip]
2026-07-25 17:44:57 +07:00
Zarz Eleutherius c19477a4f4 New translations app_en.arb (Korean)
[ci skip]
2026-07-25 16:41:28 +07:00
zarzet 1cfda41ef2 feat(audio): use VHQ settings for SoXR resampling 2026-07-25 15:58:42 +07:00
zarzet 19787ed461 fix(config): refresh announcements and remote content 2026-07-25 15:43:25 +07:00
Zarz Eleutherius a7069a8f91 New translations app_en.arb (Korean)
[ci skip]
2026-07-25 15:34:01 +07:00
Zarz Eleutherius bb127d1cc2 New translations app_en.arb (French)
[ci skip]
2026-07-25 14:00:34 +07:00
Zarz Eleutherius 927ca6e4ca New translations app_en.arb (Korean)
[ci skip]
2026-07-25 14:00:32 +07:00
Zarz Eleutherius 66cf998fb2 New translations app_en.arb (Korean)
[ci skip]
2026-07-25 12:55:14 +07:00
github-actions[bot] 93512fc3f7 chore: update AltStore source to v4.8.0 2026-07-25 03:34:20 +00:00
zarzet 555f0e8117 fix(ci): download pinned Gradle for native tests 2026-07-25 10:28:26 +07:00
zarzet cbd8fd0342 style(go): apply Go 1.26 formatting 2026-07-25 10:20:03 +07:00
zarzet 60a74a7e0e fix(ci): install Android 37 platform package 2026-07-25 10:14:44 +07:00
Zarz Eleutherius 37db310157 New translations app_en.arb (Hindi)
[ci skip]
2026-07-25 10:13:00 +07:00
Zarz Eleutherius 13fe8b4593 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-07-25 10:12:58 +07:00
Zarz Eleutherius a5f96b57e6 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-07-25 10:12:56 +07:00
Zarz Eleutherius f0a7a043ec New translations app_en.arb (Ukrainian)
[ci skip]
2026-07-25 10:12:55 +07:00
Zarz Eleutherius 58249d6a13 New translations app_en.arb (Turkish)
[ci skip]
2026-07-25 10:12:53 +07:00
Zarz Eleutherius d9dc7ddf3a New translations app_en.arb (Russian)
[ci skip]
2026-07-25 10:12:52 +07:00
Zarz Eleutherius cc1ad1552c New translations app_en.arb (Dutch)
[ci skip]
2026-07-25 10:12:50 +07:00
Zarz Eleutherius a3feb39e20 New translations app_en.arb (Japanese)
[ci skip]
2026-07-25 10:12:49 +07:00
Zarz Eleutherius 7676579659 New translations app_en.arb (German)
[ci skip]
2026-07-25 10:12:47 +07:00
Zarz Eleutherius bcb3729fa9 New translations app_en.arb (Arabic)
[ci skip]
2026-07-25 10:12:46 +07:00
Zarz Eleutherius 49a824c171 New translations app_en.arb (Spanish)
[ci skip]
2026-07-25 10:12:44 +07:00
Zarz Eleutherius 2a050f113b New translations app_en.arb (French)
[ci skip]
2026-07-25 10:12:43 +07:00
Zarz Eleutherius e4b99dd44b New translations app_en.arb (Indonesian)
[ci skip]
2026-07-25 10:12:41 +07:00
Zarz Eleutherius 18584ab81f New translations app_en.arb (Portuguese)
[ci skip]
2026-07-25 10:12:39 +07:00
Zarz Eleutherius a4f7360336 New translations app_en.arb (Korean)
[ci skip]
2026-07-25 10:12:38 +07:00
zarzet eabafecaeb fix(navigation): open download queue from snackbar actions 2026-07-24 22:19:03 +07:00
zarzet 01c08322c1 fix(extensions): preserve explicit track metadata 2026-07-24 21:32:13 +07:00
zarzet a41074d271 chore(deps): update Flutter, Go, and native dependencies 2026-07-24 20:37:07 +07:00
zarzet 452a15b652 fix(lyrics): reject mismatched search results 2026-07-24 18:48:05 +07:00
zarzet 4713be02de chore(settings): graduate native download worker 2026-07-24 12:23:36 +07:00
zarzet 89dcef8ffd test(native-worker): add Android regression gates 2026-07-24 12:16:41 +07:00
zarzet 0d8bc7b269 fix(native-worker): harden background queue handling 2026-07-24 12:15:55 +07:00
zarzet fa432266f3 chore(settings): graduate backup and restore from beta 2026-07-24 11:40:09 +07:00
zarzet 0cbe51ad19 fix(analysis): render full-track spectrograms 2026-07-23 18:24:14 +07:00
zarzet 5856729e9a fix(library): persist records before download completion 2026-07-23 18:00:22 +07:00
zarzet c35b11a155 fix(library): preserve downloaded file records 2026-07-23 17:40:09 +07:00
zarzet 3f487c03cc chore(release): bump version to 4.8.0 2026-07-23 13:38:48 +07:00
zarzet 5481f8e8cf feat(ui): smooth download progress updates 2026-07-23 13:36:22 +07:00
zarzet cdbb0d6bd7 fix(notifications): restore verification alert sound 2026-07-23 13:23:54 +07:00
zarzet c9114fef01 fix(android): sanitize restored installation state 2026-07-23 10:39:31 +07:00
zarzet 480a96bd0e fix(queue): clarify and coalesce verification progress 2026-07-23 09:57:02 +07:00
zarzet 685392f073 fix(download): accelerate verified extension resumes 2026-07-23 09:56:39 +07:00
zarzet 0c558fb33b test(backend): use a valid descriptor in fd coverage 2026-07-23 09:56:22 +07:00
zarzet ac7840c4e5 fix(download): persist and reconcile history reliably 2026-07-22 15:57:39 +07:00
zarzet f0988070bf fix(convert): simplify output names and harden writes 2026-07-22 15:56:07 +07:00
zarzet 87376ee73b fix(metadata): resolve audio format and local artwork 2026-07-22 15:55:30 +07:00
zarzet b2726db84a fix(queue): bridge completed downloads into library 2026-07-22 15:55:07 +07:00
zarzet 4d77a0b178 fix(ui): honor disabled hero animations 2026-07-22 15:54:50 +07:00
zarzet bd6e2b4472 fix(ui): restore plain loading skeleton on extension album screen
The hero-era loading machinery survived the hero-flight revert: the
loading scaffold rendered the real cover, _RouteSettled deferred the
swap until the push transition ended, and SkeletonCrossfade faded
between the two. The loaded header decodes the cover under a different
mem-cache key (viewport-sized vs full-res), so opening an album from
the artist screen flashed the cover: shown, blanked, shown again.

Restore the pre-hero flow: shimmer skeleton while loading, hard cut to
AlbumScreen so the cover appears exactly once. Removes the now-unused
_AlbumLoadingScaffold and three imports that only existed for it.
2026-07-21 07:35:03 +07:00
Zarz Eleutherius 16b44d2d3a New translations app_en.arb (Indonesian)
[ci skip]
2026-07-20 16:58:51 +07:00
Zarz Eleutherius 71c08501e7 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-20 04:45:34 +07:00
Zarz Eleutherius 297cddf31d New translations app_en.arb (Portuguese)
[ci skip]
2026-07-20 03:46:10 +07:00
Zarz Eleutherius 57eef4311f New translations app_en.arb (Indonesian)
[ci skip]
2026-07-20 02:32:54 +07:00
Zarz Eleutherius b5e5fb5cd2 New translations app_en.arb (Korean)
[ci skip]
2026-07-18 20:10:22 +07:00
zarzet b7e96facbe fix(logging): keep cursors valid after rollover 2026-07-16 10:56:26 +07:00
zarzet 648f36244b perf(go): add runtime metrics and benchmarks 2026-07-16 10:53:26 +07:00
zarzet b5d02da759 perf(dedupe): batch isrc lookups under one lock 2026-07-16 10:48:57 +07:00
zarzet c13fdcc6e1 perf(library): scan flac and m4a in one pass 2026-07-16 10:47:29 +07:00
zarzet 5846dc2416 perf(extensions): invoke goja providers directly 2026-07-16 10:39:03 +07:00
zarzet ef89352d09 perf(memory): drop disposable caches on pressure 2026-07-16 10:32:33 +07:00
zarzet c75139450a fix(extensions): bound file reads 2026-07-16 10:26:43 +07:00
zarzet 07c9cf034f fix(extensions): bound cookie runtime state 2026-07-16 10:24:13 +07:00
zarzet b69f67a786 fix(download): name variants by measured quality 2026-07-16 09:20:35 +07:00
zarzet 0961c09a6d feat(download): preserve quality variants 2026-07-16 08:45:01 +07:00
zarzet 491998e5c5 feat(convert): optionally retain original files 2026-07-16 08:27:24 +07:00
zarzet d3d34aaec1 perf(cache): defer cover cache maintenance 2026-07-16 08:09:56 +07:00
zarzet 5763a087c9 perf(queue): persist and index incremental updates 2026-07-16 08:09:28 +07:00
zarzet efa3ff3d66 perf(playback): batch local playlist resolution 2026-07-16 08:09:12 +07:00
zarzet ca8a6a8849 perf(shell): gate backdrop blur by device capability 2026-07-16 08:08:49 +07:00
zarzet 5a970ac83d perf(startup): bootstrap persisted app state 2026-07-16 08:08:27 +07:00
zarzet 10d59bc2b6 perf(library): trim startup lookup index 2026-07-15 22:17:17 +07:00
zarzet 79bb3214ae perf(library): streamline search and grouped counts 2026-07-15 22:16:10 +07:00
zarzet 6f566a9f03 perf(player): isolate high-frequency playback updates 2026-07-15 22:09:20 +07:00
zarzet 82ba586b72 feat(convert): support same-format lossless re-encoding 2026-07-15 22:03:32 +07:00
zarzet 6f121c7543 fix(queue): open completed batch track metadata 2026-07-15 21:45:35 +07:00
zarzet 35128a6bfc fix(reenrich): always clean temporary artifacts 2026-07-15 21:33:13 +07:00
zarzet 17dbcc1338 perf(library): lazy-load collections and dispose queries 2026-07-15 21:32:43 +07:00
zarzet 7f1c3e60db fix(player): prevent stale playback and clean SAF temps 2026-07-15 21:32:07 +07:00
zarzet bab954c6d9 fix(extensions): harden package and runtime lifecycle 2026-07-15 21:31:16 +07:00
zarzet ed9511b17f refactor(library-scan): reuse applyAudioMetadataToScan for M4A/MP3/APE
The M4A, MP3, and APE scanners hand-copied AudioMetadata into the scan result
field-for-field, identical to the existing applyAudioMetadataToScan helper
(same Date-else-Year fallback). Route them through it. Ogg is left as-is (it
intentionally has no Year fallback) and FLAC uses the separate Metadata type.
2026-07-14 19:00:34 +07:00
zarzet ca1c95346f refactor(settings): share the provider-priority reorder page
The download- and metadata-provider priority pages were 99% identical. Extract
ProviderPriorityListPage taking the provider-list load/save callbacks and
labels; both pages become thin wrappers. (The metadata page's explicit
saveLabel was the same dialogSave the scaffold already defaults to.)
2026-07-14 18:55:48 +07:00
zarzet cd6cf05227 refactor(providers): remove dead lookup/history APIs and dedup provider resolution
Delete the verbatim-duplicate LocalLibraryLookup class + provider, the unused
localLibrarySummaryProvider, notifier search()/getCount()/existsInLibrary
wrapper, and the now-orphaned LibraryDatabase.search(); the notifier's async
getById/findExisting lookups (the real ones) stay. Drop dead
download-history removeBySpotifyId/getDatabaseCount and the orphaned
history deleteBySpotifyId, and fold the byte-identical addToHistory/
adoptNativeHistoryItem into one _persistHistoryItem keeping distinct log text.
Delete dead extension_provider members (ensureSpotifyWebExtensionReady,
enabledExtensions, getExtension, resolveProviderDisplayName, URLHandler.matchesURL,
Extension.hasBrowseCategories) and collapse resolveEffectiveDownloadService/
MetadataProvider into one predicate-parameterized helper.
2026-07-14 18:50:52 +07:00
zarzet 87bf33321b refactor(search): remove unreachable artist/album/playlist result path
TrackState.searchArtists/searchAlbums/searchPlaylists were only ever written
empty (the metadata-search parse loops iterated const [] lists and customSearch
set []), so the home feed's separate artist/album/playlist result sections
never rendered. Drop the fields, the dead parse loops and _parseSearch* helpers,
the SearchArtist/Album/Playlist models, the sort caches/methods, and the
_Search* result widgets. The live tracks->buckets sections (_CollectionItemWidget
via artistItems/albumItems/playlistItems) and the search filter tabs are kept.
2026-07-14 18:41:13 +07:00
zarzet 11567fee7d refactor(reenrich): share the FFmpeg re-enrich apply flow
local_album and the queue batch action carried byte-identical ~110-line copies
of the FFmpeg re-enrich apply+SAF-writeback+temp-cleanup flow (differing only in
helper names). Extract one applyFfmpegReEnrichResult() so the SAF temp-file
cleanup — the class of bug fixed in fb7d101 — has a single source. The
track-detail copy stays: it drives snackbars and does not preserve metadata.
2026-07-14 18:33:18 +07:00
zarzet fd423e3095 perf(player): narrow now-playing queue sheet to shuffle-mode selector
The sheet watched the whole playbackStateProvider, which re-emits every 500ms
during playback, rebuilding the entire ReorderableListView twice a second just
to recompute an unchanged shuffle toggle. Watch a .select of shuffleMode only.
2026-07-14 18:26:33 +07:00
zarzet f0afb1382e refactor(backend): drop dead exports/availability path and dedup Go tag writers
Remove the never-called checkAvailability chain end to end (Dart bridge +
provider, Android/iOS MethodChannel handlers, Go exports) and stop restoring
its persistent lookup cache on every cold start; the legacy prefs key is
cleared on next cache reset.

Delete dead Go: romaji.go (test-only), the no-op pre-warm chain
(PreWarmTrackCache/PreWarmCache/…), retired Download stubs, and a batch of
exports with no Dart/Kotlin/Swift/Go caller. Kept GetTrackCacheSize/
ClearTrackIDCache (Settings cache screen), SetAllowedDownloadDirs and
Clear/GetItemProgress (live test seams).

Dedup: single updateFlacVorbis + replaceFlacPictures helper behind the six
FLAC tag writers; merge byte-identical extractCommentFrame/extractLyricsFrame
into extractLangTextFrame.

go build/vet + 237 go tests, flutter analyze + tests green.
2026-07-14 18:25:13 +07:00
zarzet e2a2e8a914 refactor(library): drop dead pre-pagination filter pipeline in queue tab
The DB-backed pagination migration left the old in-widget filter/unification
pipeline behind three unused_element ignores; every helper, cache field, and
isolate entry point below them was reachable only from that dead root.
2026-07-14 17:53:58 +07:00
zarzet 4ffd5cc701 feat(ui): shimmer skeletons for the home feed artist/album sections
While the home feed loads, show section-shaped skeletons (album cards +
artist circles) instead of the generic track-row skeleton, and shimmer
each explore cover while its image loads — matching the artist screen.
2026-07-14 17:01:14 +07:00
zarzet f09c607aab fix(extensions): surface swallowed signed-session verification in provider fallback
Two holes let a provider's verification requirement vanish so the fallback
chain failed outright instead of opening the challenge:

- classifyDownloadErrorType did not recognize 'signed session expired'
  (the exact error ensureSignedSession returns), so an expired session
  failed generically.
- Extensions that catch the needsVerification response internally (e.g.
  qobuz-web's availability search) report a plain 'not available', which
  the fallback loop skipped past silently.

The runtime now remembers when a signed-session call inside the current
script invocation required verification; the provider wrapper consumes
that after checkAvailability/download and tags the failure as
verification_required, regardless of how the script handled it. The
source-extension path also classifies thrown errors now.
2026-07-14 16:34:43 +07:00
zarzet 3833a66bf6 fix(metadata): send Accept-Language on Deezer requests so names follow the app language
Deezer localizes artist and genre names by IP geolocation when no language
is given, so users behind e.g. Arabic-region IPs got Arabic metadata on an
English device (VPN 'fixed' it). The app's display language is now pushed
to the backend and sent as Accept-Language, with an en-US default.

Fixes #480
2026-07-14 15:46:33 +07:00
zarzet 9ac10dd6bb feat(player): ReplayGain/R128 volume normalization for the built-in player
Reads REPLAYGAIN_TRACK_GAIN (album gain fallback) from FLAC/MP3/M4A tags
and converts Opus R128_TRACK_GAIN/R128_ALBUM_GAIN (Q7.8, -23 LUFS) to the
same ReplayGain dB representation on the Go side, then attenuates the
internal player's volume per track. Off by default; toggle lives under
Library > Playback. Opus header output gain is left to the decoder, which
already applies it.

Closes #465
2026-07-14 15:18:42 +07:00
zarzet e37f13275f perf(embed): native-first tag writes and overlapped fetches in finalization 2026-07-14 14:55:59 +07:00
zarzet 4605517d55 fix(download): skip AC-4 repair for non-MP4 decrypt output
Decrypt can emit a raw FLAC stream; the AC-4 repair step fed it to the
MP4 box parser, which failed with a garbage atom type whose control
bytes then broke the unescaped Kotlin error JSON (FormatException in
Dart). Guard the repair call by extension, sniff the box type in Go as
a second layer, and build platform error JSON with JSONObject.quote.
2026-07-14 14:32:29 +07:00
zarzet c6e012b23d fix(ui): fetch covers via the Go backend instead of raw HttpClient
Both call sites used a bare dart:io HttpClient with no timeout, retry, or
cache; downloadCoverToFile provides all three (plus the singleflight cover
cache) and can hang-proof the embed path.
2026-07-14 12:49:57 +07:00
zarzet cdc1ba0a68 feat(net): DoH fallback, uTLS pool flush on cleanup, retry hardening
- Resolve over DNS-over-HTTPS (1.1.1.1/8.8.8.8, cached, SSRF-filtered)
  when the OS resolver fails, on all transports and the uTLS dial path,
  so DNS-level ISP blocking no longer kills the connection outright
- Flush the pooled uTLS h2 conns from CloseIdleConnections so a network
  switch no longer leaves the first bypass request hanging on a dead conn
- Abort retry backoff sleeps on context cancellation
- Cap honored Retry-After at 2 minutes
- Restore the response body consumed by the 403/451 marker scan
2026-07-14 12:49:43 +07:00
zarzet bd9b98b942 fix(go): build with Go 1.26.5 for aligned cgo frames, safe on arm32
Fixes the random gobind crash 'bulkBarrierPreWrite: unaligned arguments'
(golang/go#46893; cgo aligns exported-function argument frames since
1.26.0). The earlier 1.26 rollback (cdc58367) was the arm32 SIGSYS from
probing futex_time64 under seccomp on Android <=10 — fixed in 1.26.3 by
gating time64 syscalls on kernel >= 5.1 instead of probing (verified in
the 1.26.5 runtime source, which names Android 8-10 explicitly).
go directive carries the full patch version so gomobile's synthesized
bind module picks the same toolchain.
2026-07-14 12:20:53 +07:00
zarzet 11979b12e2 perf(ui): skip the shell backdrop blur on low-end devices
The sigma-18 BackdropFilter re-filters everything scrolling underneath
every frame. The startup runtime profile now feeds a lowEndDevice
provider; those devices get an opaque bottom bar instead.
2026-07-14 12:05:56 +07:00
zarzet 53784d64c3 feat(ui): crossfade the app content on orientation and rail-breakpoint changes
Flutter reflows in one frame on rotation, so the new layout popped in;
tablets change the most (bottom bar to rail, two-pane now-playing).
A 300ms fade above the router runs on orientation flips and on width
changes across the 600dp rail breakpoint (fold/unfold, split-screen).
2026-07-14 09:10:18 +07:00
zarzet 4caf875da4 Revert "fix(player): don't auto-resume after interruption while backgrounded"
This reverts commit 09714e3c64b22ccdc8a6555ffd6398b9cd205f23.
2026-07-14 09:10:18 +07:00
zarzet 5a1e898795 fix(shell): rail overflow on short viewports and tab reset on rotation
- NavigationRail scrolls inside its SafeArea when the viewport is too
  short for four labeled destinations (landscape phone with the mini
  player), instead of overflowing by its natural height
- the PageView subtree carries a GlobalKey so swapping the body
  between rail and bottom-bar layouts at the 600dp breakpoint moves
  the element instead of remounting it - rotation no longer snaps
  back to the home tab and kept-alive tab state survives
2026-07-14 09:10:17 +07:00
zarzet a7f9aae0a9 fix(player): don't auto-resume after interruption while backgrounded 2026-07-14 09:10:17 +07:00
zarzet b3cad6e526 feat(settings): toggle to disable Hero animations
A single HeroMode above the router disables shared-element flights
app-wide (mini player expand, track metadata cover) for users who
prefer no motion or want to save the overlay rendering cost. On by
default; persisted in AppSettings.
2026-07-14 09:10:16 +07:00
zarzet 1893f3faa8 feat(tablet): clamp remaining screens - settings, queue lists, wizard
- SettingsGroup centers itself at 720dp via its incoming constraint
  (not screen width, so groups inside clamped sheets are never
  over-inset; unbounded-width contexts guarded); priority scaffold,
  bespoke hero cards, and donate page get the same inset - all 18+
  settings pages clamp from shared widgets
- queue list-mode, local/downloaded album track lists, favorite
  artists, home recent-access, and extension detail sections adopt
  wideListInset like the screens fixed earlier
- track metadata content clamped at 720dp, which also tames the
  width-derived spectrogram height
- announcement dialog capped at 480dp wide; setup wizard and
  tutorial content at 560dp with a 400dp action button
2026-07-14 09:10:16 +07:00
zarzet 7573d360bf feat(player): swipe the mini player away to stop playback
Horizontal Dismissible on the bar; stop() now also clears the current
media item so the bar (and any listener of it) sees the session end.
2026-07-14 09:10:15 +07:00
zarzet 8966de6d0a revert(ui): drop hero cover flights from home/search to detail screens
The list-to-header cover flights read as a gimmick and add per-frame
cost during navigation. Removed the heroTag plumbing end to end
(recents, search rows, discography cards, Album/Artist/Downloaded
headers, motion banners). Kept: the mini player expand hero, the
library-to-track-metadata hero, real loading headers, and the
loading crossfade.
2026-07-14 09:10:14 +07:00
zarzet b53146c599 fix(reenrich): keep release identity when the matched track is from another album
An ISRC identifies a recording, not a release, so identifier-first and
search matches could resolve to a compilation of the same song and
overwrite the file's album, cover, track position, and date with the
compilation's. When the file already knows its album and the candidate's
album doesn't match, those release-scoped fields are now preserved;
recording-level fields (title, artist, ISRC, genre) still enrich.
2026-07-14 09:10:14 +07:00
zarzet 266b08a80d chore(ci): drop the amd64 gomobile slice abiFilters already strips 2026-07-14 09:10:11 +07:00
zarzet 357c684a21 perf(db): enable incremental auto-vacuum so cleared data shrinks the file 2026-07-14 09:10:06 +07:00
zarzet 556399d354 feat(ui): surface the audio-analysis cache in cache management 2026-07-14 09:10:05 +07:00
zarzet c1737b92d0 perf(android): sweep stale cache files, relax scan poll, debounce worker snapshots 2026-07-14 09:10:05 +07:00
zarzet 28cdb6c837 perf(disk): byte-cap the cover disk cache with an init sweep 2026-07-14 09:10:03 +07:00
zarzet 937ff6c4a2 perf(net): cache update-check and remote-config fetches with TTL and ETag 2026-07-14 09:10:02 +07:00
zarzet 547e6493dd perf(net): fetch embed covers once per release instead of per track 2026-07-14 09:10:01 +07:00
zarzet 3cdeeb2f5f fix(extensions): remove extension data dir on uninstall 2026-07-14 09:10:00 +07:00
zarzet f5b5af6eea feat(tablet): navigation rail, landscape now-playing, clamped track lists
- shell switches to a NavigationRail at >=600dp (same destinations,
  badges included); the bottom bar and its blur stay phone-only while
  the mini player remains anchored bottom in both modes
- now-playing gains a two-pane landscape layout (artwork left,
  metadata/controls right) built from the same widget pieces
- track lists on album/playlist/library-folder screens and search
  result sections center at 720dp via wideListInset instead of
  stretching across the full tablet width; explore carousels stay
  full-bleed intentionally
2026-07-14 09:10:00 +07:00
zarzet 8f7ede4730 feat(tablet): clamp dialogs and sheets, adaptive selection-card columns
- bottom sheets capped at 640dp via bottomSheetTheme (centered on
  wide screens, no effect on phones)
- update dialog and SAF migration dialog text capped at 480/400dp
- selection action cards and service chips grow from 2 to up to 4
  columns based on available width instead of stretching
2026-07-14 09:09:59 +07:00
zarzet a10aeb76ee fix(ui): match loading-header placeholder heights to the loaded rows
The subtitle/meta placeholders were shorter than the real artist and
meta rows, so the cover shifted ~12px during the loading-to-content
crossfade and read as a ghosted double image.
2026-07-14 09:09:58 +07:00
zarzet 2d9df7e1bc fix(ui): hero continuity for motion (Apple Music) headers
Albums/artists with a motion banner have no square cover, so the Hero
had no destination: the open flight ended nowhere and there was no
return flight. The Hero now rides the full-bleed banner, flying the
still cover image as the shuttle so the video doesn't restart mid-air.
2026-07-14 09:09:57 +07:00
zarzet 16e2046d0f fix(ui): defer loaded-screen swap until the push transition settles
With cached albums the fetch finishes mid-Hero-flight; the crossfade
then mounted a second hero with the same tag, which the flight doesn't
adopt — a static cover appeared next to the flying one. The extension
screens now keep the loading header until the route animation completes.
2026-07-14 09:09:56 +07:00
zarzet 306c34a32a style(ui): unify header meta rows on the dot-separated album style
Track metadata, playlist, and library folder headers dropped their pill
badges for the same HeaderMetaRow/HeaderMetaItem used by the album
screen.
2026-07-14 09:09:56 +07:00
zarzet 93b173cff8 feat(ui): real headers on extension loading screens so hero covers fly on open
Opening an extension album/artist showed a bare skeleton, so the cover
had no Hero destination and only flew on the way back. Loading states
now render the actual header (title + Hero cover in its final slot,
skeleton placeholders for the remaining rows) above the shimmering list.
SkeletonCrossfade mutes the outgoing child's heroes to avoid duplicate
tags if the user pops mid-fade.
2026-07-14 09:09:54 +07:00
zarzet a287d6b815 fix(playlists): keep imported playlist track order (#482)
Playlist tracks were read newest-first (added_at DESC, rowid DESC),
so a batch import - inserted in playlist order with one timestamp -
came back reversed, and the in-memory prepend matched that. Playlists
now use insertion order: snapshot reads ASC, single adds append, and
batch adds append in order. Existing rows were already stored in
playlist order, so old libraries display correctly without migration.
The preview-cover query follows the first visible track.
2026-07-14 09:09:53 +07:00
zarzet 9a205987ee feat(metadata): album-style header with centered square cover
The full-bleed cover made the Hero flight from square list thumbnails
morph into a fullscreen rect and back. The header now blurs the cover
as a backdrop and shows a centered rounded square (album header layout),
so the flight stays square end to end.
2026-07-14 09:09:53 +07:00
zarzet a11cff8b28 fix(player): pause motion header video when its screen is hidden
The banner only reacted to app lifecycle, so the HEVC decoder and HLS
stream kept running behind covered routes and inactive tabs. It now
follows TickerMode: the Navigator already disables it for covered
routes, and the shell disables it for non-current tabs (which also
mutes stray animations on kept-alive pages).
2026-07-14 09:09:52 +07:00
zarzet 23488a9c05 feat(player): fade the sheet in place on close, letting only the cover travel
Button/back pops no longer slide the sheet down: it dissolves where it
stands while the Hero artwork flies back to the mini player. Drag
dismissals keep sliding from the finger's position since freezing a
half-dragged sheet mid-screen would look broken.
2026-07-14 09:09:51 +07:00
zarzet 8610f18ff7 fix(player): dissolve the sheet while it slides down on close
After the content fade, popping left an empty dark scaffold traveling
the full screen. The sheet now fades out over the second half of the
pop; opening and dragging keep it fully opaque.
2026-07-14 09:09:51 +07:00
zarzet 7a0be15359 fix(artist): drop blurBackground stretch mode on the header
Dormant until bouncing physics enabled real overscroll; the stretch zoom
stays, but blurring the artist image while it grows looked broken.
2026-07-14 09:09:50 +07:00
zarzet 8a332c566a fix(player): fade Now Playing content with the expand transition
The app bar and body slid up as a block while the Hero artwork flew its
own path, so the title/controls looked detached from the cover. Content
now fades in over the last 60% of the transition (and fades out first on
pop/drag), leaving only the sheet surface and the flying cover in motion.
2026-07-14 09:09:50 +07:00
zarzet 8c33a86df5 feat(ui): crossfade skeleton into content on extension detail screens
SkeletonCrossfade (AnimatedSwitcher, 250ms) replaces the single-frame
hard swap when the extension album/artist/playlist fetch completes.
2026-07-14 09:09:49 +07:00
zarzet c580280890 feat(ui): shared-element hero covers from search and recents to detail screens
Tab navigators get a HeroController (nested navigators have none, so
Heroes never flew inside tabs). Search result rows, recent-access items,
and artist discography cards tag their covers; Album/Artist/Downloaded
screens accept a heroTag and mount the matching Hero on the header
cover. Tags are scoped per list section to stay unique per page.
2026-07-14 09:09:49 +07:00
zarzet 702fc9825e feat(player): expand transition from mini player with drag-to-dismiss
Slide-up route with M3 emphasized easing, live finger-tracking dismissal
on the app bar and artwork, and a shared-element Hero on the artwork so
the mini player cover grows into the full player.
2026-07-14 09:09:48 +07:00
zarzet 3cf9418f47 feat(ui): fluid bouncing scroll physics on capable devices 2026-07-14 09:09:48 +07:00
zarzet 8d914d967c feat(android): request highest display refresh rate at current resolution 2026-07-14 09:09:47 +07:00
zarzet e8c9df20b3 perf(analysis): stream PCM from disk instead of buffering the whole track 2026-07-14 09:09:46 +07:00
zarzet f69f7e7bf6 perf(images): decode completion-bridge cover precache at cell size 2026-07-14 09:09:45 +07:00
zarzet eeb0298d13 perf(scan): stream SAF library scan results to a spill file 2026-07-14 09:09:43 +07:00
zarzet 96eb412fe5 perf(memory): release Go heap and decoded image caches on OS memory pressure 2026-07-14 09:09:42 +07:00
zarzet f983a303fd perf(lyrics): bound the in-memory lyrics cache 2026-07-14 09:09:41 +07:00
zarzet 08007f493c perf(extensions): reuse isolated goja download runtimes via per-extension pool 2026-07-14 09:09:39 +07:00
zarzet e302fac390 perf(ogg): stream tag rewrites instead of buffering every page 2026-07-14 09:09:35 +07:00
zarzet 4d0f1120d2 perf(m4a): stream tag rewrites instead of buffering the whole file 2026-07-14 09:09:32 +07:00
zarzet 9b53d662f7 perf: hoist per-call regex compiles to package level 2026-07-14 09:09:30 +07:00
zarzet c4e266a5b9 refactor!: rename extension store bridge ABI to repo across all layers
Coordinated rename of the nine store-named bridge surfaces: Go
exports (InitExtensionStoreJSON -> InitExtensionRepoJSON etc.),
method-channel strings, the Kotlin and Swift handlers, and the Dart
PlatformBridge methods. Channel strings verified 1:1 across
Dart/Kotlin/Swift; gomobile bindings match the new export names.
Android MediaStore APIs untouched. store_registry_url pref key kept
for existing users. Local Android/iOS builds need the gobackend
AAR/xcframework rebuilt; CI does this in the release workflow.
2026-07-14 09:09:29 +07:00
zarzet 2273d9ba48 feat(download): resume interrupted downloads via Range/If-Range 2026-07-14 09:09:26 +07:00
zarzet 034b505d15 perf(network): enable gzip on the metadata transport 2026-07-14 09:09:25 +07:00
zarzet 122b16133a perf(tls): enable session resumption on the stdlib transports 2026-07-14 09:09:24 +07:00
zarzet 86d5ae3a2b refactor(dart): rename store provider and screen folder to repo
store_provider.dart -> repo_provider.dart, screens/store/ ->
screens/repo/; Store* classes and storeProvider renamed to Repo*.
Method-channel names in platform_bridge stay unchanged (bridge ABI
matching the Kotlin handler).
2026-07-14 09:09:22 +07:00
zarzet fd4348b803 refactor(go): rename extension store files and internals to repo
extension_store.go -> extension_repo.go, exports_store.go ->
exports_repo.go; unexported types/functions/log tag renamed to match
the user-facing repo terminology. The gomobile-exported function
names (InitExtensionStoreJSON etc.) are the bridge ABI called from
Kotlin/Swift and stay unchanged, as does the store_registry_url pref
key that guards existing users' settings.
2026-07-14 09:09:20 +07:00
zarzet 34c7b33368 chore(l10n): finish store -> repo wording sweep
The es/pt/zh base locales still shipped the old Store labels, the
backup-restore hint said 'from the store' in every locale (boutique
in fr, store loanword in ko), and the Go store lookup error said
'not found in store'. All user-visible surfaces now say repo;
translator descriptions in the template updated to match.
2026-07-14 09:09:19 +07:00
zarzet 4fc6a6b45d feat(update): force update when 3+ stable releases behind
UpdateChecker now derives the latest release and a releasesBehind
count from one releases-list call. When the installed version is
forceUpdateThreshold (3) or more stable releases behind, the update
dialog becomes mandatory: barrier and back-press are blocked, the
later/don't-remind actions are hidden, backing out of the installer
returns to the dialog, and the check bypasses the user's
check-for-updates opt-out. Prereleases never count toward the
threshold.
2026-07-13 09:44:59 +07:00
zarzet aae48cc47c ci: track latest stable Flutter in all workflows 2026-07-13 09:08:23 +07:00
zarzet 4b7834e090 ci: cache pub deps, filter jobs by path, and speed up release builds
CI (every push/PR):
- pub-cache cached keyed on pubspec.lock
- dorny/paths-filter skips the Flutter job for Go-only changes and
  vice versa (skipped required checks count as passing)

Release:
- gomobile installed from the go.mod-pinned x/mobile version instead
  of @latest (reproducible, covered by the setup-go cache; verified
  locally that go.sum already satisfies cmd/gomobile)
- Android bind targets arm/arm64/amd64 only - 386 dropped, amd64 kept
  so the universal APK still runs on x86_64 emulators
- NDK r29 cached; docker prune dropped from the free-space step
- Flutter pinned to 3.41.5 (matches .fvmrc/ci.yml) in both build jobs
  plus pub-cache caching; xcodeproj gem installed only when missing
2026-07-13 09:07:23 +07:00
zarzet 9b3b586006 fix(network): harden audit findings in transport, uTLS pool, and cover cache
- sharedTransport ResponseHeaderTimeout 45s -> 120s: downloads ride
  this transport and some providers prepare files server-side before
  the first byte; the stall watchdog still bounds dead transfers
- uTLS pool: a request that fails on a pooled (possibly silently
  dead) connection now re-dials once when the body is repeatable,
  matching the old dial-per-request reliability
- cover singleflight completes via defer with a default error so a
  panicking fetch can never strand waiters in (nil, nil) or leave a
  permanent in-flight entry
2026-07-13 08:45:30 +07:00
zarzet 0f0ebe5565 chore: bump version to 4.8.0-beta.1; fix ICU plural syntax in ar/fr 2026-07-13 08:39:06 +07:00
zarzet 2fa4aa5b70 perf(network): mobile stability and data-usage fixes from network audit
Stability:
- transient timeouts no longer classified as ISP blocking; they fall
  through to retry backoff (hard blocks - DNS/RST/cert - still abort)
- connectivity change now closes idle Go connections in every network
  mode (debounced), so pooled sockets from the old interface are not
  reused after a wifi/cellular handoff
- download body reads get a 60s stall watchdog that cancels and
  surfaces a retryable network error instead of hanging; distinct
  from user cancellation
- ResponseHeaderTimeout 45s on all transports; IdleConnTimeout 90->60s;
  dial timeout 30->10s; retry backoff gains full jitter; Retry-After
  honored on 5xx and Deezer 429

Data usage / speed:
- cover downloads deduplicated with singleflight plus a 24MB/15min
  in-memory cache keyed by final URL (album batches fetched the same
  1800px cover once per track before)
- song.link availability cached (30min positive / 5min negative)
  in front of the 9-req/min rate limiter
- uTLS Cloudflare path now pools one HTTP/2 connection per host with
  a shared TLS session cache instead of a full handshake per request

Deezer artist track-count N+1 kept: counts feed the discography
download UI and are already amortized by the artist TTL cache.
2026-07-13 08:39:05 +07:00
zarzet 8579f68554 feat(artist): shimmer skeleton for album and popular-track cover placeholders
Covers loaded as flat gray boxes while the image downloaded; the
album cards and popular list now shimmer like the rest of the
loading states.
2026-07-13 07:30:34 +07:00
zarzet fb7d101019 fix(queue): restore pre-refactor guarantees found by commit audit
- _replaceSafFileVia: op now registers produced files via addCleanup
  the moment they exist, so converted temp files are deleted even
  when a later step throws (old code's outer-variable finally)
- native worker history: recompute format from the final file path
  at history-write time and gate bitrate on lossy output again -
  the extracted helper was recording pre-conversion format/bitrate
  (FLAC conversions logged as m4a with nulled bit depth on SAF)
- drop dead ignore_for_file header in the finalization part
2026-07-13 06:21:42 +07:00
Zarz Eleutherius 9a9152f7f8 New translations app_en.arb (Korean)
[ci skip]
2026-07-13 00:27:02 +07:00
Zarz Eleutherius d9ceb8ac78 New translations app_en.arb (Korean)
[ci skip]
2026-07-12 22:54:50 +07:00
zarzet 2d52f3251e refactor(queue): split download_queue_provider into concern part files
Pure move: paths, native worker, finalization, ReplayGain, and
metadata embedding move to part files as private extensions on
DownloadQueueNotifier; queue orchestration, state, and public API
stay in the main file (7598 -> 4022 lines).
2026-07-12 21:31:38 +07:00
zarzet de32b8b5f6 refactor(queue): extract shared payload, history, and polling helpers
- _buildDownloadRequestPayload/_historyItemFromResult shared by the
  inline and native-worker paths; _buildOutputDir now derives from
  _buildRelativeOutputDir
- shouldAbortWork extended (deleteFileOnAbort, null-item check) and
  used at all ten guard sites; _purgeAlbumRgEntry replaces six copies
- ProgressStreamPoller shared by download queue and local library;
  openVerificationAndAwaitGrant shared by queue and track provider
2026-07-12 21:09:31 +07:00
zarzet e1af1c8dc9 chore(l10n): remove 70 unused localization keys
1,303 stale entries dropped across 19 locales (largest groups:
Spotify credentials flow, collection playlists, notification
channels, backup strings); generated localizations rebuilt.

Crowdin sources need a resync after this lands.
2026-07-12 20:23:19 +07:00
zarzet 85949eef8c refactor: unify extension call skeleton and download finalization paths
Go:
- callExtensionScript generic replaces the ~40-line perf/lock/run/
  timeout skeleton in ten wrapper methods; PostProcess V1/V2 share
  postProcessCommon (V1 keeps its postProcess-only probe)
- attemptExtensionDownload extracts the duplicated provider download
  attempt from the source-extension and priority-loop paths;
  overlayStr/overlayInt collapse the enrichment overlay chains;
  buildDownloadFilename shared by both output-path builders

Dart (download_queue_provider):
- _replaceSafFileVia wraps the SAF copy/operate/publish/cleanup
  roundtrip used at eight sites
- decrypt and external-LRC finalization unified between the inline
  and native-worker pipelines (divergences parametrized: AC-4 repair,
  per-stage error messages, base-name resolution, extension-state
  snapshot); HIGH/container conversion keep separate lenient/strict
  policies and share only the SAF mechanics
2026-07-12 20:19:54 +07:00
zarzet 62015a4e2d refactor(screens): migrate remaining screens to shared header and selection components
- album/playlist/library-folder/track-metadata adopt
  CollapsingHeaderScrollMixin; artist/library-folder adopt
  SelectionModeMixin
- HeaderCircleButton and HeaderMetaRow replace per-screen copies
  in album and playlist headers; dead _tallHeader flag removed
- SelectionBottomBar gains optional subtitle labels and backs the
  playlist selection bar in queue tab
2026-07-12 19:38:50 +07:00
zarzet 44de61a06a refactor(screens): share album-screen scaffolding and track flows
- SelectionModeMixin + CollapsingHeaderScrollMixin extracted;
  local/downloaded album screens now share AlbumTrackTile,
  AlbumScaffoldBody, DestructiveSelectionButton, HeaderMetaRow,
  and confirmAndDeleteTracks
- track_detail_actions.dart: shared downloadSingleTrack,
  queueTracksSkippingDownloaded, download-all confirm, queued
  snackbar, release-date formatter, list footer, love-all
- TtlCache<T> replaces the copied 10-minute static caches;
  album fetch branches share _applyAlbumMetadata
- playlist error card now uses ErrorCard; formatMegabytes shared
  by queue tab and update dialog
2026-07-12 19:19:22 +07:00
zarzet 4b9853eef7 refactor(ui): consolidate duplicated sheets, rows, and cover widgets
- single-track convert sheet now reuses BatchConvertSheet
  (confirmLabelBuilder + sourceIsLossless params) instead of a
  full inline copy
- provider priority page migrated to PrioritySettingsScaffold;
  shared showDiscardChangesDialog and ReorderablePriorityItem
  replace per-page copies
- home tab search rows unified into one _SearchResultRowItem;
  _parseTrack copies rebuilt on Track.fromBackendMap with only
  the load-bearing local overrides kept; loading/error scaffold
  extracted
- PlayerArtwork widget shared by now-playing and mini player
- LocalOrNetworkCoverImage dispatches file vs network cover in
  one place (playlist picker, queue nav, library folder)
2026-07-12 18:58:16 +07:00
zarzet 4e0cae9c20 refactor: extract shared helpers for repeated low-level patterns
Dart:
- notification_service: single _details() builder replaces 13 copies
  of the NotificationDetails block
- platform_bridge: _invokeMap() for 34 invoke+decode call sites,
  _cachedInvoke() unifies the three TTL/in-flight cache scaffolds
- ffmpeg_service: _promoteTempOutput(), _appendCoverInputArgs(),
  single _writeReplayGainTags() and _convertToLossless() for the
  ALAC/FLAC twins
- sqlite_helpers.dart: shared openAppDatabase/path-key/migration
  helpers for the three database classes
- library_collections: parametrized wishlist/loved/favorite CRUD
- extension_provider: one predicate-based replacedBuiltIn* lookup

Go:
- extension runtime: parseGojaHeaders/coerceGojaBody/doExtensionHTTP
  shared by httpGet/httpPost/httpRequest/shortcuts/fetch
- exports_metadata: applyAudioMetadataToResult + successMethodJSON,
  APE edit path reuses audioMetadataFromEditFields
- lyrics: lrclibGet() for both LRCLib fetchers
- extension_store: drop hand-rolled strings helpers
2026-07-12 14:10:23 +07:00
zarzet aa2cdef1d2 refactor: remove dead code across Dart and Go layers
- drop unused widgets (AnimatedStateSwitcher, GridSkeleton,
  adjacentHorizontalPageRoute, buildRemovalAnimation, SwingIcon,
  BottomSheetOptionTile) and empty _buildInfoCard placeholders
- drop test-only Go wrapper methods (GetDownloadURL, MatchTrack,
  CheckAvailability 7-arg, CustomSearchForItemID) and their parsers
- drop unused httputil getters/validators and hand-rolled base64
  decoder in favor of encoding/base64
- remove unused riverpod_generator dev dependency
2026-07-12 09:35:11 +07:00
Zarz Eleutherius d6726f976a New translations app_en.arb (Korean)
[ci skip]
2026-07-11 20:36:39 +07:00
zarzet cea6a58c18 refactor(playlist): parse tracks via Track.fromBackendMap
Removes the screen's private copy of the backend-map parser; the factory
is a strict superset (broader duration coercion, source/albumType/
itemType, and empty-spotify_id fallback to the native id).
2026-07-11 17:31:11 +07:00
zarzet 59a5ca622d refactor(screens): migrate playlist and library-folder headers to AlbumDetailHeader
Both carried yet another copy of the collapsing detail header.
AlbumDetailHeader gains appBarTitle (selection-aware toolbar title) and a
leading override for the folder screen's close/back switch. The playlist
header adopts the album layout (bottom-anchored content, 0.85 gradient,
length-adaptive title size, scrim only when blurred) instead of its
drifted centered variant; motion-banner and pill-chip meta are preserved
through the slots.
2026-07-11 17:18:01 +07:00
zarzet 05415d96d5 refactor(screens): unify album header, selection UI, and batch engine
The local/downloaded album screens had drifted copies of the album
header, selection bottom bar, disc chip, batch convert/ReplayGain trio,
and assorted small helpers. All now delegate to the shared widgets, with
the online album screen's design as the reference, and both album screens
run batch actions through the queue_tab engine via UnifiedLibraryItem
(strict-superset implementation covering both DB writebacks and SAF
paths). Also folds the remaining per-screen helper copies (cover URL,
error card, byte/clock formatting, readPositiveInt) into their shared
homes.

Intentional deltas: selection-bar strings follow queue_tab's l10n keys,
both providers reload after a conversion, and audio-analysis durations
round instead of floor.
2026-07-11 16:36:29 +07:00
zarzet fca3039208 refactor(models): add Track.fromBackendMap and copyWith, dedupe track parsing
The provider and the redownload service each hand-rolled the same
backend-map parsing and duration extraction. The factory ports the richer
provider variant (cover normalization, native-vs-spotify preferredId), so
the redownload service gains those for free. checkAvailability's 30-line
manual reconstruction becomes copyWith, which also stops silently
dropping previewUrl, totalDiscs, and deezerId.
2026-07-11 16:36:09 +07:00
zarzet b12502c8f6 refactor(settings): replace 18 inline collapsing headers with SettingsSliverAppBar
Every settings-style page carried the same 40-line SliverAppBar block;
titles, custom leadings (discard-confirm back handlers), and action
buttons are preserved through the widget's slots.
2026-07-11 16:36:08 +07:00
zarzet 764f978897 refactor(android): consolidate SAF and filename helpers in SafDownloadHandler
MainActivity duplicated the whole SAF helper cluster (sanitizeFilename,
truncation, ensureDocumentDir, createOrReuseDocumentFile, ...) and both
classes synchronized on separate locks, so a foreground SAF write racing
a service-worker write into the same tree was never serialized and could
produce duplicate documents. One shared home, one lock. normalizeExt
adopts the trim-first variant; mimeTypeForExt is the union of all three
copies (adds wav/aiff/ogg coverage everywhere).
2026-07-11 16:34:37 +07:00
zarzet 8c2d2d5289 refactor(backend): dedupe response builders and MusicBrainz fetch
marshalJSONString collapses 62 identical marshal-and-return blocks in the
gomobile exports; jsError/jsSuccess collapse 135 goja extension-response
maps; the two MusicBrainz fetchers share one request+retry helper; the
extension-HTTP default User-Agent block and fileExists get single homes.
No exported signature changes.
2026-07-11 16:34:36 +07:00
zarzet 9580fafe4f feat(ui): add shared widgets, utils, and models for deduplicated screens
Single homes for logic that was copy-pasted across screens: the settings
collapsing header, album detail header (online screen's design as the
reference), selection pill button + bottom-bar chrome, disc separator
chip, error card, cover URL upgrade, byte/clock formatting, duration
extraction, UnifiedLibraryItem (moved out of the queue_tab library so
other screens can import it), and the batch convert/ReplayGain engine
keyed on it.
2026-07-11 16:34:16 +07:00
zarzet 49ecfe261a feat(metadata): route MP3, Ogg, and M4A edits to the native writers
EditFileMetadata now answers native_mp3/native_ogg/native_m4a and only
falls back to method=ffmpeg when a native editor rejects the file, so
every editor UI switches off the remux path without Dart changes. The
download embed path does the same for flac/mp3/opus/m4a. Side effect:
ReplayGain for MP3/Opus was silently never written (the old dispatch
answered ffmpeg and the caller ignored it) - it is now written natively.
Covers all three editors with synthetic-file tests, including foreign-tag
preservation and stco offset tracking.
2026-07-11 12:52:01 +07:00
zarzet 89e5f4bde9 feat(metadata): add native Ogg/Opus vorbis comment editor
EditOggFields rebuilds only the OpusTags/vorbis comment packet and
repaginates the header pages; audio pages are copied verbatim and only
renumbered (with fresh CRCs) when the header page count changes. Cover art
is written as METADATA_BLOCK_PICTURE from the shared FLAC picture builder.
The field semantics are extracted from EditFlacFields into a shared
applyVorbisFieldEdits so FLAC and Ogg interpret edits identically.
Unsupported layouts (multiplexed streams, unknown codecs) return an error
so callers can fall back.
2026-07-11 12:46:51 +07:00
zarzet 2eab955f5f feat(metadata): add native M4A ilst tag editor
EditM4AFields edits standard iTunes atoms, freeform ISRC/LABEL, and
ReplayGain in a single atomic rewrite, preserving atoms it does not
control. Creates the moov>udta>meta>ilst chain (with an mdir hdlr) when a
raw stream lacks one, and shifts stco/co64 offsets for faststart files.
2026-07-11 12:45:38 +07:00
zarzet 2bd671d305 feat(metadata): add native MP3 ID3v2 tag editor
EditMP3Fields parses the existing ID3v2.2/2.3/2.4 tag into raw frames,
replaces only the frames the edit controls, and re-serializes as ID3v2.4
with the audio bytes streamed through untouched (temp+fsync+rename).
Unlike the ffmpeg remux this preserves foreign frames - POPM ratings,
SYLT synced lyrics, chapters, and other taggers' TXXX entries.
2026-07-11 12:45:21 +07:00
zarzet e5a1ee2ac3 fix(metadata): shift stco/co64 chunk offsets when the ilst resizes
writeM4AFreeformTags changed the ilst size without updating chunk offsets,
which broke playback for faststart files (moov before mdat) after any
ISRC/label/ReplayGain write. Reuses the AC-4 path's shiftChunkOffsets.
2026-07-11 12:45:20 +07:00
zarzet 8f997314b0 fix(finalizer): stage conversion outputs and sync SAF streams
Decrypt, HIGH conversion, and container conversion wrote ffmpeg output
directly under a real audio name in the final directory, so a process kill
mid-conversion left a partial file that library scans listed as a corrupt
track. Outputs now stream into a '.partial<ext>' sibling (invisible to
scans, real trailing extension so ffmpeg still infers the muxer) and are
fsynced and renamed into place only on success.

Also fixes the delete-then-rename in the ffmpeg tag embed (a kill between
the two lost the file entirely - now renames over the original) and
flushes+fsyncs SAF output streams before staged documents are published.
2026-07-11 12:44:53 +07:00
zarzet 2192e68684 fix(download): fsync staged downloads before the promote rename
The staged-write-then-rename protocol was atomic against process kills but
not against power loss: f2fs/ext4 can persist the rename before the data,
leaving a zero-length or truncated file under the final name. Sync the
staged file before promoting and fsync the directory after.
2026-07-11 12:44:19 +07:00
zarzet 21aeaafced fix(metadata): write FLAC, M4A, and AC-4 tags atomically with fsync
go-flac's Save(samePath) rewrites the file in place by shifting the audio
body within the same inode, so a process kill or power loss mid-save
destroyed the file with no recovery copy. All tag writers now stream to a
sibling temp, fsync, and rename over the target, so an interruption leaves
either the old intact file or the new complete one. The M4A freeform and
AC-4 whole-file rewrites get the same treatment, and the WAV/AIFF writer
gains the missing fsync before its rename.
2026-07-11 12:44:01 +07:00
Zarz Eleutherius bbfc724ceb New translations app_en.arb (German)
[ci skip]
2026-07-10 19:53:11 +07:00
Zarz Eleutherius 7d3a6f9780 New translations app_en.arb (German)
[ci skip]
2026-07-10 18:47:47 +07:00
Zarz Eleutherius 19fccde3a3 New translations app_en.arb (Korean)
[ci skip]
2026-07-10 17:24:34 +07:00
Zarz Eleutherius 2cc2ac6efd New translations app_en.arb (Korean)
[ci skip]
2026-07-10 16:26:59 +07:00
zarzet 270496b3d5 refactor(lyrics): classify provider errors by type, not message text
Replace the substring signal lists in isLyricsProviderUnavailableError
with typed errors (errLyricsNotFound / errLyricsServiceUnavailable in
the new lyrics_errors.go). Providers now classify failures at the point
of origin: HTTP statuses via lyricsHTTPStatusError (429/5xx cooldown),
netease API codes and paxsenix proxy failures as explicit unavailable,
and every "no lyrics/songs found" path as typed not-found.

Substring matching survives only for error payloads from third-party
proxies (rate limit, missing parameters), whose messages genuinely
arrive as free text.

Two deliberate behavior changes: LyricsPlus 429/5xx responses now cool
the provider down (previously unclassified), and proxy payloads saying
"not found" are treated as not-found instead of disabling the provider.
2026-07-10 15:11:34 +07:00
zarzet db477f970c chore: bump version to 4.8.0-beta.1 2026-07-10 14:59:32 +07:00
Zarz Eleutherius d1ede223cf New translations app_en.arb (Korean)
[ci skip]
2026-07-10 14:04:22 +07:00
zarzet 0d39002af0 perf(native-worker): stop shipping the full item payload on every poll
The 1s snapshot poll re-read and re-parsed the whole worker state file
— which retains every completed item's result (history row, possibly
full synced lyrics; 1.5-5 KB each) for the entire run — then
re-serialized it across the channel and re-iterated it on the Dart UI
isolate. Late in a 1000-track batch that is megabytes parsed several
times per second, on the Android main thread.

Pollers now echo back the state_serial of the last snapshot they fully
processed; when the state has not advanced, the native side serves a
cached compact header (no items, no results, no settings) merged with
the small progress delta — steady-state polls are O(1) regardless of
batch size, and the full payload is delivered exactly once per state
transition. The channel handler also reads and parses off the main
thread now, matching its neighbours.
2026-07-10 13:08:57 +07:00
zarzet 87b46a9694 perf(dedupe): make the ISRC index incremental, cover-free, and batch-stable
The index was rebuilt from scratch every 5 minutes even while
AddToISRCIndex kept it write-consistent after every download, and each
rebuild parsed the FULL metadata of every FLAC in the library —
including multi-megabyte embedded cover art — sequentially, with the
triggering download blocked behind the build lock. For a few thousand
files that is gigabytes of flash I/O per rebuild, repeated throughout
a long batch.

- Indexing now reads only the Vorbis comment block (block headers are
  walked and picture/padding payloads seeked past, never loaded)
- A per-file (size, mtime) -> ISRC cache carries across rebuilds, so a
  rebuild is normally a stat walk that re-reads only changed files
- Add() refreshes the index timestamp, so the TTL no longer forces a
  rebuild in the middle of the workload the index exists to serve
- Cold builds parse with 4 workers, matching the library scanner
2026-07-10 13:08:55 +07:00
zarzet 364b2aa138 fix(metadata): stop the endless FLAC-write failures on misnamed files
When a provider delivers a lossy/unknown stream, the native finalizer
preserved the container but kept the requested .flac name, so every
metadata/ReplayGain write treated the file as FLAC and failed with
"failed to write FLAC metadata: failed to parse FLAC file: fLaC head
incorrect" — on the first download, on every retry (the file is never
deleted from public storage), on every manual edit attempt, and the
history backfill re-probed the same file on every app launch forever.

- Finalizer now renames a preserved lossy/unknown container away from
  its .flac name to match the real content (aac/MP4 -> .m4a, mp3, opus),
  mirroring the Dart pipeline, so the correct tag writer is picked
- EditFileMetadata sniffs MP4 content before taking the .flac branch
  and reports what is actually wrong (rename to .m4a) instead of the
  cryptic parse error
- The history audio-metadata backfill remembers paths whose probe
  failed permanently (unparseable content) and stops reselecting them
  on every launch; transient failures (missing file, unmounted volume)
  still retry
2026-07-10 12:07:11 +07:00
zarzet df1ac30e07 Merge Crowdin translation updates (l10n_main, #428)
Three-way per-key merge: Crowdin wins for keys it updated, locally
added keys that Crowdin has not exported yet are kept. Also normalizes
@@locale to underscore form (es_ES, pt_PT, zh_CN, zh_TW) and repairs
two translations whose placeholders diverged from the template
(ar extensionsInstallPartialSuccess, es_ES cueSplitSplitting).
2026-07-10 11:37:53 +07:00
zarzet 196fd0f651 feat(ios): run verification and OAuth through ASWebAuthenticationSession
The captcha/OAuth flow relied on the OS routing the spotiflac://
callback back into the app. In sideload containers (LiveContainer)
the guest app's URL scheme is never registered with iOS, so after
solving the challenge the redirect went nowhere and the user could
not return to the app (LiveContainer#242/#162 — unresolved upstream).

ASWebAuthenticationSession intercepts the callback scheme in-process,
so no OS-level scheme registration is involved: the session sheet
closes itself on completion and the callback URL is fed into the same
deep-link handler the OS path uses. Verification challenges, the help
dialog's open-browser action, and extension OAuth logins all prefer
the session on iOS, falling back to url_launcher if it fails to start.
Safari's cookie store is shared so captcha providers see an
established browsing context.
2026-07-10 11:25:55 +07:00
zarzet b28e8a83a8 fix(queue): keep create-playlist label at natural size
Making the button flexible let a tight layout squeeze its label away
entirely. Only the count text needs to give way — it ellipsizes while
the action buttons keep their full labels.
2026-07-10 11:13:58 +07:00
zarzet a8cc42ed30 fix(queue): prevent header row overflow on narrow screens
The track/album count header rows sized their text and action buttons
at natural width; on narrow layouts (or long localized labels) the
row overflowed by a few pixels. The count text and the create-playlist
button label now flex and ellipsize instead.
2026-07-10 10:36:02 +07:00
zarzet b4f66ff4eb chore(backend): align merged test file and fd stubs with lint conventions 2026-07-10 10:25:12 +07:00
zarzet b1f8224310 test(extensions): skip POSIX permission assertion on Windows
The signed-session test from #462 asserts the session file is 0600,
but Windows does not preserve Unix permission bits, so the suite failed
on Windows dev machines while passing in CI.
2026-07-10 10:24:25 +07:00
zarzet f808a6a369 fix(android): stop double-destroying the shared audio_service engine
MainActivity only overrode provideFlutterEngine, so the activity's
fragment delegate treated the plugin-cached engine as its own
(createFlutterFragment forwards shouldDestroyEngineWithHost, default
true) and destroyed it when the activity finished, while the engine
stayed registered in AudioServicePlugin's FlutterEngineCache. When
AudioService stopped afterwards, disposeFlutterEngine() called
destroy() on the already destroyed engine and crashed the app with
"Cannot execute operation because FlutterJNI is not attached to
native".

Mirror audio_service's own AudioServiceFragmentActivity: expose the
plugin engine as a cached engine (getCachedEngineId), never destroy it
with the host (shouldDestroyEngineWithHost = false), and pre-create it
in onCreate. The plugin remains the engine's sole owner and disposes
it only once, when the service stops with no activity attached.
2026-07-10 10:22:39 +07:00
zarzet 7d73e77f9b fix(renovate): target main instead of nonexistent dev branch
baseBranches pointed at a dev branch that does not exist on the remote,
so Renovate has been silently opening no update PRs at all.
2026-07-10 10:18:04 +07:00
zarzet 79e37769e5 chore(deps): upgrade Gradle to 9.6.1, Kotlin to 2.4.0, lifecycle to 2.11.0
AGP stays at 9.2.1 (already the latest stable). Verified with a full
debug APK build against a freshly bound gobackend.aar.
2026-07-10 10:18:04 +07:00
zarzet 9b7f1631f0 fix(native-worker): keep batches and retries intact across seam races
- Verification challenge no longer abandons the batch: batch mates the
  worker cancel would mark skipped are requeued and fenced from later
  snapshot applications, and the adoption loop restarts the queue for
  requeued items when the run ends
- Pause vs cancel race: a cancelled result with no pause flag set waits
  up to 1.5s for the pause intent to land on the main looper before
  being classified as a permanent skip
- Stale Go cancel flag: new ResetDownloadCancel export (wired through
  Android and iOS bridges) drops a pre-registered cancel with no active
  download; retryItem/retryAllFailed call it so the first retry of a
  cancelled-before-start item no longer aborts instantly
- Reconcile-loop errors now cancel the native worker and clear the run
  id before marking items failed, instead of leaving the service
  downloading a batch the UI already wrote off
2026-07-10 10:18:03 +07:00
zarzet d819878ec7 fix(downloads): stage direct-mode outputs and serialize same-path writes
The non-SAF filesystem path (Android direct mode and all of iOS) had
none of the safeguards the SAF path gained: extension downloads
streamed straight into the final filename, every error path stranded
the partial file there, and the ISRC duplicate check (exists + size>0
with a head-only FLAC parse) then accepted the truncated file as
complete forever. On iOS this was routine, not rare: the background
task grace is ~30s, after which the process is suspended mid-stream.

- fileDownload/fileDownloadChunked now stream into a .partial sibling
  and promote to the final name with an atomic rename on success;
  failures remove the staged file and never touch the final name
- fileWrite (full-content) uses the same stage-and-rename protocol
- writes are serialized per final output path, so concurrent queue
  items that resolve to the same filename can no longer interleave
  bytes into one file

The staged suffix keeps partials invisible to extension duplicate
checks, which match on the final audio extension.
2026-07-10 10:18:03 +07:00
zarzet 823b050744 chore(deps): update Go modules
go get -u across the backend: goja 2026-07-01, golang.org/x/* latest,
brotli 1.2.2, klauspost/compress 1.19. Toolchain stays on go1.25.9;
CI and docs now pin 1.25.9 explicitly.
2026-07-10 10:18:02 +07:00
zarzet d7df859f1e chore(deps): upgrade Flutter packages
Notable: ffmpeg_kit_flutter_new_full 2.1.0 -> 2.2.2 (new native FFmpeg
binaries on both platforms), file_picker 12.0.0-beta.5 -> beta.7,
audio_service 0.18.19, video_player platform packages 2.11.0.
2026-07-10 10:18:02 +07:00
zarzet 8313f3c827 fix(saf): stage all SAF downloads, serialize same-name writes, safe replace
- All SAF downloads now write under a staged .partial name; the handler
  itself promotes to the final name for legacy callers (the foreground
  Dart queue), so a killed process can never leave a half-written file
  that the exists check then accepts as complete forever. Stale partials
  are removed before reuse since fd truncation is best-effort on some
  providers.
- The exists-check/create/write/publish sequence is serialized per
  target file (tree URI + dir + name); two concurrent downloads that
  sanitize to the same display name can no longer interleave writes
  into one document — the second waits and dedupes via already_exists.
- Replacing an existing final file now renames it aside and deletes it
  only after the staged copy holds the final name; a failed rename
  restores it instead of losing both copies.
- The FFmpeg command pump always delivers a result for every claimed
  command id, including on exception or cancellation; previously a
  cancel mid-command stranded the Go post-processing call forever with
  the finalizer thread blocked inside it.
2026-07-10 10:18:02 +07:00
zarzet 22151b0988 fix(native-worker): recover from dead runs and make queue replacement safe
Stale snapshot (queue wedged forever): the service is START_NOT_STICKY,
so a process kill or reboot mid-batch froze the state file at
is_running:true with nothing ever rewriting it. Adoption then set
isProcessing and polled that flag indefinitely; restored items sat at
downloading/queued until the user intervened. Adoption and the
reconcile poll now check DownloadService.isServiceRunning() (three
consecutive dead polls in the loop), reconcile whatever the worker
finished, requeue in-flight items, clear the run id, and hand the batch
back to the Dart queue.

Adoption gate race (duplicate executors): adoption was gated on
_canUseAndroidNativeWorker, which reads extension state that loads
asynchronously after startup and settings toggles the user may have
flipped mid-run. A background batch surviving task removal was then
orphaned while the Dart queue re-downloaded the same items. Adoption
now only requires Android; request contexts that cannot be built yet
(extensions still loading) are retried on each reconcile poll.

Queue replacement race (superseded worker kept running): startNativeWorker
only cancelled the coroutine, which cannot interrupt the blocking
gomobile call, and it reset the shared cancel flag the old loop checks -
so a replaced worker kept downloading its old batch, and its finally
block wrote is_running:false over the new run's snapshot and stopSelf'd
the service underneath it. Replacement now cancels the old run's Go
downloads and FFmpeg work first, and workers carry a generation token:
a superseded generation exits at the next checkpoint and skips the
final snapshot write and service teardown. An invalid replacement
payload no longer stops the service while a run is active.
2026-07-10 10:18:01 +07:00
zarzet fb3fad279c fix(native-worker): never delete a published download over bookkeeping failures
The finalizer's catch-all cleanup deleted the finished output for any
exception, including ones thrown after the file was already published
to SAF. A failed history insert (SQLITE_BUSY against the concurrently
open Flutter connection, disk full, or a schema-version mismatch that
would throw on every download), an external .lrc write error, or a
quality-metadata refresh failure destroyed a complete, valid track and
forced a full re-download.

- Track outputPublished and skip cleanupFailedFinalizationOutput once
  the file reached its final destination
- Make the history insert, external LRC write, and quality metadata
  refresh non-fatal (log + history_written:false instead of failing
  the item)
2026-07-10 10:18:01 +07:00
zarzet 7f7e408623 ci: enforce gofmt on the Go backend
The package is fully gofmt-clean after the modernization pass, so the
formatting check deferred from the initial CI setup can now be enabled.
2026-07-10 10:18:01 +07:00
zarzet 8abb99ac91 chore(backend): modernize interface{} to any, silence lint noise
gofmt -r rewrite of all 506 interface{} occurrences to the any alias
(identical semantics), drop unused spec constants into comments, rename
unused parameters to _, convert three if-else chains to tagged
switches, and use slices.ContainsFunc for the private-IP check. No
behavior change; the whole package is now gofmt-clean.
2026-07-10 10:18:00 +07:00
zarzet e85151643f fix(extensions): serialize storage/credential/salt file writes across runtimes
Each isolated download runtime carried its own storage write mutex, so
two concurrent downloads through the same extension could interleave
writes to the shared storage file, the encrypted credentials file, or -
worst - both generate different credential salts, making the loser's
credentials undecryptable. Guard all three files with a process-wide
per-path mutex and write via temp file + rename so readers never see a
torn file. Groundwork for concurrent downloads.
2026-07-10 10:18:00 +07:00
zarzet 1998a9300e feat(downloads): support up to 3 concurrent downloads
The Dart queue loop was already built around an activeDownloads map and
per-item progress tracking but was gated to one download at a time. Add
a concurrentDownloads setting (1-3, default 1) exposed in Download
Settings > Performance, and let the scheduler keep up to that many
items in flight. The experimental Android native worker is still
strictly sequential, so it is skipped in favor of the Dart queue while
concurrency is enabled.
2026-07-10 10:17:59 +07:00
zarzet 19f69a6090 refactor: dispatch on MusicServices constants instead of raw service ids
Service ids ('deezer', 'tidal', 'qobuz', 'spotify', 'amazon', 'local')
were compared as raw string literals in every dispatch chain. Introduce
MusicServices constants and use them at the comparison/switch sites so
a typo becomes a compile error instead of a silently dead branch.
2026-07-10 10:17:59 +07:00
zarzet c24a72c302 refactor(metadata): split _TrackMetadataScreenState into part files
track_metadata_screen.dart was 5,470 lines, most of it one State class
mixing five surfaces. Move four cohesive method clusters into private
extensions on _TrackMetadataScreenState in new part files: header/info
cards, lyrics + save/re-enrich flows, audio conversion + CUE split, and
file actions. Same library, so private state access is unchanged;
setState goes through a _setState forwarder as in queue_tab. The screen
file is now 1,575 lines.
2026-07-10 10:17:58 +07:00
zarzet 860649e3ac refactor(queue): split _QueueTabState method clusters into part files
queue_tab.dart was 7,567 lines with a single 7,300-line State class.
Continue the existing part-file split by moving six cohesive method
clusters into private extensions on _QueueTabState (same library, so
private state access is unchanged): selection mode, navigation/open
actions, collection item builders, filter UI, batch actions, and
queue/library item builders. setState calls in the parts go through a
_setState forwarder since @protected members cannot be called from
extensions. queue_tab.dart is now 2,462 lines.

# Conflicts:
#	lib/screens/queue_tab.dart
2026-07-10 10:15:56 +07:00
zarzet 2e7e2b1964 refactor(ui): share legacy provider-id resource helpers
legacyProviderIdFromResourceId and stripPrefixedResourceId were
copy-pasted in the album, artist, and playlist screens with slightly
different null semantics; the shared nullable variant plus explicit
?? 'spotify' fallbacks makes the difference visible at the call sites.
2026-07-10 10:12:20 +07:00
zarzet 70cba44164 refactor(ui): merge duplicated album/playlist track rows into TrackListTile
_AlbumTrackItem and _PlaylistTrackItem were line-for-line identical
except for the leading widget and whether the artist name is clickable;
both are now parameters of a shared TrackListTile.
2026-07-10 10:12:20 +07:00
zarzet 28b877f0eb refactor(providers): split download history out of download_queue_provider
download_queue_provider.dart (9,525 lines) hosted the entire download
history feature alongside the queue. Move DownloadHistoryItem/State/
Notifier and the history providers to download_history_provider.dart,
and promote the audio format/quality helpers both need into
lib/utils/audio_format_utils.dart. The queue provider re-exports the
history library, so existing imports keep working unchanged.
2026-07-10 10:12:20 +07:00
zarzet 32892bd4bd chore(i18n): localize folder-access download errors, drop em dashes
The two folder-access failure messages are stored on items as fixed
English sentinels so code can match on them; translate the known
sentinels at display time (error dialog and failed-row message) via new
downloadErrorSafPermissionLost / downloadErrorFolderAccessLost keys.
Also replace em dashes in recently added comments.
2026-07-10 10:12:19 +07:00
zarzet bb66cc470a fix(queue): keep just-completed tracks pinned while the batch is running
A track that finished downloading dropped out of the active lead zone
and re-appeared in the history zone below every still-queued sibling,
visually jumping several positions during album downloads. The existing
completion bridge masked this for only 6 seconds.

Keep completion-bridge cells pinned in the lead zone for as long as
other downloads are still active, and hide their history rows while
pinned so tracks don't render twice. Once the queue goes idle the
bridge dismisses and items settle into their normal history position.
2026-07-10 10:12:19 +07:00
zarzet 60bf871945 fix(downloads): flush queue persistence when app is backgrounded
Queue writes are debounced by 350 ms and the only guaranteed flush ran
in the provider's onDispose, which never fires when the OS kills the
backgrounded process - the most recent queue mutations were silently
lost, so items could vanish after the app sat minimised for a while.

Expose flushQueuePersistence() on the queue notifier and call it from
the app lifecycle observer on AppLifecycleState.paused, the last
reliable callback before a potential process kill.
2026-07-10 10:12:18 +07:00
zarzet d988b2c9d3 fix(downloads): defer verification challenge until app is foregrounded
When a download hit a verification challenge while the app was in the
background, the browser launch was attempted immediately (blocked or
silently dropped by the OS) and the 5-minute grant timeout kept running,
so the item failed without the user ever seeing the CAPTCHA.

Wait for AppLifecycleState.resumed before opening the challenge and
before starting the grant timeout, and post a local notification asking
the user to return to the app. If the process is killed while waiting,
the persisted queue already restores the item as queued on next launch.
2026-07-10 10:12:18 +07:00
zarzet 2f53be7c0d fix(extensions): expire stale pending auth challenges
A pending verification challenge lived in a process-global map with no
timestamp, so a challenge created while the app was backgrounded (and
never completed) was served again verbatim on the next attempt - sending
the user to an already-expired CAPTCHA page that needed a second request.

Stamp PendingAuthRequest with CreatedAt at every writer and make
ensureExtensionPendingAuthRequest discard entries older than 5 minutes,
starting a fresh signed-session verification instead.
2026-07-10 10:12:18 +07:00
zarzet a1764b1aa0 fix(metadata): close FLAC file handles after parsing
flac.ParseFile leaks the *os.File on parse errors, and read-only callers
(ReadMetadata, ExtractCoverArt, extractLyricsFromFlac) never closed the
handle on success either. Long library or duplicate-index scans leaked
one fd per FLAC file, and on Windows the lingering handles kept files
locked (the source of the flaky TempDir cleanup failure in
TestExportsJSONWrappersAndExtensionManagerSurface).

Route all parsing through parseFlacFile, which closes on error, and
defer f.Close() at every call site. File.Save already closes the
underlying handle; the deferred second Close is a harmless no-op.
2026-07-10 10:12:17 +07:00
zarzet 99eed524c2 refactor(backend): split extension_providers.go into themed files
Pure mechanical move within package gobackend — no signature or behavior
change. extension_providers.go (3,798 lines) now holds only the
extensionManager provider-listing/orchestration methods; DTOs, goja value
conversion, the provider wrapper methods, download fallback, and priority
state moved to extension_{provider_types,goja_convert,provider_wrapper,
fallback,priority}.go.
2026-07-10 10:12:17 +07:00
zarzet d909067c49 refactor(backend): split exports.go into themed files
Pure mechanical move within package gobackend — no signature or behavior
change, so the gomobile binding surface is identical. exports.go (4,141
lines) now holds only availability/dir/duplicate/filename exports; the
rest moved to exports_{musicbrainz,download,reenrich,metadata,lyrics,
deezer,extensions,store,library}.go along existing responsibility
boundaries.
2026-07-10 10:12:17 +07:00
zarzet 927fc8abac feat(downloads): detect lost SAF grant and offer folder re-selection
A persisted SAF tree grant can die silently (folder deleted or renamed,
SD card removed, app data reset, system revocation). Downloads then fail
with no recovery path beyond digging through Settings.

- Add isSafTreeAccessible bridge method: the grant must still be in the
  system's persisted permission list and the tree writable
- Show a warning banner in Files & Folders when the saved SAF folder is
  no longer accessible, with an inline re-select action
- In the download error dialog, replace Retry with a Re-select folder
  action when the failure is a lost folder grant (Android SAF or iOS
  bookmark); a successful re-pick retries the item automatically
- Share the folder-access error messages as constants so the UI can
  match on them reliably
2026-07-10 10:12:16 +07:00
zarzet 53824b7ac2 fix(ios): create security-scoped bookmark inside native folder picker session
The download/library folder picker on iOS previously took the bare path
string returned by file_picker and rebuilt a URL from it to create a
security-scoped bookmark. By then the picker's access grant is gone, so
bookmark creation either failed (folder selection silently rejected) or
produced a bookmark that could not be re-opened, after which the first
download run overwrote the user's chosen folder back to app Documents.

- Add a native pickIosDirectory method channel that presents
  UIDocumentPickerViewController itself and creates the bookmark inside
  the delegate callback, while the security-scoped grant is active
- Use it for the download directory (settings + first-run setup) and
  the local library folder pickers
- Stop overwriting the saved download directory when the bookmark fails
  at queue start; fail queued items with a clear message instead
- Skip the launch-time iOS path normalizer when a bookmark exists, so
  it no longer rewrites external folder paths and drops their bookmark

Fixes #454
2026-07-10 10:12:16 +07:00
zarzet fd7424dd81 refactor(ui): extract shared widgets, drop dead screens
Extract InLibraryBadge (pasted verbatim in five screens) and
playLocalIfAvailable (duplicated in three) into shared code, and delete
SearchScreen, LibraryPlaylistsScreen, and CollapsingHeader, which are
imported nowhere in lib/.
2026-07-10 10:12:15 +07:00
zarzet 46491c137a docs(contributing): fix branch refs and build steps, add PR template 2026-07-10 10:12:15 +07:00
zarzet 0535b266ff ci: run flutter analyze/test and go vet/test on pull requests 2026-07-10 10:12:15 +07:00
zarzet d20f569678 test(download): cover playlist_position in payload serialization test 2026-07-10 10:12:14 +07:00
zarzet 4c83aeafb5 feat(metadata): surface explicit content flag with [E] badge
Port of the explicit-content indicator from SpotiFLAC-Web (issue #456):

- Go: add explicit to ExtTrackMetadata (parsed generically from any
  extension via explicit/is_explicit/isExplicit), TrackMetadata, and
  AlbumTrackMetadata; built-in Deezer maps explicit_lyrics and
  explicit_content_lyrics == 1
- Dart: add explicit to the Track model with a tolerant
  parseExplicitFlag helper wired into all backend track parsers
- UI: new ExplicitBadge rendered through buildQualityBadges on album,
  playlist, search, and home track rows
2026-07-10 10:12:14 +07:00
zarzet 8abb9d119b fix(store): harden extension repo registry handling
- Persist registry URL inside store_cache.json and restore it on init so
  the disk cache survives app restarts instead of being wiped every launch
- Fall back to the cached registry when the fetched body fails to parse,
  matching the existing network/HTTP error fallbacks
- Detect HTML responses and surface a clear message instead of the raw
  Go JSON error (invalid character '<') seen in issue #474
- Validate the registry loads before persisting a new URL, and roll the
  backend back to the previous URL on failure, so a broken link never
  survives a restart or Android auto-backup restore
2026-07-10 10:12:13 +07:00
zarzet 6b30eba947 feat(extensions): complete signed-session grants from help dialog
Add a platform bridge to finish session grants on Android and iOS with
JSON success validation, let users paste callback URLs from the
clipboard, and auto-dismiss the verification help dialog after grant.
2026-07-10 10:12:13 +07:00
zarzet edb241faf7 fix(download): suffix batch filenames with track number when needed
Mark multi-track queue additions as batch downloads and append
{track:02} - {title} when the configured format has no uniqueness
token, instead of relying on playlist position alone.
2026-07-10 10:12:12 +07:00
rrbharathandGitHub 49fa6c14e6 Update link to Extension Development Guide (#380) 2026-07-10 10:11:20 +07:00
AmixandGitHub 89c9fa323b docs: align contributing guide with actual branch and Riverpod pattern (#461)
The repo has no `dev` branch (only `main` and l10n-related branches),
and recent history shows PRs merge straight into `main` — update the
fork/PR instructions to match. Also swap the Riverpod example for the
hand-written `Notifier` pattern actually used across lib/providers/,
since the codebase doesn't use riverpod_annotation code generation.
2026-07-10 10:11:16 +07:00
AmixandGitHub fc2818dd97 test(extensions): add coverage for signed session HMAC auth flow (#462)
extension_signed_session.go implements the HMAC-signed session
scheme extensions use to authenticate against their own backends
(namespace scoping, on-disk persistence, rolling-key signing,
expiry/refresh, and the grant exchange flow), but had zero direct
or indirect test coverage.

Adds table-driven tests for the pure helpers (namespace
sanitization, config defaults, time parsing, URL building,
Retry-After parsing, record scope normalization) plus
runtime-backed tests using a mocked http.RoundTripper for the
stateful flows: file persistence round-trips, status/clear,
grant exchange and refresh, 401-triggered session revocation, and
unauthenticated fetches surfacing the verification URL.

The centerpiece is TestDoSignedSessionRequestSignature, which
recomputes the rolling-key HMAC signature server-side from the
request headers the client actually sent, the same way a real
backend would validate it, to catch any accidental regression in
the signing scheme itself (field order, rolling-key derivation, or
header names).
2026-07-10 10:11:12 +07:00
AmixandGitHub c281763718 fix(ui): use State's context instead of stale parameter in CSV import (#460)
_importCsv guards every other post-await BuildContext use with the
State's own `mounted`/`this.context`, but one spot read the l10n
strings off the function's `context` parameter instead, requiring a
`// ignore: use_build_context_synchronously` to silence the analyzer.
Switching to `this.context` matches the surrounding guard and lets the
lint verify the usage on its own, so the suppression is no longer
needed.
2026-07-10 10:11:04 +07:00
Hamidreza BayatandGitHub e97ecf38c5 Use more specific MediaQuery methods to reduce unnecessary rebuilds (#467) 2026-07-10 10:11:00 +07:00
Zarz Eleutherius e2b2e2aac6 New translations app_en.arb (Spanish)
[ci skip]
2026-07-07 05:19:13 +07:00
Zarz Eleutherius 5fe644b079 New translations app_en.arb (Spanish)
[ci skip]
2026-07-07 04:05:05 +07:00
Zarz Eleutherius 9e299c52cd New translations app_en.arb (Korean)
[ci skip]
2026-07-07 00:23:25 +07:00
Zarz Eleutherius 4b69278bec New translations app_en.arb (Korean)
[ci skip]
2026-07-05 22:15:21 +07:00
Zarz Eleutherius 0f3a16c79a New translations app_en.arb (Portuguese)
[ci skip]
2026-07-05 13:29:43 +07:00
Zarz Eleutherius 25f1bd3f10 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-05 12:15:35 +07:00
Zarz Eleutherius aba72ca6b6 New translations app_en.arb (Korean)
[ci skip]
2026-07-05 11:16:45 +07:00
Zarz Eleutherius 6ec6f79b83 New translations app_en.arb (Spanish)
[ci skip]
2026-07-05 02:06:22 +07:00
Zarz Eleutherius 0266ad5ea5 New translations app_en.arb (Spanish)
[ci skip]
2026-07-05 01:09:04 +07:00
Zarz Eleutherius 91c141b7ee New translations app_en.arb (Spanish)
[ci skip]
2026-07-03 04:25:28 +07:00
Zarz Eleutherius 4577ce5ca7 New translations app_en.arb (Spanish)
[ci skip]
2026-07-03 03:26:55 +07:00
Zarz Eleutherius 7d843ca02a New translations app_en.arb (Indonesian)
[ci skip]
2026-07-03 01:58:22 +07:00
Zarz Eleutherius 89de08e08c New translations app_en.arb (Korean)
[ci skip]
2026-07-03 00:04:38 +07:00
Zarz Eleutherius 02213d1edf New translations app_en.arb (Korean)
[ci skip]
2026-07-02 21:58:18 +07:00
Zarz Eleutherius 9dedb66800 New translations app_en.arb (French)
[ci skip]
2026-07-02 14:36:38 +07:00
Zarz Eleutherius 24408e0cba New translations app_en.arb (Korean)
[ci skip]
2026-07-02 12:18:01 +07:00
Zarz Eleutherius 4d251a2a0d New translations app_en.arb (Korean)
[ci skip]
2026-07-02 10:04:54 +07:00
Zarz Eleutherius 52972eefef New translations app_en.arb (Korean)
[ci skip]
2026-07-02 09:09:05 +07:00
Zarz Eleutherius 471b412dc5 New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 07:02:34 +07:00
Zarz Eleutherius 4c85a8f05e New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 05:43:37 +07:00
Zarz Eleutherius 0c441b2c86 New translations app_en.arb (Hindi)
[ci skip]
2026-07-02 02:32:59 +07:00
Zarz Eleutherius f6ddd62b13 New translations app_en.arb (Indonesian)
[ci skip]
2026-07-02 02:32:58 +07:00
Zarz Eleutherius 5fd67e53c4 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-07-02 02:32:56 +07:00
Zarz Eleutherius 1206c57d6c New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-07-02 02:32:54 +07:00
Zarz Eleutherius 38815fa0aa New translations app_en.arb (Ukrainian)
[ci skip]
2026-07-02 02:32:52 +07:00
Zarz Eleutherius 959206b0be New translations app_en.arb (Turkish)
[ci skip]
2026-07-02 02:32:51 +07:00
Zarz Eleutherius 7b7ee523fd New translations app_en.arb (Russian)
[ci skip]
2026-07-02 02:32:49 +07:00
Zarz Eleutherius b1220b4c19 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-02 02:32:47 +07:00
Zarz Eleutherius 62b894a7ba New translations app_en.arb (Dutch)
[ci skip]
2026-07-02 02:32:45 +07:00
Zarz Eleutherius 45188ef87d New translations app_en.arb (Korean)
[ci skip]
2026-07-02 02:32:43 +07:00
Zarz Eleutherius 2185ccf2c9 New translations app_en.arb (Japanese)
[ci skip]
2026-07-02 02:32:42 +07:00
Zarz Eleutherius e8d95178c7 New translations app_en.arb (German)
[ci skip]
2026-07-02 02:32:40 +07:00
Zarz Eleutherius a3ebe27b76 New translations app_en.arb (Arabic)
[ci skip]
2026-07-02 02:32:38 +07:00
Zarz Eleutherius b39d4fa15a New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 02:32:37 +07:00
Zarz Eleutherius f86b2b816b New translations app_en.arb (French)
[ci skip]
2026-07-02 02:32:35 +07:00
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
Zarz Eleutherius 308a75037e New translations app_en.arb (Korean)
[ci skip]
2026-07-02 00:58:11 +07:00
Zarz Eleutherius bda41a0c1a New translations app_en.arb (Korean)
[ci skip]
2026-07-02 00:01:11 +07:00
Zarz Eleutherius ec5e7ed1cd New translations app_en.arb (Arabic)
[ci skip]
2026-07-02 00:01:10 +07:00
Zarz Eleutherius 6528d445d3 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 22:52:54 +07:00
Zarz Eleutherius aacf1bef9c New translations app_en.arb (Korean)
[ci skip]
2026-07-01 21:38:38 +07:00
Zarz Eleutherius 4262553036 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 20:35:15 +07:00
Zarz Eleutherius a3d6c6f916 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 19:03:56 +07:00
Zarz Eleutherius bce05eb071 New translations app_en.arb (French)
[ci skip]
2026-07-01 19:03:54 +07:00
Zarz Eleutherius e35ea061d5 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 17:52:36 +07:00
Zarz Eleutherius 8c7c168fdf New translations app_en.arb (French)
[ci skip]
2026-07-01 17:52:34 +07:00
Zarz Eleutherius 7a811ad55e New translations app_en.arb (Korean)
[ci skip]
2026-07-01 16:56:33 +07:00
Zarz Eleutherius 8d29be2a6d New translations app_en.arb (Korean)
[ci skip]
2026-07-01 15:56:58 +07:00
Zarz Eleutherius 36567f40a1 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 14:31:17 +07:00
Zarz Eleutherius 76e0de7d20 New translations app_en.arb (French)
[ci skip]
2026-07-01 14:31:15 +07:00
Zarz Eleutherius 74a41a2d49 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 12:53:58 +07:00
Zarz Eleutherius 1ba82df9e3 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 11:36:48 +07:00
Zarz Eleutherius ccf995ec11 New translations app_en.arb (French)
[ci skip]
2026-07-01 05:21:22 +07:00
Zarz Eleutherius 159b7c2ab6 New translations app_en.arb (Hindi)
[ci skip]
2026-07-01 04:22:04 +07:00
Zarz Eleutherius dc60ad1137 New translations app_en.arb (Indonesian)
[ci skip]
2026-07-01 04:22:02 +07:00
Zarz Eleutherius 1c46708303 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-07-01 04:22:01 +07:00
Zarz Eleutherius 532042e39e New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-07-01 04:21:59 +07:00
Zarz Eleutherius 5cb6faa2d6 New translations app_en.arb (Ukrainian)
[ci skip]
2026-07-01 04:21:57 +07:00
Zarz Eleutherius 91b88a40ea New translations app_en.arb (Turkish)
[ci skip]
2026-07-01 04:21:56 +07:00
Zarz Eleutherius 72a9691c55 New translations app_en.arb (Russian)
[ci skip]
2026-07-01 04:21:54 +07:00
Zarz Eleutherius 00e8280d22 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-01 04:21:52 +07:00
Zarz Eleutherius ed2c7606c3 New translations app_en.arb (Dutch)
[ci skip]
2026-07-01 04:21:51 +07:00
Zarz Eleutherius c1a6262d96 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 04:21:49 +07:00
Zarz Eleutherius 59b953d577 New translations app_en.arb (Japanese)
[ci skip]
2026-07-01 04:21:47 +07:00
Zarz Eleutherius 0854174140 New translations app_en.arb (German)
[ci skip]
2026-07-01 04:21:45 +07:00
Zarz Eleutherius 3802df0abd New translations app_en.arb (Arabic)
[ci skip]
2026-07-01 04:21:44 +07:00
Zarz Eleutherius 57f09cc50c New translations app_en.arb (Spanish)
[ci skip]
2026-07-01 04:21:42 +07:00
Zarz Eleutherius 5257a035d7 New translations app_en.arb (French)
[ci skip]
2026-07-01 04:21:40 +07:00
github-actions[bot] eb36b0bb7b chore: update AltStore source to v4.7.0 2026-06-30 20:59:56 +00:00
Zarz Eleutherius 0cab036718 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 01:47:32 +07:00
Zarz Eleutherius 4fabd8cfd0 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 23:54:56 +07:00
Zarz Eleutherius 6b7f63d784 New translations app_en.arb (Korean)
[ci skip]
2026-06-30 23:54:55 +07:00
Zarz Eleutherius 95b56c80c5 New translations app_en.arb (Korean)
[ci skip]
2026-06-30 21:51:26 +07:00
Zarz Eleutherius f2020b7653 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-30 17:01:36 +07:00
Zarz Eleutherius 97bb4f6641 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 17:01:34 +07:00
Zarz Eleutherius adeed5ae13 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 14:43:55 +07:00
Zarz Eleutherius 5e9cce4b8c New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 08:55:15 +07:00
Zarz Eleutherius 22a8fab1c7 New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 03:44:33 +07:00
Zarz Eleutherius f6a0cc9fad New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 02:11:11 +07:00
Zarz Eleutherius 765f0d63b6 New translations app_en.arb (Portuguese)
[ci skip]
2026-06-29 23:32:47 +07:00
Zarz Eleutherius c193e88990 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 22:21:01 +07:00
Zarz Eleutherius 6398d8f7eb New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 22:20:59 +07:00
Zarz Eleutherius 030b8c5459 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 20:52:21 +07:00
Zarz Eleutherius 469af1ac5d New translations app_en.arb (Korean)
[ci skip]
2026-06-29 18:37:22 +07:00
Zarz Eleutherius 6c635dfd01 New translations app_en.arb (Spanish)
[ci skip]
2026-06-29 16:35:21 +07:00
Zarz Eleutherius 299e93ffe3 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 15:38:14 +07:00
Zarz Eleutherius 4dc5b2ee30 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 13:39:38 +07:00
Zarz Eleutherius 6613461eea New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 09:49:23 +07:00
Zarz Eleutherius 9de442b0c8 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 08:39:04 +07:00
Zarz Eleutherius 6bc65fe559 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 07:35:09 +07:00
Zarz Eleutherius 7da423ee1b New translations app_en.arb (Korean)
[ci skip]
2026-06-29 06:39:15 +07:00
Zarz Eleutherius c6ccefbd72 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 06:39:13 +07:00
Zarz Eleutherius 72d25f576c New translations app_en.arb (Korean)
[ci skip]
2026-06-29 01:20:27 +07:00
Zarz Eleutherius 7ab9481fab New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 22:07:34 +07:00
Zarz Eleutherius f494e03a6e New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 20:57:58 +07:00
Zarz Eleutherius a90dce95a3 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 19:35:19 +07:00
Zarz Eleutherius a35f6ad939 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 18:37:44 +07:00
Zarz Eleutherius 80e704fd26 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 11:03:47 +07:00
Zarz Eleutherius 88fc958729 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 09:34:15 +07:00
Zarz Eleutherius 12d6940a6e New translations app_en.arb (Korean)
[ci skip]
2026-06-27 22:36:44 +07:00
Zarz Eleutherius 34bf726cc1 New translations app_en.arb (Korean)
[ci skip]
2026-06-27 21:33:08 +07:00
Zarz Eleutherius 68af217b7b New translations app_en.arb (Korean)
[ci skip]
2026-06-27 01:30:31 +07:00
Zarz Eleutherius 5eda1c4cb0 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 22:10:10 +07:00
Zarz Eleutherius f5c934f744 New translations app_en.arb (Turkish)
[ci skip]
2026-06-26 20:46:23 +07:00
Zarz Eleutherius 89c71f6b16 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 20:46:21 +07:00
Zarz Eleutherius 4523bc5532 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-26 19:18:41 +07:00
Zarz Eleutherius b9c66e0c7b New translations app_en.arb (Korean)
[ci skip]
2026-06-26 14:12:22 +07:00
Zarz Eleutherius f26c7cfe02 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 11:13:48 +07:00
Zarz Eleutherius 3bbe29c2e8 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 19:21:07 +07:00
Zarz Eleutherius e0cc1f7cb2 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 07:08:49 +07:00
Zarz Eleutherius 9faa1b7961 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 06:01:55 +07:00
Zarz Eleutherius 142d7e639b New translations app_en.arb (Korean)
[ci skip]
2026-06-25 00:45:57 +07:00
Zarz Eleutherius 9e115902b7 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 23:08:31 +07:00
Zarz Eleutherius d2ec68808c New translations app_en.arb (Korean)
[ci skip]
2026-06-24 21:57:39 +07:00
Zarz Eleutherius 352186eb40 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 11:55:48 +07:00
Zarz Eleutherius 68e742b670 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 10:55:05 +07:00
Zarz Eleutherius d7c4586358 New translations app_en.arb (Korean)
[ci skip]
2026-06-23 19:57:10 +07:00
Zarz Eleutherius 84f784e538 New translations app_en.arb (Korean)
[ci skip]
2026-06-23 16:02:14 +07:00
Zarz Eleutherius 5f9822f726 New translations app_en.arb (Korean)
[ci skip]
2026-06-22 23:06:40 +07:00
Zarz Eleutherius 41a1b94811 New translations app_en.arb (Korean)
[ci skip]
2026-06-22 22:05:04 +07:00
Zarz Eleutherius e4b1d39c4e New translations app_en.arb (Korean)
[ci skip]
2026-06-22 20:58:17 +07:00
Zarz Eleutherius 136de95290 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-22 19:25:42 +07:00
Zarz Eleutherius 1afe66cb66 New translations app_en.arb (French)
[ci skip]
2026-06-21 23:58:16 +07:00
Zarz Eleutherius bdf1626273 New translations app_en.arb (French)
[ci skip]
2026-06-21 22:50:25 +07:00
Zarz Eleutherius 87131ad633 New translations app_en.arb (Hindi)
[ci skip]
2026-06-21 21:33:23 +07:00
Zarz Eleutherius 439418e419 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-21 21:33:21 +07:00
Zarz Eleutherius 0eb4e15a8f New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-06-21 21:33:20 +07:00
Zarz Eleutherius 5f3928399c New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-21 21:33:18 +07:00
Zarz Eleutherius 0c0f2d54ad New translations app_en.arb (Ukrainian)
[ci skip]
2026-06-21 21:33:17 +07:00
Zarz Eleutherius 2f54fe6cf6 New translations app_en.arb (Turkish)
[ci skip]
2026-06-21 21:33:15 +07:00
Zarz Eleutherius e827a26458 New translations app_en.arb (Russian)
[ci skip]
2026-06-21 21:33:14 +07:00
Zarz Eleutherius c886d55317 New translations app_en.arb (Portuguese)
[ci skip]
2026-06-21 21:33:13 +07:00
Zarz Eleutherius 0189f576c7 New translations app_en.arb (Dutch)
[ci skip]
2026-06-21 21:33:11 +07:00
Zarz Eleutherius 13c46d0f5e New translations app_en.arb (Korean)
[ci skip]
2026-06-21 21:33:10 +07:00
Zarz Eleutherius 60175108df New translations app_en.arb (Japanese)
[ci skip]
2026-06-21 21:33:08 +07:00
Zarz Eleutherius 7c2b87f49a New translations app_en.arb (German)
[ci skip]
2026-06-21 21:33:07 +07:00
Zarz Eleutherius f33ee40779 New translations app_en.arb (Arabic)
[ci skip]
2026-06-21 21:33:05 +07:00
Zarz Eleutherius b9142bc40c New translations app_en.arb (Spanish)
[ci skip]
2026-06-21 21:33:04 +07:00
Zarz Eleutherius 680e0e0976 New translations app_en.arb (French)
[ci skip]
2026-06-21 21:33:02 +07:00
473 changed files with 140888 additions and 173057 deletions
+24
View File
@@ -0,0 +1,24 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{dart,json,yaml,yml}]
indent_style = space
indent_size = 2
[*.{kt,kts,swift}]
indent_style = space
indent_size = 4
[*.go]
indent_style = tab
[*.md]
trim_trailing_whitespace = false
[*.{bat,cmd,ps1}]
end_of_line = crlf
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.41.5"
"flutter": "3.44.8"
}
+4
View File
@@ -1,5 +1,9 @@
* text=auto eol=lf
# Scripts executed on Unix runners must keep LF line endings.
*.sh text eol=lf
gradlew text eol=lf
# Windows scripts
*.bat text eol=crlf
*.cmd text eol=crlf
+61 -54
View File
@@ -1,30 +1,29 @@
name: Bug Report
description: Report a bug or unexpected behavior
description: Report a reproducible bug or unexpected behavior
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the form below.
Thanks for reporting a SpotiFLAC Mobile bug. Please provide enough detail for another person to reproduce it.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have searched existing issues and this bug hasn't been reported yet
- label: I searched existing issues and did not find this bug.
required: true
- label: I am using the latest version of SpotiFLAC (Stable Version)
- label: I can reproduce this on the release channel selected below.
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is
placeholder: Describe the bug...
label: Description
description: What happened, and what did you expect instead?
placeholder: Include the visible error and any relevant context.
validations:
required: true
@@ -32,29 +31,26 @@ body:
id: steps
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
1. Open ...
2. Enable ...
3. Tap ...
4. Observe ...
validations:
required: true
- type: textarea
id: expected
- type: dropdown
id: trigger
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe what you expected...
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Describe what actually happened...
label: Area
options:
- App startup
- Search or navigation
- Download or verification
- Library or metadata
- Playback or background service
- Audio analysis
- Other
validations:
required: true
@@ -62,62 +58,73 @@ body:
id: version
attributes:
label: App Version
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
placeholder: "e.g., v2.2.0"
description: Find this in Settings > About.
placeholder: "e.g. 4.8.1 (139)"
validations:
required: true
- type: dropdown
id: platform
id: channel
attributes:
label: Platform
description: Which platform are you using?
label: Release Channel
options:
- Android
- iOS
- Stable
- Beta
- Local or development build
validations:
required: true
- type: input
id: device
attributes:
label: Device & OS Version
description: What device and OS version are you using?
placeholder: "e.g., Samsung Galaxy S24, Android 14"
label: Device and OS
placeholder: "e.g. Samsung Galaxy S24, Android 16"
validations:
required: true
- type: dropdown
id: download-service
id: storage
attributes:
label: Download Service
description: Which download service were you using when the bug occurred?
label: Download Storage
options:
- Tidal
- Qobuz
- Amazon Music
- Deezer (search only)
- SAF folder
- App-specific folder
- Not applicable
- Not sure
validations:
required: true
- type: dropdown
id: worker
attributes:
label: Download Worker
options:
- Native worker
- Dart worker
- Not applicable
- Not sure
validations:
required: true
- type: input
id: provider
attributes:
label: Extension or Provider
description: If relevant, include the extension/provider name and version.
placeholder: "Name, version, and selected priority"
- type: textarea
id: logs
attributes:
label: Logs / Screenshots
label: Logs and Media
description: |
If applicable, add logs or screenshots to help explain your problem.
**To get logs:**
1. Go to Settings > Options > Detailed Logging (turn ON)
2. Reproduce the bug
3. Go to Settings > Logs
4. Tap Share button to export logs
placeholder: Paste logs or drag & drop screenshots here...
Enable detailed logging, reproduce the problem, then export logs from Settings > Logs. For an Android cold-start crash, attach `adb logcat -b crash -d`. Add screenshots or a short recording when useful. Remove credentials and personal data first.
placeholder: Paste logs or drag files here.
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context about the problem
placeholder: Add any other context...
placeholder: Settings, frequency, regression version, or anything else that may help.
+2 -2
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: README
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
url: https://github.com/spotiflacapp/SpotiFLAC-Mobile#readme
about: Check the README for setup instructions and FAQ
- name: Extension Development Guide
url: https://spotiflac.zarz.moe/docs
about: Documentation for building SpotiFLAC extensions
about: Documentation for building SpotiFLAC Mobile extensions
+64 -51
View File
@@ -1,111 +1,124 @@
name: Download Issue
description: Report issues with downloading specific tracks or albums
description: Report a wrong, missing, failed, or low-quality download
title: "[Download]: "
labels: ["download-issue"]
body:
- type: markdown
attributes:
value: |
Having trouble downloading a specific track or album? Please provide details below.
Download matching is extension-driven. Include the source item, provider order, and logs so the exact decision path can be inspected.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
- label: I searched existing issues and did not find this problem.
required: true
- label: I am using the latest version of SpotiFLAC (Stable Version)
- label: I updated the affected extension and retried once.
required: true
- type: dropdown
id: issue-type
attributes:
label: Issue Type
description: What kind of download issue are you experiencing?
options:
- Track not found on service
- Track or album not found
- Wrong track downloaded
- Download fails/errors
- Metadata incorrect
- Audio quality issue
- Download fails or stalls
- Metadata or cover is incorrect
- Audio format or quality is incorrect
- Duplicate or replacement behavior
- Other
validations:
required: true
- type: input
id: spotify-url
id: source-url
attributes:
label: Spotify URL
description: The Spotify URL of the track/album you're trying to download
placeholder: "https://open.spotify.com/track/..."
validations:
required: true
label: Source URL
description: URL pasted or opened in SpotiFLAC Mobile, if available.
placeholder: "https://..."
- type: input
id: track-info
attributes:
label: Track Info
description: Artist name and track title
placeholder: "Artist - Track Title"
label: Track or Album
placeholder: "Artist - Track or album title"
validations:
required: true
- type: input
id: metadata-source
attributes:
label: Search or Metadata Extension
placeholder: "Extension name and version"
validations:
required: true
- type: input
id: download-provider
attributes:
label: Selected Download Extension
placeholder: "Extension name and version"
validations:
required: true
- type: textarea
id: priority
attributes:
label: Provider Priority and Requested Quality
placeholder: |
Provider order: first, second, third
Requested quality: best / 24-bit / ...
Automatic fallback: on/off
validations:
required: true
- type: dropdown
id: download-service
id: storage
attributes:
label: Download Service
description: Which service did you try to download from?
label: Download Storage
options:
- Tidal
- Qobuz
- Amazon Music
- All services
- SAF folder
- App-specific folder
- Not sure
validations:
required: true
- type: dropdown
id: search-service
id: worker
attributes:
label: Search Service
description: Which search service are you using?
label: Download Worker
options:
- Spotify
- Deezer
- Native worker
- Dart worker
- Not sure
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Describe the issue in detail
placeholder: |
What happened? What did you expect?
If wrong track was downloaded, what track was downloaded instead?
label: What Happened?
description: For a wrong match, identify both the expected and downloaded recording.
placeholder: Describe the result and the expected result.
validations:
required: true
- type: input
id: version
id: environment
attributes:
label: App Version
description: Which version of SpotiFLAC are you using?
placeholder: "e.g., v2.2.0"
label: App Version, Device, and OS
placeholder: "4.8.1 (139), device model, Android/iOS version"
validations:
required: true
- type: textarea
id: screenshots
id: logs
attributes:
label: Screenshots / Logs
description: |
If applicable, add screenshots or logs.
**To get logs:**
1. Go to Settings > Options > Detailed Logging (turn ON)
2. Try downloading the track again
3. Go to Settings > Logs
4. Tap Share button to export logs
placeholder: Drag & drop screenshots or paste logs here...
label: Logs and Screenshots
description: Enable detailed logging, retry once, and export Settings > Logs. Remove credentials and personal data.
placeholder: Paste logs or drag files here.
validations:
required: true
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
Thanks for helping improve the SpotiFLAC Extension API!
Thanks for helping improve the SpotiFLAC Mobile Extension API!
This form is for extension developers who need new features or capabilities that don't exist yet.
- type: checkboxes
@@ -15,7 +15,7 @@ body:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
- label: I have read the [Extension Development Guide](https://github.com/spotiflacapp/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
required: true
- label: I have searched existing issues and this API feature hasn't been requested yet
required: true
+1 -1
View File
@@ -50,7 +50,7 @@ body:
options:
- UI/UX Improvement
- Download Feature
- New Service Integration
- Extension System/API
- Metadata/Tagging
- Performance
- Settings/Configuration
+42
View File
@@ -0,0 +1,42 @@
## Summary
<!-- What changed, why is it needed, and which user-visible behavior is affected? -->
## Related Issues
<!-- Use "Fixes #123" when this PR should close an issue. -->
## Type of Change
- [ ] Bug fix
- [ ] Feature
- [ ] Performance improvement
- [ ] Refactor with no intended behavior change
- [ ] Tests, build, or repository maintenance
- [ ] Documentation or localization
## Validation
<!-- Check only commands relevant to this change. Explain skipped checks below. -->
- [ ] `dart format --output=none --set-exit-if-changed lib test`
- [ ] `flutter analyze`
- [ ] `flutter test`
- [ ] `gofmt`, `go vet ./...`, and `go test ./...` in `go_backend/`
- [ ] Android or iOS native checks
- [ ] Manual reproduction or device test
Validation notes:
<!-- Devices, commands, test cases, or the reason a check was not applicable. -->
## Contributor Checklist
- [ ] This PR is focused and contains no unrelated changes.
- [ ] Extension-specific behavior, if any, uses a generic capability or manifest contract.
- [ ] User-facing strings, documentation, and generated files are updated where needed.
- [ ] No credentials, downloaded media, or build artifacts are included.
## Screenshots or Recordings
<!-- Add before/after media for UI changes. Delete this section otherwise. -->
+184
View File
@@ -0,0 +1,184 @@
name: CI
on:
pull_request:
push:
branches:
- main
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
name: Detect changed paths
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
dart: ${{ steps.filter.outputs.dart }}
go: ${{ steps.filter.outputs.go }}
android: ${{ steps.filter.outputs.android }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Filter paths
uses: dorny/paths-filter@v3
id: filter
with:
filters: |
dart:
- 'lib/**'
- 'test/**'
- 'assets/**'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'l10n.yaml'
- '.fvmrc'
- '.github/workflows/ci.yml'
go:
- 'go_backend/**'
- '.github/workflows/ci.yml'
android:
- 'android/**'
- 'go_backend/**'
- 'lib/models/settings.dart'
- 'lib/providers/download_queue_provider*.dart'
- 'lib/services/download_request_payload.dart'
- 'lib/services/history_database.dart'
- 'lib/services/platform_bridge.dart'
- 'pubspec.yaml'
- 'pubspec.lock'
- '.fvmrc'
- '.github/workflows/ci.yml'
flutter:
name: Flutter analyze & test
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.dart == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version-file: .fvmrc
cache: true
- name: Cache pub dependencies
uses: actions/cache@v5
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-${{ runner.os }}-
- name: Get Flutter dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests
run: flutter test
go:
name: Go vet & test
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.go == 'true'
defaults:
run:
working-directory: go_backend
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go_backend/go.mod
cache-dependency-path: go_backend/go.sum
- name: Check formatting
run: test -z "$(gofmt -l .)"
- name: Vet
run: go vet ./...
- name: Run tests
run: go test ./...
android:
name: Android compile & native tests
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.android == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "25"
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go_backend/go.mod
cache-dependency-path: go_backend/go.sum
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version-file: .fvmrc
cache: true
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
gradle-version: "9.6.1"
- name: Install Android SDK & NDK
run: |
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
"ndk;29.0.14206865" \
"platforms;android-37.0" \
"build-tools;37.0.0"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
- name: Build Go backend for Android
working-directory: go_backend
run: |
go install golang.org/x/mobile/cmd/gomobile
gomobile init
mkdir -p ../android/app/libs
gomobile bind \
-target=android/arm,android/arm64 \
-androidapi 24 \
-o ../android/app/libs/gobackend.aar \
.
env:
CGO_ENABLED: 1
- name: Get Flutter dependencies
run: flutter pub get
- name: Configure Flutter SDK for Gradle
run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties
- name: Compile Kotlin and run native unit tests
run: gradle -p android :app:compileDebugKotlin :app:testDebugUnitTest
+70 -24
View File
@@ -47,15 +47,14 @@ jobs:
steps:
- name: Free disk space
run: |
# Remove large unused tools (~15GB total)
# Remove large unused tools (~15GB total). Docker prune was dropped:
# it took 1-2 minutes and the rm below already frees enough.
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo rm -rf /usr/local/share/boost
sudo rm -rf /usr/share/swift
sudo rm -rf /usr/local/.ghcup
# Clean docker images
sudo docker image prune --all --force
# Show available space
df -h
@@ -71,7 +70,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25.8"
go-version-file: go_backend/go.mod
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
@@ -84,6 +83,12 @@ jobs:
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Cache Android NDK
uses: actions/cache@v5
with:
path: /usr/local/lib/android/sdk/ndk/29.0.14206865
key: ndk-29.0.14206865
- name: Install Android SDK & NDK
run: |
# Use pre-installed Android SDK on GitHub runners
@@ -94,22 +99,29 @@ jobs:
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
# Install NDK r29 (supports 16KB page size for Android 15+)
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
# Keep the installed platform aligned with compileSdk/targetSdk.
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37.0" "build-tools;37.0.0"
# Set NDK path
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
- name: Install gomobile
working-directory: go_backend
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
# Installed from inside the module so the go.mod-pinned x/mobile
# version is used (reproducible + covered by the setup-go cache).
go install golang.org/x/mobile/cmd/gomobile
gomobile init
- name: Build Go backend for Android
working-directory: go_backend
run: |
mkdir -p ../android/app/libs
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
# arm/arm64 only: ndk.abiFilters in app/build.gradle.kts strips
# every other ABI from all outputs (universal APK included), so an
# amd64 slice would never reach a shipped APK — it only bloats the
# aar and slows this step.
gomobile bind -target=android/arm,android/arm64 -androidapi 24 -o ../android/app/libs/gobackend.aar .
env:
CGO_ENABLED: 1
@@ -117,8 +129,16 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version-file: .fvmrc
cache: true
- name: Cache pub dependencies
uses: actions/cache@v5
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-${{ runner.os }}-
- name: Get Flutter dependencies
run: flutter pub get
@@ -127,13 +147,11 @@ jobs:
- name: Build APK (Release - unsigned)
run: |
flutter build apk --release --split-per-abi || true
# Verify APKs were created
flutter build apk --release --split-per-abi \
--target-platform android-arm,android-arm64
ls -la build/app/outputs/flutter-apk/
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
echo "ERROR: APK not found!"
exit 1
fi
test -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
test -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
- name: Sign APKs
uses: r0adkll/sign-android-release@v1
@@ -149,12 +167,29 @@ jobs:
- name: Rename APKs
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION="${{ needs.get-version.outputs.version }}"
cd build/app/outputs/flutter-apk
# Signed files have -signed suffix
mv app-arm64-v8a-release-signed.apk SpotiFLAC-${VERSION}-arm64.apk || mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
rename_required_apk() {
unsigned="$1"
destination="$2"
signed="${unsigned%.apk}-signed.apk"
if [ -f "$signed" ]; then
mv "$signed" "$destination"
elif [ -f "$unsigned" ]; then
mv "$unsigned" "$destination"
else
echo "ERROR: Missing APK: $unsigned"
exit 1
fi
}
rename_required_apk \
app-arm64-v8a-release.apk \
"SpotiFLAC-${VERSION}-arm64.apk"
rename_required_apk \
app-armeabi-v7a-release.apk \
"SpotiFLAC-${VERSION}-arm32.apk"
ls -la
- name: Upload APK artifact
@@ -179,7 +214,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25.8"
go-version-file: go_backend/go.mod
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
@@ -191,8 +226,10 @@ jobs:
restore-keys: pods-${{ runner.os }}-
- name: Install gomobile
working-directory: go_backend
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
# go.mod-pinned x/mobile version (reproducible + cached by setup-go).
go install golang.org/x/mobile/cmd/gomobile
gomobile init
- name: Build Go backend for iOS
@@ -210,8 +247,9 @@ jobs:
- name: Add XCFramework to Xcode project
run: |
# Install xcodeproj gem for modifying Xcode project
sudo gem install xcodeproj
# xcodeproj usually ships with the preinstalled CocoaPods; only
# install it when missing.
gem list -i xcodeproj >/dev/null || sudo gem install xcodeproj
# Create Ruby script to add framework
cat > add_framework.rb << 'EOF'
@@ -252,8 +290,16 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version-file: .fvmrc
cache: true
- name: Cache pub dependencies
uses: actions/cache@v5
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-${{ runner.os }}-
- name: Get Flutter dependencies
run: flutter pub get
@@ -445,7 +491,7 @@ jobs:
jq --arg ver "$VERSION_NUM" \
--arg date "$DATE" \
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
--arg url "https://github.com/${{ github.repository }}/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
--argjson size "$IPA_SIZE" \
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
+70 -61
View File
@@ -1,89 +1,98 @@
# OS files
# Operating-system files
.DS_Store
Thumbs.db
Desktop.ini
# IDE
# Editors and local assistants
.idea/
.vscode/
*.iml
.cursorignore
.cursorrules
# Kiro specs (development only)
.kiro/
.playwright-mcp/
.rtk/
.claude/settings.local.json
CLAUDE.md
AGENTS.md
# Design assets (banners, mockups)
design/
# Reference folder (development only)
referensi/
# Documentation (development only, published separately)
docs/
# Old spotiflac_android folder (moved to root)
spotiflac_android/
# Flutter/Dart
# Flutter and Dart
.dart_tool/
.fvm/
.packages
build/
*.lock
!pubspec.lock
.flutter-plugins
.flutter-plugins-dependencies
.metadata
/build/
/coverage/
*.apk
*.aab
*.ipa
# Go backend build artifacts
go_backend/*.aar
go_backend/*.jar
go_backend/*.exe
go_backend/*.xcframework/
# Local JavaScript tooling (the app itself has no Node dependency)
/node_modules/
/bun.lock
/package-lock.json
# Android
android/.gradle/
android/app/libs/gobackend.aar
android/app/libs/gobackend-sources.jar
android/local.properties
android/*.iml
android/key.properties
android/*.jks
android/*.keystore
android/app/*.jks
# Go backend build outputs
/go_backend/*.aar
/go_backend/*.jar
/go_backend/*.exe
/go_backend/*.xcframework/
# iOS
ios/Frameworks/
ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
# Android build state and signing material
/android/.gradle/
/android/.kotlin/
/android/app/build/
/android/app/libs/*.aar
/android/app/libs/*-sources.jar
/android/local.properties
/android/*.iml
/android/key.properties
/android/*.jks
/android/*.keystore
/android/app/*.jks
# Extension folder
extension/*
extension/v2/
extension/v2/**
# iOS generated state
/ios/Frameworks/
/ios/Pods/
/ios/.symlinks/
/ios/Flutter/ephemeral/
/ios/Flutter/Flutter.framework/
/ios/Flutter/Flutter.podspec
*.xcarchive
DerivedData/
# Agent instructions
AGENTS.md
# Credentials and machine-local configuration
.env
.env.*
!.env.example
*.mobileprovision
*.p12
*.pem
# Temp/misc
.tmp/
# Local research and design material
/design/
/referensi/
/extension/
/tool/
/spotiflac_android/
# Documentation published separately. Keep the in-repo extension contract
# available because contribution templates link to it.
/docs/*
!/docs/EXTENSION_DEVELOPMENT.md
# Temporary files and logs
/.tmp/
nul
NUL
network_requests.txt
*.bak
/network_requests.txt
/AndroidManifest.xml
# Log files
*.bak
*.orig
*.swp
*.tmp
*.log
hs_err_*.log
flutter_*.log
# Development tools
tool/
.claude/settings.local.json
.playwright-mcp/
# FVM Version Cache
.fvm/
+521 -13
View File
@@ -1,5 +1,513 @@
# Changelog
## [4.8.5] - 2026-08-01
### Added
- **Searchable Settings**: Search both top-level pages and controls inside them. Opening a nested result now scrolls to the matching control and briefly highlights it.
- **Album Multi-Select Download**: Select several tracks in an album and choose the provider and quality once for the whole selection.
- **Player & Queue Controls**: Repeat Off/All/One modes, swipe up from Now Playing to open the queue, move queued tracks up or down, `Download next`, and one-tap retry when connectivity returns.
- **Playback Session Restore**: Restore the previous queue, track, technical quality, and playback position after the app process is restarted.
- **Track Navigation**: `Go to Album` is available from track actions, including sparse search results, and `Open on...` exposes supported platform links.
- **Library Views**: Playlist filtering, a configurable default Library view, a missing-ISRC filter, and clearer horizontally scrollable filter chips.
- **Quality Labels**: Choose between measured bitrate labels and classic bit-depth/sample-rate labels. High-bitrate tracks receive a distinct badge color, and lossless average bitrate is shown in Track Metadata.
- **Duplicate Review**: Detect ISRC duplicates beyond FLAC, review matching files, and keep the preferred copy.
- **M3U Playlists**: Import M3U/M3U8 playlists and export Library collections as M3U8.
- **Metadata Sources**: Choose a specific extension for online auto-fill, use extension-defined custom search, or manually fetch metadata from MusicBrainz.
- **Cover Tools**: View the current embedded-cover dimensions, preview the dimensions of a fetched cover, resize embedded artwork, and select the best available online cover candidate.
- **Android Download Widget**: Monitor the active download queue from the Android home screen.
- **Appearance Controls**: Adaptive detail-page colors derived from cover art and an optional force-backdrop-blur setting for lower device tiers.
- **Audio Processing**: Very-high-quality SoXR resampling settings for supported lossless conversions.
### Fixed
- **Provider Matching**: Honor the explicitly selected provider and reject mismatched provider results that could download a cover or unrelated song with the original album metadata.
- **Verification & Sessions**: Coordinate one verification flow across sequential and parallel album downloads, follow canonical gateway/provider error contracts, block challenged session generations, and prevent stale requests from invalidating a newer session.
- **Verification Alerts**: Notify the user when downloads pause for verification and recover signed-session bootstrap failures more reliably.
- **Download Storage**: Recover from an unwritable selected folder, retain published files when bookkeeping fails, and keep cancelled items retryable after app restarts.
- **Quality Variants**: Complete alternate-quality downloads reliably and append technical quality to filenames only when a real filename collision exists.
- **Library Stability**: Keep rows and cached history visible during active downloads and refreshes, retain records when deletion fails, and backfill/persist local bitrate metadata without opening every track.
- **Playback & Lyrics**: Refresh lyrics after automatic track changes and cold starts, reject empty embedded lyric payloads, preserve technical quality between tracks, and restore playback position reliably.
- **WAV & AIFF Metadata**: Preserve supported tags and artwork through conversion and metadata editing.
- **Audio Analysis**: Wait for complete FFmpeg metrics, isolate analysis from active downloads, detect effective/full-band spectral cutoff more accurately, and avoid unsupported AC-4 spectrogram analysis.
- **Startup Compatibility**: Stop forcing the highest Android display mode during every cold start, avoiding OEM-specific crashes and unnecessary GPU load.
- **User-Facing Errors**: Replace raw `PlatformException` and backend details with concise, sanitized messages.
- **Metadata UI**: Keep Track Metadata headers readable in light themes and make the online source picker, preview, apply, and feedback flow consistent with the rest of the app.
- **Selection & Sheets**: Keep selection bars and modal sheets above the app navbar and make download-picker drag dismissal feel natural.
- **Database Upgrade**: Make playback-session migration safe when an older installation already contains the destination table.
### Improved
- **Now Playing**: Smoother track transitions, consistent custom action sheets, better synced-lyrics positioning, and aligned play/preview controls and hitboxes.
- **Responsive UI**: More consistent search fields, settings groups, page headers, modal sheets, empty/error states, and collection selection behavior, with improved tablet widths and short-screen setup/tutorial layouts.
- **Download Performance**: Faster duplicate checks for large playlists, fewer native-widget updates, reduced progress/session write churn, and safer HTTP Range recovery.
- **Library Performance**: Batched history/index updates, fewer rebuilds during scans, lazy duplicate loading, cached MusicBrainz lookups, and quality probes that avoid unnecessary SAF copies.
- **Extension Security**: Verify decentralized package integrity, isolate bad registry checksums, anchor URL handlers to their declared hosts, and enforce `minAppVersion` and required runtime features.
- **Dependencies**: Updated Flutter, Go, FFmpeg, Android notification, and supporting native dependencies.
---
## [4.8.0] - 2026-07-24
### Added
- **Tablet & Landscape UI**: Navigation rail, landscape Now Playing, clamped layouts for track lists, dialogs, sheets, settings, queue lists, and the setup wizard, with adaptive selection-card columns.
- **Playback Volume Normalization**: ReplayGain/R128 volume normalization for the built-in player (Library > Playback, off by default).
- **Concurrent Downloads**: Support up to 3 simultaneous downloads.
- **Download Resume**: Interrupted downloads resume via HTTP Range/If-Range instead of restarting from zero.
- **Lost Folder Access Recovery**: Detects a lost SAF grant and offers folder re-selection.
- **Native Tag Editors**: New native MP3 ID3v2, Ogg/Opus Vorbis comment, and M4A ilst tag editors — metadata edits for these formats no longer need FFmpeg.
- **Shared-Element Transitions**: Hero covers fly from search/recents to detail screens, the player expands from the mini player with drag-to-dismiss, and skeletons crossfade into content.
- **Player Gestures**: Swipe the mini player away to stop playback.
- **Convert Options**: Optionally retain original files after conversion, and support same-format lossless re-encoding.
- **Quality Variants**: Downloads preserve quality variants, named by measured quality.
- **Explicit Badge**: Explicit tracks show an [E] badge.
- **iOS Verification**: Extension verification and OAuth run through ASWebAuthenticationSession.
- **Forced Update**: Force update when 3 or more stable releases behind.
- **Display**: Requests the highest display refresh rate on Android; fluid bouncing scroll physics on capable devices; shimmer skeletons for home feed and artist screens.
- **Hero Animation Toggle**: Setting to disable Hero animations.
- **Network Hardening**: DNS-over-HTTPS fallback and download retry hardening.
### Fixed
- **Safer File Writes**: All SAF and direct-mode downloads are staged then atomically promoted with fsync; FLAC, M4A, and AC-4 tags are written atomically; fixed the endless FLAC-write failures on misnamed files.
- **Native Worker Reliability**: Recovers from dead runs, keeps batches and retries intact across races, never deletes a published download over bookkeeping failures, and flushes queue persistence when the app is backgrounded.
- **Download History**: Persisted and reconciled reliably; completed downloads bridge into the library; library records persist before download completion.
- **Playlist Import Order**: Imported playlists keep their track order (#482).
- **Playback**: No auto-resume after an audio interruption while backgrounded; stale playback and SAF temp files cleaned up.
- **M4A Metadata**: stco/co64 chunk offsets shift correctly when the ilst tag resizes.
- **Extensions**: Data directory removed on uninstall, storage/credential writes serialized across runtimes, stale pending auth challenges expire, package and runtime lifecycle hardened, bounded file reads and cookie state.
- **Localization**: Deezer requests send Accept-Language so names follow the app language.
- **arm32 Stability**: Built with Go 1.26.5 for aligned cgo frames.
- **CSV Import**: Use State's context instead of a stale parameter (#460) — thanks @A831ARD0.
### Improved
- **Broad Performance Pass**: Streaming tag rewrites for Ogg and M4A, streamed PCM analysis, SAF scan results spill to disk, Go heap and image caches released on memory pressure, per-extension goja runtime pool, incremental queue persistence and ISRC index, byte-capped cover disk cache, TTL/ETag-cached update checks, gzip and TLS session resumption, backdrop blur gated by device capability, incremental DB auto-vacuum.
- **Codebase Health**: Large-scale deduplication across screens, providers, and the Go backend; dead code paths removed.
### Changed
- **Graduated from Beta**: Native download worker and Backup & Restore are now stable.
- **Terminology**: Extension "store" renamed to "repo" across all layers.
---
## [4.7.1] - 2026-07-02
### Added
- **Full Extension Metadata Embedding**: Full metadata and cover are embedded after extension downloads.
- **Lossless Conversion Options**: Dither and resampler options.
- **Verification Browser Preference**: Choose in-app or external browser for extension verification, with manual verification help when browser launch fails.
### Fixed
- **Convert Sheets**: Draggable and scroll-controlled bottom sheets.
- **Verification**: Defaults to the in-app browser first; native worker verification hands off to the interactive queue; signed-session auth bootstraps and clears pending state correctly.
- **AC-4**: Truncated sample entries are rejected safely.
---
## [4.7.0] - 2026-07-01
### Added
- **New Playback Experience**: Rebuilt built-in player with media integration, reorderable up-next queue, and playback stability fixes.
- **Track Previews**: Play short preview snippets from track lists.
- **Motion Artwork**: HLS motion-artwork header banners (Apple Music style) and audio-quality badges.
- **Backup & Restore (Beta)**: Settings, download history, library, and installed extensions.
- **Lossless Conversion Caps**: Cap bit depth/sample rate on lossless conversion, including album batch convert.
- **ReplayGain**: Opus R128 gain tags.
- **AC-4 Passthrough**: Download support for AC-4 streams.
- **Queue**: Tap a failed download to view its error details.
- **Filename Placeholders**: `{playlist_position}` placeholder.
- **Playlist Folders**: Rename action.
- **Network Setting**: Opt-in allow local/private network access.
### Fixed
- **Verification**: Retries tracked per service; signed session files scoped by endpoint and app context; early abort in fallback.
- **Lyrics**: Provider priority synced to backend, improved fallback and health handling.
- **Metadata**: Oversized cover art capped, QuickTime/MP4 tag support, native M4A ISRC/label writes, all edited fields read back from file.
- **Navigation**: Tapping an artist name navigates to the correct artist.
- **Compatibility**: Impeller disabled on Sony audio players and Vivante GPUs.
- **Fallback Quality**: Actual output format detected from audio codec; requested quality honored when the fallback provider recognizes it.
### Improved
- **Performance**: Optimized queue/library pagination, counts, scan, and extension runtime.
---
## [4.6.0] - 2026-06-14
### Added
- **WAV & AIFF Support**: Full metadata support with a settings-style metadata menu.
- **LyricsPlus Provider** and **ReplayGain batch scanning**.
- **French & German Locales**.
- **UI Polish**: Blurred backdrop playlist headers, frosted translucent bottom navbar, active downloads shown inside the library grid, actual lyrics source shown in metadata.
### Fixed
- **Downloads**: Selected provider honored when it equals the track source; fallback provider requests its own highest quality; iOS background task handling.
- **UI**: High-res cover in the track metadata header, centered modals on large screens, modernized edit-metadata and convert sheets, predictive-back page transition override.
### Removed
- **Concurrent Download Option**: Removed during the playback rework (returned in 4.8.0 with proper support).
---
## [4.5.6] - 2026-06-02
### Added
- **Cross-Extension Sharing**: Rebuilt sharing and queue controls; polished search empty state.
### Fixed
- **Sharing & Fallback Routing**: Cross-service sharing aligned with fallback routing.
- **Metadata**: Stricter matching, `embedLyrics` setting respected, improved Apple Music lyrics; album-only autofill and placeholder re-enrich regressions resolved.
- **Downloads**: Hardened error handling and re-enrich sidecars.
- **iOS**: Deployment target aligned for the file picker.
---
## [4.5.5] - 2026-05-15
### Added
- **5 New Lyrics Providers**.
- **Enhanced Audio Analysis**: Loudness, clipping, and spectral cutoff metrics, with rescan support after a cached result.
- **AAC Support**: AAC conversion and lossy download target.
- **Apple Music Word-Synced Lyrics**: Toggle for eLRC word sync.
- **Launch Announcements**: Remote-config announcements shown on app start.
- **History**: Codec format and bitrate persisted in download history.
- **MP4 Codec Detection**: FLAC/ALAC/EAC3/AC3/AC4 detected inside MP4 containers.
- **Library**: Scroll-to-top and scroll-to-bottom quick buttons.
### Fixed
- **No Fake Lossless**: Codec probes prevent lossy-to-lossless upscale conversions in every branch.
- **MP3 Lyrics**: Embedded via ID3v2.3 USLT frame.
- **FLAC**: Native muxer forced when decrypting to .flac output; native handling fixes.
- **SAF**: Extension-agnostic `.partial` staged filename.
---
## [4.5.1] - 2026-05-08
### Fixed
- **Native Worker**: Avoids the Android binder payload limit.
- **Extensions**: Missing-extension state shown for returning users.
- **Downloads**: `dataSync` foreground service type declared; default quality settings restored.
- **UI**: Settings editor white screens fixed; library search stabilized.
---
## [4.5.0] - 2026-05-06
### Changed
- **All Built-in Download Providers Retired**: Tidal, Qobuz, and Deezer now live as extensions with isolated runtimes. Install them from the Store to keep downloading.
- **Settings Reorganized**: Split into focused pages — thanks @Amonoman.
### Added
- **Android Native Download Worker (Experimental)**: Downloads continue reliably in the background.
- **Favorite Artists Collection**.
- **Extension Service Health**: Health indicators for extension services.
- **Download Deduplication Setting** — thanks @Amonoman.
- **New Look**: Google Sans Flex font and monochrome icon support.
- **Extension APIs**: `skip_fallback` capability, download dedup/ISRC/lyrics APIs, generic provider resolution, progress phases.
- **Metadata**: MusicBrainz album artist fallback; instrumental lyrics heuristic; audio duration exposed.
- **Home Feed**: Can be disabled.
### Fixed
- **iOS**: Security-scoped bookmarks for download directory persistence.
- **SAF**: Filenames and directory segments truncate safely at UTF-8 boundaries.
- **Native Worker**: Metadata embedding, notification progress, and history schema sync.
- **Localization**: Missing Crowdin locale variants mapped; unsupported locales fall back to English.
### Improved
- **Database-Backed Pagination**: Queue tab and local library migrated from in-memory lists to paginated database queries — much lower memory use on large libraries.
- **Performance**: Delta-mode native worker snapshots, reduced bridge/UI churn, improved queue resilience.
---
## [4.3.1] - 2026-04-14
### Added
- **Extension Output Control**: `preserveNativeOutputExtensions` capability; M4A converts to FLAC when the extension doesn't prefer native M4A output; extension download metadata carried through the host pipeline.
### Fixed
- **Container Handling**: `.mp4` treated as alias for `.m4a` throughout the download pipeline; `OutputExt` reset on extension→extension and extension→built-in fallback.
- **Provider Choice**: User's provider choice respected over source extension priority.
- **Tidal Metadata**: Copyright and album artist improved; DATE/YEAR tag sync fixed.
---
## [4.3.0] - 2026-04-13
### Added
- **Artist Search Filter** and normalized search filter handling.
- **Default Search Tab Preference**.
- **Native M4A ReplayGain** tag writing and SAF picker error handling.
- **Download Cancel**: Propagates to extension HTTP requests.
### Changed
- **Deezer Search via Extension**: Search flow moved out of the core app.
- **Extension Manifest**: `author` field removed.
### Fixed
- **iOS**: Extension OAuth callback handled.
- **M4A**: Existing metadata preserved during embed; ALAC quality parsing improved.
- **Extensions**: Shared link handling stabilized; genre falls back to extra metadata.
---
## [4.2.2] - 2026-04-06
### Added
- **Configurable Extension Download Fallback**.
### Changed
- **Deezer is now an Extension**: Moved from built-in service to installable extension.
### Fixed
- **Metadata**: Downloaded metadata persisted, re-enrich matching aligned with autofill, composer preserved across Qobuz and history, flat singles output preserved for extension releases.
---
## [4.2.1] - 2026-04-04
### Added
- **Metadata Enrichment**: Composer and track totals.
### Fixed
- **Crash Fix**: Stale audio service manifest entries removed (crashed on some devices).
- **Stability**: Hardened gomobile extension bindings, M4A cover retention, local convert format and library entries preserved, embedded metadata details preserved.
---
## [4.2.0] - 2026-04-04
### Added
- **ReplayGain Scanning** and APEv2 tag support.
- **Singles/EP Filename Format**: Separate format setting (#271).
- **Bulk Re-enrich Field Selection**: Choose which fields to update.
- **Resolve API**: With SongLink fallback.
- **Extension API**: `skipLyrics` manifest field; additional search/metadata API with separate rate limiting.
### Fixed
- **Multi-Artist Tags** (#288).
- **Qobuz 401 Errors**: API calls routed through an authenticated gateway.
- **Metadata**: Missing track/disc numbers from search downloads, ISRC validation to prevent ID leakage, label/copyright resolved from file metadata.
- **UI**: System navigation bar matches app theme.
### Improved
- **UI Smoothness**: Memoization, compute isolates, SQL-backed playlist picker, viewport-aware image caching.
- **Performance**: Incremental queue lookups, async cover cleanup, native JSON decoding on iOS.
### Removed
- **Legacy API Clients**: Including the Yoinkify fallback and an unused lyrics provider.
---
## [4.1.3] - 2026-03-30
### Added
- **Artist Tag Mode**: Setting with split Vorbis support.
- **Search & Filters**: Metadata filters and extended sort options; Qobuz album-search fallback; stable cover cache keys.
### Fixed
- **Samsung SAF Library Scan**, Qobuz album cover, and M4A metadata save.
---
## [4.1.2] - 2026-03-29
### Added
- **Batch Progress Dialog**: Replaces snackbars for batch operations.
- **Library**: Haptic feedback when swiping tabs; play button on playlist/library track tiles.
### Fixed
- **Extension Quality**: Tidal quality options used as fallback; DEFAULT quality normalized to prevent API failures.
- **ALAC**: `attached_pic` disposition for cover art embedding.
- **Downloads**: `START_NOT_STICKY` prevents DownloadService auto-restart.
### Improved
- **Track Matching**: Scoring-based ReEnrich selection; Spotify URLs routed through extensions.
---
## [4.1.1] - 2026-03-27
### Added
- **Extension Download Progress**: Byte-level progress tracking.
- **Spectrogram Cache**: Cached as PNG for instant loading on subsequent views.
### Fixed
- **UI**: Artist skeleton polish; SpectrogramView null crash when loading from PNG cache.
- **SAF**: `artist_album_flat` case added to relative output dir builder.
---
## [4.1.0] - 2026-03-26
Version numbering returns to 4.x, continuing from the 3.x rollback in 3.7.0.
### Added
- **Audio Quality Analysis**: Analysis widget with cached results.
- **Smart Service Selection**: Recommended download service auto-selected based on content source.
- **Search Result Sorting**.
### Changed
- **YouTube Downloads**: Extracted to the YouTube Music extension.
### Fixed
- **UI**: Skeleton visibility and artist header in light mode, store URL input flash on startup, unintended home reset on tab switch, embedded cover refresh.
- **Metadata**: Hi-res cover art for Tidal/Qobuz, album metadata override, FLAC metadata fallback for mismatched files, USLT lyrics detection.
- **Navigation**: Tidal/Qobuz items from Recent Access open built-in screens.
### Improved
- **Startup**: Lazy extension VM init and incremental startup maintenance.
- **Deezer Resolution**: Uses Tidal/Qobuz metadata; Qobuz skips SongLink when ISRC is already available.
---
## [3.9.0] - 2026-03-25
### Added
- **Built-in Tidal/Qobuz Search**: With a recommended service picker and a built-in search provider setting.
- **Home Feed Provider Setting**.
- **Tidal HIGH Restored**: AAC 320kbps lossy quality option (#242).
- **Full M4A Tag Engine**: Read engine with atom path fallback, metadata/cover embed across all screens, and library scan support.
- **Playlist Source Folders**: `createPlaylistFolder` setting for playlist folder prefixes.
- **Resilient Artist Matching**: Diacritic folding for loose artist name matching.
### Fixed
- **Localization**: Crowdin locale files consolidated, ICU plural warnings fixed.
---
## [3.8.8] - 2026-03-18
### Added
- **Tidal ISRC & Metadata Search**.
### Changed
- **Lyrics Providers**: Migrated to Paxsenix endpoints.
### Fixed
- **Extension Download Reliability** and Qobuz API integration.
- **Stability**: Library scan IDs, pause queue behavior, and a scan race condition.
---
## [3.8.7] - 2026-03-17
### Fixed
- **Queue**: Active downloads properly stop when pausing; queue-as-FLAC hides when all selected tracks are already FLAC and skips FLAC tracks from selection.
- **Downloads**: Already-downloaded tracks skipped in library folder download-all; Spotify track availability resolution improved.
- **Covers**: Deezer and Tidal cover art upgraded to max quality.
- **Library**: Auto-scan cooldown honored.
---
## [3.8.6] - 2026-03-16
### Added
- **Local Library Auto-Scan** option.
### Fixed
- **Re-downloads**: Tracks converted to a different format are no longer re-downloaded.
- **iOS**: Folder picker delayed until sheet dismiss; Afkar hosts updated.
- **UI**: Double horizontal padding removed in the store tab.
---
## [3.8.5] - 2026-03-16
### Added
- **FLAC/ALAC Bidirectional Lossless Conversion**.
- **Selective Auto-Fill**: Pick fields to auto-fill from online in the Edit Metadata sheet, with improved track resolution.
- **FLAC Redownload Queue**: Queue FLAC redownloads for local library tracks.
- **Opus 320kbps Quality** (Tidal HIGH tier removed; restored in 3.9.0).
- **Bulk Playlist Download**: Download multiple selected playlists — thanks @ViscousPot.
- **Playlist Import**: Playlist name auto-filled during import — thanks @ViscousPot.
- **Qobuz Afkar API Provider**: With request metadata preferred for consistent album grouping.
### Fixed
- **Wrong-Track Protection**: Resolved Tidal/Deezer tracks are verified against the download request before downloading.
- **Download All**: Skips already-downloaded tracks for albums and playlists.
- **Various Artists**: Album-level artist used instead of the first track's artist.
- **M4A/ALAC**: Cover art extracted for conversion, embedded lyrics detected, metadata and cover preserved in ALAC/M4A→FLAC conversion.
- **Batch Convert**: Target formats filtered by source formats.
---
## [3.8.0] - 2026-03-14
### Added
- **Qobuz & Tidal Metadata Search**: Built-in metadata search providers with priority-based unified search, full metadata API, URL parsers, and store support.
- **Auto-Enrich for Extension Downloads**: Metadata is automatically enriched.
- **Store Registry URL Management**: With iOS handler support and a cleaner store UI.
- **Provider Priority**: Deezer entry added to the priority UI.
### Fixed
- **Tidal**: Track resolution and playlist owner info.
- **iOS**: Stale built-in Spotify bridge handlers removed.
### Improved
- **Performance**: Optimized polling, progress caching, staggered warmup, snapshot-based library scan, SAF metadata reading, CUE sibling resolution, and startup initialization.
- **Localization**: Hardcoded strings replaced with l10n keys across 13 screens.
---
## [3.7.2] - 2026-03-07
### Changed
@@ -745,8 +1253,8 @@ Thank you for your understanding and continued support. This decision was made t
### Highlights
- **Local Library Scanning** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
- **Duplicate Detection** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
- **Local Library Scanning** ([#117](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
- **Duplicate Detection** ([#117](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
- **Unified Library Tab**: History renamed to Library, shows Downloaded + Local Library tracks with source badges
### Added
@@ -814,7 +1322,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
### Added
- **Clear All Queue Button**: Cancel all queued downloads with one tap ([#96](https://github.com/zarzet/SpotiFLAC-Mobile/issues/96))
- **Clear All Queue Button**: Cancel all queued downloads with one tap ([#96](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/96))
- **IDHS Fallback**: Fallback link resolver when SongLink fails (rate limited 8 req/min)
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
@@ -831,10 +1339,10 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
### Fixed
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/zarzet/SpotiFLAC-Mobile/issues/108))
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/108))
- **Opus Cover Art**: Implemented METADATA_BLOCK_PICTURE for proper cover embedding
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/zarzet/SpotiFLAC-Mobile/issues/112))
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/zarzet/SpotiFLAC-Mobile/issues/110))
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/112))
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/110))
---
@@ -1047,7 +1555,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
- **Quick Search Provider Switcher** ([#76](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
- Tap the search icon to reveal a dropdown menu with all available search providers
- Shows default provider (Deezer based on metadata source setting) at the top
- Lists all enabled extensions with custom search capability
@@ -1056,16 +1564,16 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
- Search hint text updates immediately when switching providers
- Re-triggers search automatically if there's existing text in the search bar
- Eliminates need to navigate to Settings > Extensions > Search Provider
- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
- **Extension Button Setting Type** ([#74](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
- Extensions can define `button` type in manifest settings
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
- Useful for authentication, manual sync, or any custom action
- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
- **Genre & Label Metadata** ([#75](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
- Fetches genre and label from Deezer album API for each track
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
- Works automatically when Deezer track ID is available (via ISRC matching)
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
- **MP3 Quality Option** ([#69](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
- Available in both the quality picker dialog and default quality settings
@@ -1091,12 +1599,12 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
- Extracted from first track's artist metadata
- Styled with `onSurfaceVariant` color for visual hierarchy
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
- Tracks sorted by disc number first, then by track number
- Single-disc albums display normally without separators
- Fixes confusion when albums have duplicate track numbers across discs
- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
- **Album Grouping in Recents** ([#70](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
- Prevents flooding the recents list when downloading full albums
- Groups tracks by album name and artist
- Tapping navigates directly to the downloaded album screen
@@ -1236,4 +1744,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
---
_For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
_For older versions, see [GitHub Releases_](https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases)
+119 -234
View File
@@ -1,282 +1,167 @@
# Contributing to SpotiFLAC
# Contributing to SpotiFLAC Mobile
First off, thank you for considering contributing to SpotiFLAC! 🎉
Thank you for helping improve SpotiFLAC Mobile. Bug reports, focused pull
requests, documentation, and translations are all welcome.
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
Please follow the [Code of Conduct](CODE_OF_CONDUCT.md) when participating in
the project.
## Table of Contents
## Before You Start
- [Code of Conduct](#code-of-conduct)
- [How Can I Contribute?](#how-can-i-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Features](#suggesting-features)
- [Code Contributions](#code-contributions)
- [Translations](#translations)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Guidelines](#coding-guidelines)
- [Commit Guidelines](#commit-guidelines)
- [Pull Request Process](#pull-request-process)
- Search the [existing issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
before opening a new one.
- Use the issue template that best matches the problem.
- Keep pull requests focused. Separate unrelated fixes into separate PRs.
- Never commit credentials, signing files, downloaded media, or generated build
artifacts.
## Code of Conduct
Translations are managed through the
[SpotiFLAC Mobile Crowdin project](https://crowdin.com/project/spotiflac-mobile).
The English source strings live in `lib/l10n/arb/app_en.arb`.
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
## Toolchain
## How Can I Contribute?
The repository is the source of truth for tool versions:
### Reporting Bugs
- Flutter: `.fvmrc`
- Dart: bundled with the pinned Flutter SDK
- Go: `go_backend/go.mod`
- Android SDK, NDK, and Java: `.github/workflows/ci.yml`
- Xcode: required only for iOS builds
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
When creating a bug report, please use the bug report template and include:
- **Clear and descriptive title**
- **Steps to reproduce** the issue
- **Expected behavior** vs **actual behavior**
- **Screenshots or screen recordings** if applicable
- **Device information** (model, OS version)
- **App version**
- **Logs** from Settings > About > View Logs
### Suggesting Features
Feature requests are welcome! Please use the feature request template and:
- **Check existing issues** to avoid duplicates
- **Describe the feature** clearly
- **Explain the use case** - why would this be useful?
- **Consider the scope** - is this a small enhancement or a major feature?
### Code Contributions
1. **Fork the repository** and create your branch from `dev`
2. **Make your changes** following our coding guidelines
3. **Test your changes** thoroughly
4. **Submit a pull request** to the `dev` branch
### Translations
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
2. Select your language or request a new one
3. Start translating!
Translation files are located in `lib/l10n/arb/`.
[FVM](https://fvm.app/) is recommended. If you do not use FVM, install the
exact Flutter version declared in `.fvmrc` and replace `fvm flutter` with
`flutter` (and `fvm dart` with `dart`) in the commands below.
## Development Setup
### Prerequisites
1. Fork and clone the repository:
- **Flutter SDK** 3.10.0 or higher
- **Dart SDK** 3.10.0 or higher
- **Android Studio** or **VS Code** with Flutter extensions
- **Git**
### Getting Started
1. **Clone your fork**
```bash
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
cd SpotiFLAC-Mobile
git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
```
2. **Add upstream remote**
2. Install the pinned Flutter SDK and Dart dependencies:
```bash
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
fvm install
fvm flutter pub get
```
3. **Use FVM (Flutter Version: 3.41.5)**
```bash
fvm use
```
3. Build the Go backend for Android. `ANDROID_NDK_HOME` must point to the NDK
version used by CI and `CGO_ENABLED` must be enabled.
4. **Install dependencies**
```bash
flutter pub get
```
5. **Generate code** (for Riverpod, JSON serialization, etc.)
```bash
dart run build_runner build --delete-conflicting-outputs
```
6. **Set up Go environment (Go Version: 1.25.7)**
```bash
cd go_backend
mkdir -p ../android/app/libs
cd go_backend
go mod download
go install golang.org/x/mobile/cmd/gomobile
gomobile init
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
mkdir -p ../android/app/libs
gomobile bind \
-target=android/arm,android/arm64 \
-androidapi 24 \
-o ../android/app/libs/gobackend.aar \
.
cd ..
```
7. **Run the app**
Running `go install` from `go_backend/` uses the `x/mobile` version pinned by
`go.mod`. Do not replace it with `@latest` in project scripts.
4. Run the app:
```bash
flutter run
fvm flutter run
```
### Building
For iOS, run `scripts/build_ios.sh` on macOS before opening
`ios/Runner.xcworkspace`.
## Project Boundaries
```text
lib/ Flutter UI, state, models, and platform orchestration
go_backend/ Download pipeline, extension runtime, and shared backend logic
android/ Android platform bridge and foreground worker
ios/ iOS platform bridge and application project
test/ Flutter unit and widget tests
assets/ Images, fonts, and bundled resources
docs/ Contributor-facing technical contracts
scripts/ Reproducible project build helpers
```
SpotiFLAC Mobile is extension-driven. Extension-specific behavior must be
declared through a generic manifest field, capability, or reusable app API.
Do not add provider-name checks such as `if source == 'provider-name'` to the
main app. The Go backend should parse and expose the generic declaration, and
Dart should consume that declaration without knowing which extension uses it.
## Generated Files
- After changing ARB files, run `fvm flutter gen-l10n` and commit the resulting
localization sources.
- Run `fvm dart run build_runner build --delete-conflicting-outputs` only when a
model or generator input changes, then commit the relevant generated source.
- Do not commit `build/`, `.dart_tool/`, AAR/XCFramework output, IDE state, or
local research directories.
## Validation
Run checks that cover the code you changed. Before opening a PR, the relevant
commands should pass.
Flutter and Dart:
```bash
# Debug build
flutter build apk --debug
# Release build
flutter build apk --release
fvm dart format --output=none --set-exit-if-changed lib test
fvm flutter analyze
fvm flutter test
```
## Project Structure
```
lib/
├── l10n/ # Localization files
│ └── arb/ # ARB translation files
├── models/ # Data models
├── providers/ # Riverpod providers
├── screens/ # UI screens
│ └── settings/ # Settings sub-screens
├── services/ # Business logic services
├── theme/ # App theming
├── utils/ # Utility functions
├── widgets/ # Reusable widgets
├── app.dart # App configuration
└── main.dart # Entry point
```
## Coding Guidelines
### General
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
- Use meaningful variable and function names
- Keep functions small and focused
- Add comments for complex logic
### Formatting
- Use `dart format` before committing
- Maximum line length: 80 characters
- Use trailing commas for better formatting
Go backend:
```bash
dart format .
cd go_backend
gofmt -w .
go vet ./...
go test ./...
```
### Linting
Ensure your code passes all lints:
Android native code, after building `gobackend.aar`:
```bash
flutter analyze
cd android
./gradlew :app:compileDebugKotlin :app:testDebugUnitTest
```
### State Management
For user-facing changes, add or update tests where practical and include
before/after screenshots for UI changes.
We use **Riverpod** for state management. Follow these patterns:
## Code and Commit Style
```dart
// Use code generation with riverpod_annotation
@riverpod
class MyNotifier extends _$MyNotifier {
@override
MyState build() => MyState();
// Methods to update state
}
```
- Follow `analysis_options.yaml`, `.editorconfig`, and existing module patterns.
- Keep user-facing strings in the localization files.
- Prefer small functions and explicit error handling at platform boundaries.
- Use [Conventional Commits](https://www.conventionalcommits.org/), for example:
### Localization
```text
feat(download): add batch selection
fix(storage): handle revoked folder access
docs(contributing): refresh Android setup
```
All user-facing strings should be localized:
## Pull Requests
```dart
// Good
Text(AppLocalizations.of(context)!.downloadComplete)
1. Create a branch from an up-to-date `main`.
2. Make one focused change and include tests or verification evidence.
3. Complete the pull request template, including any checks that were not run
and why.
4. Link related issues with `Fixes #123` where appropriate.
5. Respond to review feedback with follow-up commits; maintainers may squash
commits when merging.
// Bad
Text('Download Complete')
```
To add new strings:
1. Add the key to `lib/l10n/arb/app_en.arb`
2. Run `flutter gen-l10n`
## Commit Guidelines
We follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
```
### Types
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `style`: Code style changes (formatting, etc.)
- `refactor`: Code refactoring
- `perf`: Performance improvements
- `test`: Adding or updating tests
- `chore`: Maintenance tasks
### Examples
```
feat(download): add batch download support
fix(ui): resolve overflow on small screens
docs: update contributing guidelines
chore(deps): update flutter_riverpod to 3.1.0
```
## Pull Request Process
1. **Update your fork**
```bash
git fetch upstream
git rebase upstream/dev
```
2. **Create a feature branch**
```bash
git checkout -b feat/my-new-feature
```
3. **Make your changes** and commit following our guidelines
4. **Push to your fork**
```bash
git push origin feat/my-new-feature
```
5. **Create a Pull Request**
- Target the `dev` branch
- Fill in the PR template
- Link related issues
6. **Address review feedback**
- Make requested changes
- Push additional commits
- Request re-review when ready
### PR Requirements
- [ ] Code follows project conventions
- [ ] All tests pass
- [ ] No new linting errors
- [ ] Documentation updated (if needed)
- [ ] Commit messages follow guidelines
- [ ] PR description is clear and complete
## Questions?
If you have questions, feel free to:
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
Thank you for contributing! 💚
When reporting a crash, include the SpotiFLAC Mobile version, release channel,
device/OS, exact reproduction steps, storage mode, and exported app logs. For a
cold-start Android crash, `adb logcat -b crash -d` is especially useful.
+31 -11
View File
@@ -16,8 +16,7 @@
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
[![GitHub Release](https://img.shields.io/github/v/release/spotiflacapp/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
@@ -52,7 +51,22 @@ Extensions let the community add new music sources and features without waiting
### Developing Extensions
> [!NOTE]
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
> Want to build your own extension? Start with the
> [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md). The
> [documentation site](https://spotiflac.zarz.moe/docs) contains the expanded
> API reference.
---
## Development
SpotiFLAC Mobile combines a Flutter/Dart UI, a Go backend compiled with
`gomobile`, and thin Android/iOS platform bridges. Toolchain versions are
pinned in [`.fvmrc`](.fvmrc), [`go_backend/go.mod`](go_backend/go.mod), and the
GitHub Actions workflows.
Start with the [Contributing Guide](CONTRIBUTING.md) for the development setup,
project boundaries, validation commands, and pull request checklist.
---
@@ -72,7 +86,10 @@ Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
<br>
Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
Starting from version 3.8.0, SpotiFLAC Mobile uses a decentralized extension
repository system. Extensions are hosted independently rather than on a
built-in server, so anyone can create and host a compatible repository. Enter
a repository URL in the Store tab to browse and install extensions.
</details>
@@ -104,7 +121,9 @@ Yes! Just paste the playlist URL in the search bar. The app will fetch all track
<summary><b>Why do I need to grant storage permission?</b></summary>
<br>
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
The app needs a writable destination for downloaded files. On Android, choose a
folder with the system folder picker (SAF), or use the app-specific folder. If
Android revokes a saved folder grant, select the folder again in Settings.
</details>
@@ -112,7 +131,8 @@ The app needs permission to save downloaded files to your device. On Android 13+
<summary><b>Is this app safe?</b></summary>
<br>
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
SpotiFLAC Mobile is open source, so its code and build workflows can be
inspected directly in this repository.
</details>
@@ -125,13 +145,13 @@ Some countries have restricted access to certain streaming service APIs. If down
</details>
<details>
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
<summary><b>Can I add SpotiFLAC Mobile to AltStore or SideStore?</b></summary>
<br>
Yes! Add the official source to receive updates directly within the app. Copy this link:
```
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Mobile/refs/heads/main/apps.json
```
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
@@ -139,7 +159,7 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
</details>
> [!NOTE]
> If SpotiFLAC is useful to you, consider supporting development:
> If SpotiFLAC Mobile is useful to you, consider supporting development:
>
> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
@@ -149,8 +169,8 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
Thanks to everyone who has contributed to SpotiFLAC Mobile!
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
<a href="https://github.com/spotiflacapp/SpotiFLAC-Mobile/graphs/contributors">
<img src="https://contrib.rocks/image?repo=spotiflacapp/SpotiFLAC-Mobile" />
</a>
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
+3
View File
@@ -43,6 +43,9 @@ linter:
type_annotate_public_apis: true
cancel_subscriptions: true
close_sinks: true
# Catches dead cross-layer chains (Dart wrapper kept alive only by its own
# declaration) before they accumulate into another dedup campaign.
unreachable_from_main: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+7 -2
View File
@@ -123,8 +123,13 @@ dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.activity:activity-ktx:1.13.0")
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
// NativeDownloadFinalizer imports FFmpegKit APIs directly. The Flutter
// plugin owns the runtime AAR; compileOnly avoids packaging it twice here.
compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1")
testImplementation("junit:junit:4.13.2")
}
+15
View File
@@ -3,6 +3,7 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
@@ -21,6 +22,9 @@
android:label="SpotiFLAC Mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:fullBackupContent="@xml/backup_rules_legacy"
android:dataExtractionRules="@xml/backup_rules"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
@@ -114,6 +118,17 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".DownloadQueueWidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_download_queue_info" />
</receiver>
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
@@ -0,0 +1,131 @@
package com.zarz.spotiflac
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.view.View
import android.widget.RemoteViews
/**
* Home-screen widget showing the active download. Updated only on discrete
* events (track transition, status change, coarse progress steps) from
* DownloadService — never per progress byte — to respect battery discipline.
* The last pushed state is persisted so launcher-driven refreshes (reboot,
* resize) render without the service running.
*/
class DownloadQueueWidgetProvider : AppWidgetProvider() {
override fun onEnabled(context: Context) {
widgetPreferences(context).edit().putBoolean(KEY_ENABLED, true).apply()
}
override fun onDisabled(context: Context) {
widgetPreferences(context).edit().putBoolean(KEY_ENABLED, false).apply()
}
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
if (appWidgetIds.isNotEmpty()) {
widgetPreferences(context).edit().putBoolean(KEY_ENABLED, true).apply()
}
render(context, appWidgetManager, appWidgetIds)
}
companion object {
private const val PREFS = "download_widget_state"
private const val KEY_ENABLED = "enabled"
private const val KEY_RUNNING = "running"
private const val KEY_TITLE = "title"
private const val KEY_SUBTITLE = "subtitle"
private const val KEY_PERCENT = "percent" // -1 = indeterminate
/** Persists the state and re-renders all widget instances. */
fun push(
context: Context,
running: Boolean,
title: String = "",
subtitle: String = "",
percent: Int = -1,
) {
val prefs = widgetPreferences(context)
// Most users never add the optional launcher widget. Keep the
// download hot path free of AppWidget IPC and preference writes
// for those users.
if (!prefs.getBoolean(KEY_ENABLED, false)) return
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(
ComponentName(context, DownloadQueueWidgetProvider::class.java)
)
if (ids.isEmpty()) {
prefs.edit().putBoolean(KEY_ENABLED, false).apply()
return
}
prefs.edit()
.putBoolean(KEY_RUNNING, running)
.putString(KEY_TITLE, title)
.putString(KEY_SUBTITLE, subtitle)
.putInt(KEY_PERCENT, percent)
.apply()
render(context, manager, ids)
}
private fun widgetPreferences(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun render(
context: Context,
manager: AppWidgetManager,
ids: IntArray,
) {
val prefs = widgetPreferences(context)
val running = prefs.getBoolean(KEY_RUNNING, false)
val views = RemoteViews(context.packageName, R.layout.widget_download_queue)
if (running) {
views.setTextViewText(
R.id.widget_title,
prefs.getString(KEY_TITLE, "").orEmpty().ifEmpty { "Downloading..." }
)
views.setTextViewText(
R.id.widget_subtitle,
prefs.getString(KEY_SUBTITLE, "").orEmpty()
)
views.setViewVisibility(R.id.widget_progress, View.VISIBLE)
val percent = prefs.getInt(KEY_PERCENT, -1)
if (percent in 0..100) {
views.setProgressBar(R.id.widget_progress, 100, percent, false)
} else {
views.setProgressBar(R.id.widget_progress, 100, 0, true)
}
} else {
views.setTextViewText(R.id.widget_title, "SpotiFLAC")
views.setTextViewText(R.id.widget_subtitle, "No active downloads")
views.setViewVisibility(R.id.widget_progress, View.GONE)
}
val launchIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
views.setOnClickPendingIntent(
R.id.widget_root,
PendingIntent.getActivity(
context,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
)
for (id in ids) {
manager.updateAppWidget(id, views)
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
package com.zarz.spotiflac
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.AtomicFile
import androidx.core.app.NotificationCompat
import gobackend.Gobackend
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicLong
// Wi-Fi-only download policy: network callback, pause and resume.
internal fun DownloadService.configureNativeWorkerNetworkPolicy(settingsJson: String) {
nativeWorkerDownloadNetworkMode = try {
JSONObject(settingsJson).optString("download_network_mode", "any")
} catch (_: Exception) {
"any"
}
unregisterNativeWorkerNetworkCallback()
if (!NativeWorkerPolicy.requiresWifi(nativeWorkerDownloadNetworkMode)) {
nativeWorkerNetworkPaused = false
return
}
nativeWorkerNetworkPaused = !hasUsableWifiConnection()
val connectivityManager =
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
synchronized(nativeWorkerWifiNetworks) {
nativeWorkerWifiNetworks.add(network)
}
refreshNativeWorkerNetworkPause()
}
override fun onLost(network: Network) {
synchronized(nativeWorkerWifiNetworks) {
nativeWorkerWifiNetworks.remove(network)
}
refreshNativeWorkerNetworkPause()
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
synchronized(nativeWorkerWifiNetworks) {
if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
networkCapabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET,
)
) {
nativeWorkerWifiNetworks.add(network)
} else {
nativeWorkerWifiNetworks.remove(network)
}
}
refreshNativeWorkerNetworkPause()
}
}
try {
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
nativeWorkerNetworkCallback = callback
} catch (e: Exception) {
android.util.Log.w(
"DownloadService",
"Failed to monitor Wi-Fi for native worker: ${e.message}",
)
}
}
internal fun DownloadService.hasUsableWifiConnection(): Boolean {
val connectivityManager =
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
if (synchronized(nativeWorkerWifiNetworks) {
nativeWorkerWifiNetworks.isNotEmpty()
}
) {
return true
}
return try {
val activeNetwork = connectivityManager.activeNetwork ?: return false
val capabilities =
connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} catch (_: Exception) {
false
}
}
internal fun DownloadService.refreshNativeWorkerNetworkPause() {
if (!NativeWorkerPolicy.requiresWifi(nativeWorkerDownloadNetworkMode)) return
val shouldPause = NativeWorkerPolicy.shouldPauseForNetwork(
nativeWorkerDownloadNetworkMode,
hasUsableWifiConnection(),
)
if (shouldPause == nativeWorkerNetworkPaused) return
nativeWorkerNetworkPaused = shouldPause
if (nativeWorkerJob?.isActive != true) return
if (shouldPause) {
currentStatus = if (nativeWorkerVerificationPaused) {
"verification_required"
} else {
"waiting_wifi"
}
cancelActiveNativeItemForPause()
updateNotification(0L, 0L)
} else {
currentStatus = if (nativeWorkerVerificationPaused) {
"verification_required"
} else {
"preparing"
}
updateNotification(0L, 0L)
}
writeNativeWorkerSnapshotAsync(
isRunning = nativeWorkerJob?.isActive == true,
isPaused = isNativeWorkerPaused(),
currentItemId = "",
message = if (isNativeWorkerPaused()) {
nativeWorkerPauseMessage()
} else {
"Wi-Fi restored"
},
includeItems = true,
)
}
internal fun DownloadService.unregisterNativeWorkerNetworkCallback() {
val callback = nativeWorkerNetworkCallback
nativeWorkerNetworkCallback = null
synchronized(nativeWorkerWifiNetworks) {
nativeWorkerWifiNetworks.clear()
}
if (callback == null) return
try {
val connectivityManager =
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.unregisterNetworkCallback(callback)
} catch (_: Exception) {
}
}
@@ -0,0 +1,244 @@
package com.zarz.spotiflac
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.AtomicFile
import androidx.core.app.NotificationCompat
import gobackend.Gobackend
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicLong
// Album ReplayGain accumulation and journal persistence for the native worker.
internal fun DownloadService.writeNativeAlbumReplayGainIfComplete(): Boolean {
val entries = synchronized(nativeReplayGainEntries) {
nativeReplayGainEntries.map { JSONObject(it.toString()) }
}
if (entries.size <= 1) return true
val statuses = synchronized(nativeWorkerItems) {
nativeWorkerItems.associate { it.itemId to it.status }
}
val requestKeys = synchronized(nativeReplayGainRequestAlbumKeys) {
nativeReplayGainRequestAlbumKeys.toMap()
}
val eligible = buildEligibleNativeAlbumReplayGain(entries, statuses, requestKeys)
if (eligible.length() <= 1) {
return !hasPendingNativeAlbumReplayGainWork(statuses)
}
return writeNativeAlbumReplayGainEntries(eligible)
}
internal fun DownloadService.buildEligibleNativeAlbumReplayGain(
entries: List<JSONObject>,
statuses: Map<String, String>,
requestKeys: Map<String, String>
): JSONArray {
val eligibleIndexes = NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = entries.map { it.optString("album_key", "") },
statuses = statuses,
requestAlbumKeys = requestKeys,
)
val eligible = JSONArray()
for (index in eligibleIndexes) {
eligible.put(entries[index])
}
return eligible
}
internal fun DownloadService.writeNativeAlbumReplayGainEntries(eligible: JSONArray): Boolean {
if (eligible.length() <= 1) return true
try {
val result = JSONObject(NativeDownloadFinalizer.writeAlbumReplayGain(this, eligible.toString()))
return result.optBoolean("success", false)
} catch (e: Exception) {
android.util.Log.w("DownloadService", "Native album ReplayGain failed: ${e.message}")
return false
}
}
internal fun DownloadService.hasPendingNativeAlbumReplayGainWork(statuses: Map<String, String>): Boolean {
return NativeReplayGainPolicy.hasPendingWork(statuses)
}
internal fun DownloadService.writeNativeReplayGainJournal() {
val requestKeys = synchronized(nativeReplayGainRequestAlbumKeys) {
nativeReplayGainRequestAlbumKeys.toMap()
}
if (requestKeys.isEmpty()) return
val entries = synchronized(nativeReplayGainEntries) {
nativeReplayGainEntries.map { JSONObject(it.toString()) }
}
val statuses = synchronized(nativeWorkerItems) {
nativeWorkerItems.associate { it.itemId to it.status }
}
synchronized(DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
val file = AtomicFile(File(filesDir, DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE))
val existing = readNativeReplayGainJournalLocked(file)
val mergedEntries = mergeNativeReplayGainJournalEntries(
existing?.optJSONArray("entries"),
entries,
)
val mergedRequestKeys = mergeJsonObjectStringMap(
existing?.optJSONObject("request_album_keys"),
requestKeys,
)
val mergedStatuses = mergeJsonObjectStringMap(
existing?.optJSONObject("statuses"),
statuses,
)
val root = JSONObject()
.put("run_id", nativeWorkerRunId)
.put("updated_at", System.currentTimeMillis())
.put("entries", mergedEntries)
.put("request_album_keys", JSONObject(mergedRequestKeys))
.put("statuses", JSONObject(mergedStatuses))
var stream: java.io.FileOutputStream? = null
try {
stream = file.startWrite()
stream.write(root.toString().toByteArray(Charsets.UTF_8))
file.finishWrite(stream)
stream = null
} catch (e: Exception) {
android.util.Log.w("DownloadService", "Failed to write native ReplayGain journal: ${e.message}")
} finally {
if (stream != null) {
file.failWrite(stream)
}
}
}
}
internal fun DownloadService.readNativeReplayGainJournalLocked(file: AtomicFile): JSONObject? {
return try {
if (!file.baseFile.exists()) return null
val text = file.openRead().bufferedReader(Charsets.UTF_8).use {
it.readText()
}
JSONObject(text)
} catch (e: Exception) {
android.util.Log.w("DownloadService", "Failed to merge native ReplayGain journal: ${e.message}")
null
}
}
internal fun DownloadService.mergeNativeReplayGainJournalEntries(
existingEntries: JSONArray?,
currentEntries: List<JSONObject>
): JSONArray {
val byKey = linkedMapOf<String, JSONObject>()
fun add(entry: JSONObject) {
val trackId = entry.optString("track_id", "")
val path = entry.optString("file_path", "")
val key = if (trackId.isNotBlank()) {
"track:$trackId"
} else {
"path:$path"
}
if (key != "path:") {
byKey[key] = JSONObject(entry.toString())
}
}
if (existingEntries != null) {
for (index in 0 until existingEntries.length()) {
existingEntries.optJSONObject(index)?.let(::add)
}
}
for (entry in currentEntries) add(entry)
return JSONArray().apply {
for (entry in byKey.values) put(entry)
}
}
internal fun DownloadService.mergeJsonObjectStringMap(
existing: JSONObject?,
current: Map<String, String>
): Map<String, String> {
val merged = linkedMapOf<String, String>()
if (existing != null) {
for (key in existing.keys()) {
merged[key] = existing.optString(key, "")
}
}
for ((key, value) in current) {
merged[key] = value
}
return merged
}
internal fun DownloadService.clearNativeReplayGainJournal() {
synchronized(DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
try {
AtomicFile(File(filesDir, DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE)).delete()
} catch (_: Exception) {
}
}
}
internal fun DownloadService.flushNativeAlbumReplayGainJournalIfComplete() {
val root = synchronized(DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
try {
val file = File(filesDir, DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE)
if (!file.exists()) return
val text = AtomicFile(file).openRead().bufferedReader(Charsets.UTF_8).use {
it.readText()
}
JSONObject(text)
} catch (e: Exception) {
android.util.Log.w("DownloadService", "Failed to read native ReplayGain journal: ${e.message}")
return
}
}
val entriesArray = root.optJSONArray("entries") ?: return
val entries = mutableListOf<JSONObject>()
for (index in 0 until entriesArray.length()) {
entriesArray.optJSONObject(index)?.let { entries.add(JSONObject(it.toString())) }
}
val statusesJson = root.optJSONObject("statuses") ?: JSONObject()
val statuses = mutableMapOf<String, String>()
for (key in statusesJson.keys()) {
statuses[key] = statusesJson.optString(key, "")
}
val requestKeysJson = root.optJSONObject("request_album_keys") ?: JSONObject()
val requestKeys = mutableMapOf<String, String>()
for (key in requestKeysJson.keys()) {
requestKeys[key] = requestKeysJson.optString(key, "")
}
val eligible = buildEligibleNativeAlbumReplayGain(entries, statuses, requestKeys)
if (eligible.length() <= 1 && hasPendingNativeAlbumReplayGainWork(statuses)) {
return
}
if (writeNativeAlbumReplayGainEntries(eligible)) {
clearNativeReplayGainJournal()
}
}
@@ -0,0 +1,297 @@
package com.zarz.spotiflac
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.AtomicFile
import androidx.core.app.NotificationCompat
import gobackend.Gobackend
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicLong
// Native-worker item state snapshots for the Flutter side.
internal fun DownloadService.writeNativeWorkerSnapshot(
isRunning: Boolean,
isPaused: Boolean,
currentItemId: String,
message: String,
lastResult: JSONObject? = null,
settingsJson: String = "",
includeItems: Boolean = false,
snapshotSerial: Long = snapshotWriteSerial.incrementAndGet()
) {
try {
synchronized(snapshotWriteLock) {
if (includeItems) {
if (snapshotSerial < latestCommittedStateSnapshotSerial) return
} else {
if (snapshotSerial < latestCommittedProgressSnapshotSerial) return
}
val counts = nativeWorkerCounts()
val snapshot = JSONObject()
.put("contract_version", DownloadService.NATIVE_WORKER_CONTRACT_VERSION)
.put("run_id", nativeWorkerRunId.ifBlank { readNativeWorkerRunIdFromSnapshotFile() })
.put("is_running", isRunning)
.put("is_paused", isPaused)
.put("total", counts.total)
.put("completed", counts.completed)
.put("failed", counts.failed)
.put("skipped", counts.skipped)
.put("current_item_id", currentItemId)
.put("message", message)
.put("updated_at", System.currentTimeMillis())
.put("snapshot_serial", snapshotSerial)
.put("state_serial", if (includeItems) snapshotSerial else latestCommittedStateSnapshotSerial)
.put("snapshot_mode", if (includeItems) "compact_items" else "delta")
// Snapshot of the header before the per-item payload is
// attached; served to pollers that already consumed this
// items payload (see getNativeWorkerSnapshot).
val headerCandidate = if (includeItems) snapshot.toString() else null
snapshot.put("item_ids", nativeWorkerItemIds())
if (includeItems) {
snapshot.put("items", nativeWorkerItemsSnapshot(includeStatic = false))
} else {
nativeWorkerItemSnapshot(currentItemId, includeStatic = false)?.let {
snapshot.put("item_delta", it)
}
}
if (settingsJson.isNotBlank() && includeItems) {
snapshot.put("settings_json", settingsJson)
}
if (lastResult != null) {
snapshot.put("last_result", lastResult)
}
synchronized(DownloadService.NATIVE_WORKER_STATE_FILE_LOCK) {
val targetFileName = if (includeItems) {
DownloadService.NATIVE_WORKER_STATE_FILE
} else {
DownloadService.NATIVE_WORKER_PROGRESS_FILE
}
val file = AtomicFile(File(filesDir, targetFileName))
var stream: java.io.FileOutputStream? = null
try {
stream = file.startWrite()
stream.write(snapshot.toString().toByteArray(Charsets.UTF_8))
file.finishWrite(stream)
stream = null
if (includeItems) {
latestCommittedStateSnapshotSerial = snapshotSerial
if (headerCandidate != null) {
DownloadService.lastStateHeaderJson = headerCandidate
DownloadService.lastStateHeaderSerial = snapshotSerial
}
} else {
latestCommittedProgressSnapshotSerial = snapshotSerial
}
} finally {
if (stream != null) {
file.failWrite(stream)
}
}
}
}
} catch (e: Exception) {
android.util.Log.w("DownloadService", "Failed to write native worker snapshot: ${e.message}")
}
}
internal fun DownloadService.writeNativeWorkerSnapshotAsync(
isRunning: Boolean,
isPaused: Boolean,
currentItemId: String,
message: String,
lastResult: JSONObject? = null,
settingsJson: String = "",
includeItems: Boolean = false
) {
val snapshotSerial = snapshotWriteSerial.incrementAndGet()
serviceScope.launch {
writeNativeWorkerSnapshot(
isRunning = isRunning,
isPaused = isPaused,
currentItemId = currentItemId,
message = message,
lastResult = lastResult,
settingsJson = settingsJson,
includeItems = includeItems,
snapshotSerial = snapshotSerial
)
}
}
internal fun DownloadService.readNativeWorkerRunIdFromSnapshotFile(): String {
return try {
synchronized(DownloadService.NATIVE_WORKER_STATE_FILE_LOCK) {
val file = File(filesDir, DownloadService.NATIVE_WORKER_STATE_FILE)
if (!file.exists()) {
""
} else {
val text = AtomicFile(file).openRead().bufferedReader(Charsets.UTF_8).use {
it.readText()
}
JSONObject(text).optString("run_id", "")
}
}
} catch (_: Exception) {
""
}
}
internal fun DownloadService.updateNativeWorkerItem(itemId: String, updater: (DownloadService.NativeWorkerItem) -> Unit) {
synchronized(nativeWorkerItems) {
nativeWorkerItems.firstOrNull { it.itemId == itemId }?.let(updater)
}
}
internal fun DownloadService.updateNativeWorkerItemProgress(itemId: String) {
try {
val raw = Gobackend.getAllDownloadProgress()
val root = JSONObject(raw)
val items = root.optJSONObject("items") ?: return
val progress = items.optJSONObject(itemId) ?: return
val backendStatus = progress.optString("status", "downloading")
val bytesReceived = progress.optLong("bytes_received", 0L)
val bytesTotal = progress.optLong("bytes_total", 0L)
if (backendStatus == "preparing") {
currentStatus = "preparing"
updateNativeWorkerItem(itemId) {
it.status = "preparing"
it.progress = 0.0
it.bytesReceived = 0L
it.bytesTotal = 0L
}
lastProgress = 0L
lastTotal = 0L
updateNotification(0L, 0L)
return
}
val progressValue = if (bytesTotal > 0L) {
bytesReceived.toDouble() / bytesTotal.toDouble()
} else {
progress.optDouble("progress", 0.0)
}.coerceIn(0.0, 1.0)
currentStatus = if (backendStatus == "finalizing") {
"finalizing"
} else {
"downloading"
}
updateNativeWorkerItem(itemId) {
it.status = currentStatus
it.progress = progressValue
it.bytesReceived = bytesReceived
it.bytesTotal = bytesTotal
}
if (bytesTotal > 0L) {
lastProgress = bytesReceived
lastTotal = bytesTotal
updateNotification(bytesReceived, bytesTotal)
} else if (progressValue > 0.0) {
val percentProgress = (progressValue * DownloadService.NOTIFICATION_PERCENT_TOTAL).toLong()
.coerceIn(0L, DownloadService.NOTIFICATION_PERCENT_TOTAL)
lastProgress = percentProgress
lastTotal = DownloadService.NOTIFICATION_PERCENT_TOTAL
updateNotification(percentProgress, DownloadService.NOTIFICATION_PERCENT_TOTAL)
} else {
lastProgress = 0L
lastTotal = 0L
updateNotification(0L, 0L)
}
} catch (_: Exception) {
}
}
internal fun DownloadService.nativeWorkerCounts(): DownloadService.NativeWorkerCounts {
var total = 0
var completed = 0
var failed = 0
var skipped = 0
synchronized(nativeWorkerItems) {
total = nativeWorkerItems.size
for (item in nativeWorkerItems) {
when (item.status) {
"completed" -> completed++
"failed" -> failed++
"skipped" -> skipped++
}
}
}
return DownloadService.NativeWorkerCounts(
total = total,
completed = completed,
failed = failed,
skipped = skipped
)
}
internal fun DownloadService.nativeWorkerItemSnapshot(itemId: String, includeStatic: Boolean): JSONObject? {
if (itemId.isBlank()) return null
synchronized(nativeWorkerItems) {
val item = nativeWorkerItems.firstOrNull { it.itemId == itemId } ?: return null
return nativeWorkerItemSnapshotLocked(item, includeStatic)
}
}
internal fun DownloadService.nativeWorkerItemIds(): JSONArray {
val array = JSONArray()
synchronized(nativeWorkerItems) {
for (item in nativeWorkerItems) {
array.put(item.itemId)
}
}
return array
}
internal fun DownloadService.nativeWorkerItemsSnapshot(includeStatic: Boolean): JSONArray {
val array = JSONArray()
synchronized(nativeWorkerItems) {
for (item in nativeWorkerItems) {
array.put(nativeWorkerItemSnapshotLocked(item, includeStatic))
}
}
return array
}
internal fun DownloadService.nativeWorkerItemSnapshotLocked(item: DownloadService.NativeWorkerItem, includeStatic: Boolean): JSONObject {
val json = JSONObject()
.put("item_id", item.itemId)
.put("status", item.status)
.put("progress", item.progress)
.put("bytes_received", item.bytesReceived)
.put("bytes_total", item.bytesTotal)
if (includeStatic) {
json.put("track_name", item.trackName)
.put("artist_name", item.artistName)
.put("item_json", item.itemJson)
}
if (item.error.isNotBlank()) {
json.put("error", item.error)
}
item.resultJson?.let { json.put("result", it) }
return json
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,471 @@
package com.zarz.spotiflac
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode
import io.flutter.embedding.android.FlutterFragment
import io.flutter.embedding.android.RenderMode
import io.flutter.embedding.android.TransparencyMode
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
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.Locale
// SAF/MediaStore URI IO helpers: temp copies, writes, sidecars, and the
// SAF post-processing pipeline.
internal fun MainActivity.errorJson(message: String): String {
val obj = JSONObject()
obj.put("success", false)
obj.put("error", message)
obj.put("message", message)
return obj.toString()
}
/**
* Detect whether a content URI belongs to the MediaStore provider.
* Samsung One UI may return MediaStore URIs from SAF tree traversal,
* which require READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission
* instead of SAF tree permission.
*/
internal fun MainActivity.isMediaStoreUri(uri: Uri): Boolean {
val authority = uri.authority ?: return false
return authority == "media" ||
authority.startsWith("media.") ||
authority.contains("media")
}
/**
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
*/
internal fun MainActivity.resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
try {
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val name = cursor.getString(0)?.lowercase(Locale.ROOT) ?: ""
val ext = extFromFileName(name)
if (ext.isNotBlank()) return ext
}
}
} catch (_: Exception) {}
try {
val mime = contentResolver.getType(uri)
val ext = extFromMimeType(mime)
if (ext.isNotBlank()) return ext
} catch (_: Exception) {}
return fallbackExt ?: ""
}
internal fun MainActivity.extFromFileName(name: String): String {
return when {
name.endsWith(".m4a") -> ".m4a"
name.endsWith(".mp4") -> ".mp4"
name.endsWith(".aac") -> ".aac"
name.endsWith(".mp3") -> ".mp3"
name.endsWith(".opus") -> ".opus"
name.endsWith(".flac") -> ".flac"
name.endsWith(".ogg") -> ".ogg"
name.endsWith(".wave") -> ".wav"
name.endsWith(".wav") -> ".wav"
name.endsWith(".aiff") -> ".aiff"
name.endsWith(".aifc") -> ".aifc"
name.endsWith(".aif") -> ".aif"
else -> ""
}
}
internal fun MainActivity.extFromMimeType(mime: String?): String {
return when (mime) {
"audio/mp4" -> ".m4a"
"audio/aac" -> ".aac"
"audio/eac3" -> ".m4a"
"audio/ac3" -> ".m4a"
"audio/ac4" -> ".m4a"
"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 -> ""
}
}
internal fun MainActivity.copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? {
var tempFile: File? = null
var success = false
try {
val mime = try { contentResolver.getType(uri) } catch (_: Exception) { null }
val nameHint = (
try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
?: uri.lastPathSegment
?: ""
).lowercase(Locale.ROOT)
val extFromName = extFromFileName(nameHint)
val extFromMime = extFromMimeType(mime)
val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "")
val suffix: String? = if (ext.isNotBlank()) ext else null
tempFile = File.createTempFile("saf_", suffix, cacheDir)
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tempFile).use { output ->
input.copyTo(output)
}
} ?: return null
success = true
return tempFile.absolutePath
} catch (e: SecurityException) {
// SAF permission denied - try MediaStore fallback for Samsung One UI
// which may return MediaStore URIs from SAF tree traversal
if (isMediaStoreUri(uri)) {
android.util.Log.d(
"SpotiFLAC",
"SAF denied for MediaStore URI, trying MediaStore fallback: $uri",
)
val result = copyMediaStoreUriToTemp(uri, fallbackExt)
if (result != null) {
success = true
return result
}
}
android.util.Log.w(
"SpotiFLAC",
"SAF read denied for $uri: ${e.message}",
)
return null
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Failed copying SAF uri $uri to temp: ${e.message}",
)
return null
} finally {
if (!success) {
try {
tempFile?.delete()
} catch (_: Exception) {}
}
}
}
/**
* Fallback for Samsung One UI: read a MediaStore content URI using
* READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission instead of SAF.
* This handles the case where SAF tree traversal returns MediaStore URIs
* that the SAF document provider cannot access.
*/
internal fun MainActivity.copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String?): String? {
var tempFile: File? = null
try {
val ext = resolveMediaStoreExt(uri, fallbackExt)
val suffix: String? = if (ext.isNotBlank()) ext else null
tempFile = File.createTempFile("ms_", suffix, cacheDir)
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tempFile).use { output ->
input.copyTo(output)
}
} ?: run {
tempFile.delete()
return null
}
android.util.Log.d(
"SpotiFLAC",
"MediaStore fallback succeeded for $uri",
)
return tempFile.absolutePath
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"MediaStore fallback also failed for $uri: ${e.message}",
)
try { tempFile?.delete() } catch (_: Exception) {}
return null
}
}
internal fun MainActivity.buildUriDisplayName(
uri: Uri,
displayNameHint: String? = null,
fallbackExt: String? = null,
): String {
val explicitName = displayNameHint?.trim().orEmpty()
if (explicitName.isNotEmpty()) return explicitName
val docName = try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
val uriName = uri.lastPathSegment
val resolvedName = (docName ?: uriName ?: "").trim()
if (resolvedName.isNotEmpty()) return resolvedName
val ext = when {
fallbackExt.isNullOrBlank().not() -> fallbackExt
isMediaStoreUri(uri) -> resolveMediaStoreExt(uri, fallbackExt)
else -> ""
}
return if (ext.isNullOrBlank()) "audio" else "audio$ext"
}
internal fun MainActivity.buildLibraryCoverCacheKey(stablePath: String, lastModified: Long): String {
val normalizedPath = stablePath.trim()
if (normalizedPath.isEmpty()) return ""
return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath
}
internal fun MainActivity.readAudioMetadataFromUri(
uri: Uri,
displayNameHint: String? = null,
fallbackExt: String? = null,
coverCacheKey: String = "",
): JSONObject? {
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
// Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux).
if (procSelfFdReadable != false) {
try {
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val directPath = "/proc/self/fd/${pfd.fd}"
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
directPath,
displayName,
coverCacheKey,
)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
val filenameFallback = obj.optBoolean("metadataFromFilename", false)
if (!obj.has("error") && !filenameFallback) {
procSelfFdReadable = true
return obj
}
// Go could not read real metadata from the fd path
// remember so we skip the attempt for remaining files.
if (procSelfFdReadable == null) {
procSelfFdReadable = false
android.util.Log.d(
"SpotiFLAC",
"Direct /proc/self/fd read not usable on this device, " +
"using temp-file fallback for remaining files",
)
}
}
}
} catch (e: Exception) {
if (procSelfFdReadable == null) {
procSelfFdReadable = false
android.util.Log.d(
"SpotiFLAC",
"Direct /proc/self/fd read not usable on this device, " +
"using temp-file fallback for remaining files",
)
}
}
}
val tempPath = try {
copyUriToTemp(uri, fallbackExt)
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"SAF metadata fallback copy failed for $uri: ${e.message}",
)
null
} ?: return null
try {
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
tempPath,
displayName,
coverCacheKey,
)
if (metadataJson.isBlank()) return null
val obj = JSONObject(metadataJson)
return if (obj.has("error")) null else obj
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"SAF metadata temp read failed for $uri: ${e.message}",
)
return null
} finally {
try {
File(tempPath).delete()
} catch (_: Exception) {}
}
}
internal fun MainActivity.writeUriFromPath(uri: Uri, srcPath: String): Boolean {
val srcFile = File(srcPath)
if (!srcFile.exists()) return false
contentResolver.openOutputStream(uri, "wt")?.use { output ->
FileInputStream(srcFile).use { input ->
input.copyTo(output)
}
} ?: return false
return true
}
/**
* Get the parent DocumentFile directory for a SAF document URI.
* The child URI must be a tree-based document URI (e.g. from SAF tree scan).
* Returns a DocumentFile that supports findFile() for sibling lookup.
*/
internal fun MainActivity.safParentDir(childUri: Uri): DocumentFile? {
try {
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
if (docId.isNullOrEmpty()) return null
val lastSlash = docId.lastIndexOf('/')
if (lastSlash <= 0) return null
val parentDocId = docId.substring(0, lastSlash)
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
if (treeDocId.isNullOrEmpty()) return null
val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(
childUri, parentDocId
)
return DocumentFile.fromTreeUri(this, parentUri)
?: DocumentFile.fromSingleUri(this, parentUri)
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to get SAF parent dir: ${e.message}")
return null
}
}
/**
* Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses
* the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created
* in the same parent directory. Used by re-enrich when the user's lyrics
* mode requests an external/both sidecar. Best-effort: failures are logged
* and swallowed so they never abort the metadata enrichment itself.
*/
internal fun MainActivity.writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean {
if (lrcContent.isBlank()) return false
try {
val parent = safParentDir(audioUri) ?: run {
android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir")
return false
}
val audioName = try {
DocumentFile.fromSingleUri(this, audioUri)?.name
} catch (_: Exception) {
null
} ?: return false
val baseName = audioName.substringBeforeLast('.', audioName)
val lrcName = "$baseName.lrc"
val target = SafDownloadHandler.createOrReuseDocumentFile(
parent,
"application/octet-stream",
lrcName
) ?: run {
android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName")
return false
}
contentResolver.openOutputStream(target.uri, "wt")?.use { output ->
output.write(lrcContent.toByteArray(Charsets.UTF_8))
} ?: return false
android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName")
return true
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}")
return false
}
}
internal fun MainActivity.runPostProcessingSafV2(fileUriStr: String, metadataJson: String): String {
val uri = Uri.parse(fileUriStr)
val doc = DocumentFile.fromSingleUri(this, uri)
?: return errorJson("SAF file not found")
val tempInput = copyUriToTemp(uri) ?: return errorJson("Failed to copy SAF file to temp")
val tempDir = File(tempInput).parentFile?.absolutePath ?: ""
if (tempDir.isNotBlank()) {
try {
Gobackend.allowDownloadDir(tempDir)
} catch (_: Exception) {}
}
val inputObj = JSONObject()
inputObj.put("path", tempInput)
inputObj.put("uri", fileUriStr)
inputObj.put("name", doc.name ?: File(tempInput).name)
inputObj.put("mime_type", doc.type ?: contentResolver.getType(uri) ?: "")
inputObj.put("size", doc.length())
inputObj.put("is_saf", true)
val response = Gobackend.runPostProcessingV2JSON(inputObj.toString(), metadataJson)
val respObj = JSONObject(response)
if (!respObj.optBoolean("success", false)) {
try {
File(tempInput).delete()
} catch (_: Exception) {}
return response
}
val newPath = respObj.optString("new_file_path", "")
val outputPath = if (newPath.isNotBlank()) newPath else tempInput
val outputFile = File(outputPath)
if (!outputFile.exists()) {
try {
File(tempInput).delete()
} catch (_: Exception) {}
respObj.put("success", false)
respObj.put("error", "postProcess output not found")
return respObj.toString()
}
val newName = outputFile.name
if (!newName.isNullOrBlank() && doc.name != null && doc.name != newName) {
try {
doc.renameTo(newName)
} catch (_: Exception) {}
}
val writeOk = writeUriFromPath(uri, outputFile.absolutePath)
if (!writeOk) {
respObj.put("success", false)
respObj.put("error", "failed to write postProcess output to SAF")
return respObj.toString()
}
try {
if (outputPath != tempInput) {
outputFile.delete()
}
File(tempInput).delete()
} catch (_: Exception) {}
respObj.put("new_file_path", uri.toString())
respObj.put("file_path", uri.toString())
return respObj.toString()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,289 @@
package com.zarz.spotiflac
import java.util.Locale
import kotlin.math.roundToInt
/**
* Pure finalization decisions shared by the Android background pipeline.
*
* Keeping format and naming policy free of Android/FFmpeg dependencies lets
* local JVM tests cover the branches that otherwise live inside the native
* finalizer's I/O-heavy orchestration.
*/
internal object NativeFinalizationPolicy {
fun normalizeAudioCodec(codec: String?): String? {
val normalized = normalizeOptional(codec)
?.lowercase(Locale.ROOT)
?.replace('-', '_')
?: return null
return when (normalized) {
"mp4a" -> "aac"
"ec_3" -> "eac3"
"ac_3" -> "ac3"
"ac_4" -> "ac4"
"mp4" -> "m4a"
"ogg" -> "opus"
else -> normalized
}
}
fun audioFormatForCodec(codec: String?): String? {
return when (normalizeAudioCodec(codec)) {
"flac" -> "FLAC"
"alac" -> "ALAC"
"aac" -> "AAC"
"eac3" -> "EAC3"
"ac3" -> "AC3"
"ac4" -> "AC4"
"mp3" -> "MP3"
"opus" -> "OPUS"
else -> null
}
}
fun isLossyAudioCodec(codec: String?): Boolean {
return when (normalizeAudioCodec(codec)) {
"aac", "eac3", "ac3", "ac4", "mp3", "opus", "m4a" -> true
else -> false
}
}
fun isLosslessAudioCodec(codec: String?): Boolean {
val normalized = normalizeAudioCodec(codec) ?: return false
if (normalized.startsWith("pcm_")) return true
return normalized in setOf(
"alac",
"flac",
"wavpack",
"ape",
"tta",
"mlp",
"truehd",
"shorten",
)
}
fun displayAudioQuality(
filePath: String,
fileName: String,
bitDepth: Int?,
sampleRate: Int?,
bitrateKbps: Int?,
audioCodec: String? = null,
storedQuality: String?,
): String? {
val format = audioFormatForCodec(audioCodec)
?: audioFormatForPath(filePath, fileName)
if (
format == "OPUS" ||
format == "MP3" ||
format == "AAC" ||
format == "EAC3" ||
format == "AC3" ||
format == "AC4" ||
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
) {
return if (bitrateKbps != null && bitrateKbps >= 16) {
"$format ${bitrateKbps}kbps"
} else {
nonPlaceholderQuality(storedQuality) ?: format
}
}
if (bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0) {
return "$bitDepth-bit/${sampleRateLabel(sampleRate)}kHz"
}
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
}
fun qualityVariantFilenameLabel(
measuredQuality: String,
bitDepth: Int?,
sampleRate: Int?,
bitrateKbps: Int?,
audioCodec: String?,
): String? {
if (isLossyAudioCodec(audioCodec)) {
val bitrate = bitrateKbps ?: Regex(
"\\b(\\d+)\\s*kbps\\b",
RegexOption.IGNORE_CASE,
).find(measuredQuality)?.groupValues?.getOrNull(1)?.toIntOrNull()
return bitrate?.takeIf { it >= 16 }?.let { "${it}kbps" }
}
var resolvedBitDepth = bitDepth
var resolvedSampleRate = sampleRate
if (resolvedBitDepth == null || resolvedSampleRate == null) {
val match = Regex(
"\\b(\\d+)\\s*(?:-|\\s)?bit\\s*[/_-]\\s*(\\d+(?:\\.\\d+)?)\\s*k?hz\\b",
RegexOption.IGNORE_CASE,
).find(measuredQuality)
resolvedBitDepth =
resolvedBitDepth ?: match?.groupValues?.getOrNull(1)?.toIntOrNull()
resolvedSampleRate =
resolvedSampleRate
?: match?.groupValues?.getOrNull(2)?.toDoubleOrNull()?.let { rate ->
if (rate < 1000) {
(rate * 1000).roundToInt()
} else {
rate.roundToInt()
}
}
}
if (
resolvedBitDepth == null ||
resolvedBitDepth <= 0 ||
resolvedSampleRate == null ||
resolvedSampleRate <= 0
) {
return null
}
return "${resolvedBitDepth}bit-${sampleRateLabel(resolvedSampleRate)}kHz"
}
fun applyQualityVariantFilenameLabel(
fileName: String,
stagingLabel: String,
qualityLabel: String,
): String {
if (stagingLabel.isNotEmpty() && fileName.contains(stagingLabel)) {
return fileName.replace(stagingLabel, qualityLabel)
}
if (fileName.contains(qualityLabel)) return fileName
val dotIndex = fileName.lastIndexOf('.')
val hasExtension = dotIndex > 0
val stem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
return "$stem - $qualityLabel$extension"
}
fun removeQualityVariantStagingLabel(
fileName: String,
stagingLabel: String,
): String {
if (stagingLabel.isEmpty() || !fileName.contains(stagingLabel)) return fileName
val dotIndex = fileName.lastIndexOf('.')
val hasExtension = dotIndex > 0
val stem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
val cleanedStem = stem
.replace(stagingLabel, "")
.replace(Regex("[\\s_-]+$"), "")
.trim()
.ifBlank { "track" }
return "$cleanedStem$extension"
}
fun resolveQualityVariantFilename(
fileName: String,
stagingLabel: String,
qualityLabel: String,
collisionOnly: Boolean,
cleanNameExists: Boolean,
): String {
if (!collisionOnly || cleanNameExists) {
return applyQualityVariantFilenameLabel(fileName, stagingLabel, qualityLabel)
}
return removeQualityVariantStagingLabel(fileName, stagingLabel)
}
/**
* Returns the user-facing name that a deferred SAF download was assigned
* before its audio was materialized in the app cache. Container and
* decryption passes may replace [currentFileName] with a temporary
* `native_saf_work_*` name, which must never become the published name.
*/
fun logicalOutputFileName(
deferredSafPublish: Boolean,
resultSafFileName: String?,
requestSafFileName: String?,
currentFileName: String,
): String {
if (!deferredSafPublish) return currentFileName
return normalizeOptional(resultSafFileName)
?: normalizeOptional(requestSafFileName)
?: currentFileName
}
fun resolvePreferredDecryptionExtension(
inputPath: String,
requested: String,
): String {
val normalizedRequest = normalizeExtension(requested)
if (normalizedRequest.isNotBlank()) return normalizedRequest
val lower = inputPath.lowercase(Locale.ROOT)
return when {
lower.endsWith(".m4a") -> ".flac"
lower.endsWith(".flac") -> ".flac"
lower.endsWith(".mp3") -> ".mp3"
lower.endsWith(".opus") -> ".opus"
lower.endsWith(".mp4") -> ".mp4"
else -> ".flac"
}
}
fun formatIndexTag(number: Int, total: Int): String {
if (number <= 0) return "0"
return if (total > 0) "$number/$total" else number.toString()
}
private fun audioFormatForPath(filePath: String, fileName: String): String? {
for (candidate in listOf(filePath, fileName)) {
val lower = candidate.trim().lowercase(Locale.ROOT)
when {
lower.endsWith(".opus") || lower.endsWith(".ogg") -> return "OPUS"
lower.endsWith(".mp3") -> return "MP3"
lower.endsWith(".aac") -> return "AAC"
lower.endsWith(".m4a") || lower.endsWith(".mp4") -> return "M4A"
}
}
return null
}
private fun nonPlaceholderQuality(quality: String?): String? {
val normalized = normalizeOptional(quality) ?: return null
val bitrateMatch =
Regex("\\b(\\d+)\\s*kbps\\b", RegexOption.IGNORE_CASE).find(normalized)
if (bitrateMatch != null) {
val bitrate = bitrateMatch.groupValues.getOrNull(1)?.toIntOrNull()
if (bitrate != null && bitrate < 16) return null
}
val key = normalized
.lowercase(Locale.ROOT)
.replace(Regex("[^a-z0-9]+"), "_")
.trim('_')
val placeholders = setOf(
"best",
"lossless",
"hi_res",
"hires",
"hi_res_lossless",
"hires_lossless",
"high",
"cd",
"flac_best_available",
)
return if (placeholders.contains(key)) null else normalized
}
private fun sampleRateLabel(sampleRate: Int): String {
val khz = sampleRate / 1000.0
val precision = if (sampleRate % 1000 == 0) 0 else 1
return "%.${precision}f".format(Locale.US, khz)
}
private fun normalizeOptional(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty() || trimmed.equals("null", ignoreCase = true)) {
return null
}
return trimmed
}
private fun normalizeExtension(extension: String?): String {
val trimmed = extension?.trim().orEmpty()
if (trimmed.isEmpty()) return ""
val lower = trimmed.lowercase(Locale.ROOT)
return if (lower.startsWith(".")) lower else ".$lower"
}
}
@@ -0,0 +1,314 @@
package com.zarz.spotiflac
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import android.net.Uri
import android.util.Base64
import android.util.Log
import com.antonkarpenko.ffmpegkit.FFmpegKit
import com.antonkarpenko.ffmpegkit.FFmpegKitConfig
import com.antonkarpenko.ffmpegkit.FFmpegSession
import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback
import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
import com.antonkarpenko.ffmpegkit.ReturnCode
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel
import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
import gobackend.Gobackend
import org.json.JSONObject
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.util.Locale
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.pow
// FFmpeg execution, probing, and container helpers for NativeDownloadFinalizer.
internal fun NativeDownloadFinalizer.isMP4ContainerFile(path: String): Boolean {
return try {
File(path).inputStream().use { stream ->
val header = ByteArray(12)
val read = stream.read(header)
read >= 8 &&
header[4] == 'f'.code.toByte() &&
header[5] == 't'.code.toByte() &&
header[6] == 'y'.code.toByte() &&
header[7] == 'p'.code.toByte()
}
} catch (_: Exception) {
false
}
}
internal fun NativeDownloadFinalizer.formatForPath(path: String): String {
return when (normalizeExt(File(path).extension)) {
".mp3" -> "mp3"
".opus", ".ogg" -> "opus"
".m4a", ".mp4", ".aac" -> "m4a"
else -> "flac"
}
}
internal fun NativeDownloadFinalizer.scanReplayGain(path: String, shouldCancel: () -> Boolean = { false }): NativeDownloadFinalizer.ReplayGainScan? {
val command = "-hide_banner -nostats -i ${q(path)} -filter_complex ebur128=peak=true:framelog=quiet -f null -"
val result = runFFmpeg(command, shouldCancel)
val output = result.second
val integrated = Regex("I:\\s+(-?\\d+\\.?\\d*)\\s+LUFS")
.findAll(output)
.lastOrNull()
?.groupValues
?.getOrNull(1)
?.toDoubleOrNull() ?: return null
val truePeak = Regex("Peak:\\s+(-?\\d+\\.?\\d*)\\s+dBFS")
.findAll(output)
.mapNotNull { it.groupValues.getOrNull(1)?.toDoubleOrNull() }
.maxOrNull()
val gain = -18.0 - integrated
val peak = if (truePeak != null) 10.0.pow(truePeak / 20.0) else 1.0
return NativeDownloadFinalizer.ReplayGainScan(
trackGain = "${if (gain >= 0) "+" else ""}${"%.2f".format(Locale.US, gain)} dB",
trackPeak = "%.6f".format(Locale.US, peak),
integratedLufs = integrated,
truePeakLinear = peak,
)
}
internal fun NativeDownloadFinalizer.runFFmpeg(command: String, shouldCancel: () -> Boolean = { false }): Pair<Boolean, String> {
checkCancelled(shouldCancel)
installNativeFFmpegCallbackFilter()
val latch = CountDownLatch(1)
var completedSession: FFmpegSession? = null
val session = FFmpegSession.create(
FFmpegKitConfig.parseArguments(command),
{ finishedSession ->
completedSession = finishedSession
latch.countDown()
},
null,
null,
LogRedirectionStrategy.NEVER_PRINT_LOGS,
)
val sessionId = session.sessionId
synchronized(activeFFmpegSessionLock) {
activeFFmpegSessionIds.add(sessionId)
nativeFFmpegSessionIds.add(sessionId)
}
FFmpegKitConfig.asyncFFmpegExecute(session)
try {
var cancelRequested = false
while (!latch.await(200, TimeUnit.MILLISECONDS)) {
if (shouldCancel()) {
cancelRequested = true
try {
FFmpegKit.cancel(sessionId)
} catch (_: Exception) {
}
break
}
}
if (cancelRequested) {
latch.await(5, TimeUnit.SECONDS)
throw CancellationException("Native FFmpeg session cancelled")
}
val finalSession = completedSession ?: session
val output = finalSession.getAllLogsAsString(1000) ?: ""
checkCancelled(shouldCancel)
return ReturnCode.isSuccess(finalSession.returnCode) to output
} finally {
synchronized(activeFFmpegSessionLock) {
activeFFmpegSessionIds.remove(sessionId)
}
}
}
internal fun NativeDownloadFinalizer.installNativeFFmpegCallbackFilter() {
synchronized(ffmpegCompleteCallbackLock) {
val current = FFmpegKitConfig.getFFmpegSessionCompleteCallback()
if (current !== nativeFilteringFFmpegCompleteCallback) {
forwardedFFmpegCompleteCallback = current
FFmpegKitConfig.enableFFmpegSessionCompleteCallback(nativeFilteringFFmpegCompleteCallback)
}
}
}
internal fun NativeDownloadFinalizer.withFFmpegCommandPump(
shouldCancel: () -> Boolean = { false },
block: () -> String,
): String {
val running = AtomicBoolean(true)
val handled = mutableSetOf<String>()
val pump = Thread {
while (running.get()) {
try {
val raw = Gobackend.getAllPendingFFmpegCommandsJSON()
val commands = org.json.JSONArray(raw)
for (index in 0 until commands.length()) {
val command = commands.optJSONObject(index) ?: continue
val id = command.optString("command_id", "")
val commandLine = command.optString("command", "")
if (id.isBlank() || commandLine.isBlank() || handled.contains(id)) {
continue
}
handled.add(id)
// Every claimed command must get a result delivered to
// the Go side, even on failure or cancellation: the
// backend blocks until one arrives and never retries a
// claimed id, so bailing out here would strand the
// gomobile call the main thread is sitting in forever.
val result = try {
if (shouldCancel()) {
Pair(false, "cancelled")
} else {
runFFmpeg(commandLine, shouldCancel)
}
} catch (e: Exception) {
Pair(false, e.message ?: "FFmpeg execution failed")
}
try {
Gobackend.setFFmpegCommandResultByID(
id,
result.first,
result.second,
if (result.first) "" else result.second,
)
} catch (e: Exception) {
Log.w(TAG, "Failed to deliver FFmpeg result for $id: ${e.message}")
}
}
} catch (_: Exception) {
}
try {
Thread.sleep(100)
} catch (_: InterruptedException) {
// Keep pumping until `running` flips: on cancel the Go call
// may still be waiting for a result for an in-flight
// command, and it is delivered as failed above.
}
}
}
pump.isDaemon = true
pump.start()
return try {
block()
} finally {
running.set(false)
pump.interrupt()
}
}
/**
* Staged sibling name for conversion outputs: "song.flac" -> "song.partial.flac".
* The ".partial<ext>" shape is ignored by library scans and duplicate checks,
* while the real trailing extension still lets FFmpeg infer the muxer. A
* process kill mid-conversion therefore never leaves a partial file under a
* real audio name in the user's music folder.
*/
internal fun NativeDownloadFinalizer.stagedConversionPath(finalPath: String): String {
val file = File(finalPath)
val ext = file.extension
val name = if (ext.isBlank()) "${file.name}.partial" else "${file.nameWithoutExtension}.partial.$ext"
return File(file.parentFile, name).absolutePath
}
internal fun NativeDownloadFinalizer.promoteStagedConversion(stagedPath: String, finalPath: String): Boolean {
val staged = File(stagedPath)
fsyncQuietly(staged)
val final = File(finalPath)
if (staged.renameTo(final)) return true
return final.delete() && staged.renameTo(final)
}
internal fun NativeDownloadFinalizer.buildOutputPath(inputPath: String, extension: String): String {
val ext = normalizeExt(extension).ifBlank { ".tmp" }
val file = File(inputPath)
val base = file.nameWithoutExtension.ifBlank { "track" }
val candidate = File(file.parentFile, "$base$ext").absolutePath
if (candidate != inputPath) return candidate
return File(file.parentFile, "${base}_converted$ext").absolutePath
}
internal fun NativeDownloadFinalizer.desiredFileName(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState, extension: String): String {
val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } }
val rawName = input.result.optString("quality_variant_file_name", "")
.ifBlank { input.request.optString("saf_file_name", "") }
.ifBlank { state.fileName }
.ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" }
val knownExts = listOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", ".lrc")
var base = rawName.trim()
val lower = base.lowercase(Locale.ROOT)
for (knownExt in knownExts) {
if (lower.endsWith(knownExt)) {
base = base.dropLast(knownExt.length)
break
}
}
base = base
.replace("/", " ")
.replace(Regex("[\\\\:*?\"<>|]"), " ")
.trim()
.trim('.', ' ')
.ifBlank { "track" }
return "$base$ext"
}
internal fun NativeDownloadFinalizer.shouldForceContainerConversion(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState): Boolean {
if (input.result.optBoolean("requires_container_conversion", false)) return true
if (input.request.optBoolean("requires_container_conversion", false)) return true
return false
}
internal fun NativeDownloadFinalizer.probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String {
val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel)
val output = result.second
val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output)
return match?.groupValues?.getOrNull(1)
?.trim()
?.lowercase(Locale.ROOT)
?.replace('-', '_')
.orEmpty()
}
/**
* Returns true when the file on [path] starts with the native FLAC magic
* bytes (`fLaC`). A file may contain a FLAC audio stream yet live inside
* an MP4/fMP4 container (e.g. some Amazon Music downloads); native FLAC
* tag writers require the raw fLaC header, so we must detect that mismatch
* before skipping the container conversion step.
*/
internal fun NativeDownloadFinalizer.isNativeFlacFile(path: String): Boolean {
return try {
RandomAccessFile(path, "r").use { raf ->
if (raf.length() < 4L) return false
val header = ByteArray(4)
raf.readFully(header)
header[0] == 0x66.toByte() && // 'f'
header[1] == 0x4C.toByte() && // 'L'
header[2] == 0x61.toByte() && // 'a'
header[3] == 0x43.toByte() // 'C'
}
} catch (e: Exception) {
Log.w(TAG, "Native FLAC magic probe failed for $path: ${e.message}")
false
}
}
internal fun NativeDownloadFinalizer.requestedDecryptionOutputExt(input: NativeDownloadFinalizer.FinalizeInput): String {
val descriptor = input.result.optJSONObject("decryption")
return normalizeExt(
descriptor?.optString("output_extension", "")
?.ifBlank { input.result.optString("output_extension", "") }
)
}
@@ -0,0 +1,489 @@
package com.zarz.spotiflac
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import android.net.Uri
import android.util.Base64
import android.util.Log
import com.antonkarpenko.ffmpegkit.FFmpegKit
import com.antonkarpenko.ffmpegkit.FFmpegKitConfig
import com.antonkarpenko.ffmpegkit.FFmpegSession
import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback
import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
import com.antonkarpenko.ffmpegkit.ReturnCode
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel
import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.logicalOutputFileName
import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel
import com.zarz.spotiflac.NativeFinalizationPolicy.resolveQualityVariantFilename
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
import gobackend.Gobackend
import org.json.JSONObject
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.util.Locale
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.pow
// Metadata embedding, cover download, external LRC, and quality-variant
// filename helpers for NativeDownloadFinalizer.
internal fun NativeDownloadFinalizer.qualityVariantFilenameLabel(state: NativeDownloadFinalizer.FinalizeState): String? {
return NativeFinalizationPolicy.qualityVariantFilenameLabel(
measuredQuality = state.quality,
bitDepth = state.bitDepth,
sampleRate = state.sampleRate,
bitrateKbps = state.bitrateKbps,
audioCodec = state.audioCodec,
)
}
internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
context: Context,
input: NativeDownloadFinalizer.FinalizeInput,
state: NativeDownloadFinalizer.FinalizeState,
) {
if (!input.request.optBoolean("allow_quality_variant", false)) return
val stagingLabel = input.request.optString("quality_variant", "").trim()
val qualityLabel = qualityVariantFilenameLabel(state)
if (qualityLabel == null) {
Log.w(TAG, "Keeping temporary quality label because final audio specifications are unavailable")
return
}
val logicalFileName = logicalOutputFileName(
deferredSafPublish = isDeferredSafPublish(input),
resultSafFileName = input.result.optString("saf_final_file_name", ""),
requestSafFileName = input.request.optString("saf_file_name", ""),
currentFileName = state.fileName,
)
val variantName = applyQualityVariantFilenameLabel(
fileName = logicalFileName,
stagingLabel = stagingLabel,
qualityLabel = qualityLabel,
)
val cleanName = removeQualityVariantStagingLabel(logicalFileName, stagingLabel)
val collisionOnly = input.request.optBoolean("quality_variant_collision_only", false)
val preferredName = variantName
if (preferredName == logicalFileName && preferredName == state.fileName) return
input.result.put("quality_variant_file_name", preferredName)
if (isDeferredSafPublish(input)) {
state.fileName = preferredName
return
}
if (state.filePath.startsWith("content://")) {
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return
try {
val treeUri = input.request.optString("saf_tree_uri", "")
val relativeDir = input.request.optString("saf_relative_dir", "")
val writeResult = if (collisionOnly) {
SafDownloadHandler.writeFileToSafCollisionAware(
context = context,
treeUriStr = treeUri,
relativeDir = relativeDir,
cleanFileName = cleanName,
variantFileName = variantName,
mimeType = mimeTypeForExt(File(variantName).extension),
srcPath = tempPath,
preservedSuffix = qualityLabel,
)
} else {
SafDownloadHandler.writeFileToSafUnique(
context = context,
treeUriStr = treeUri,
relativeDir = relativeDir,
fileName = preferredName,
mimeType = mimeTypeForExt(File(preferredName).extension),
srcPath = tempPath,
preservedSuffix = qualityLabel,
)
} ?: return
SafDownloadHandler.deleteContentUri(context, state.filePath)
state.filePath = writeResult.uri
state.fileName = writeResult.fileName
} finally {
File(tempPath).delete()
}
} else {
val source = File(state.filePath)
val cleanTarget = File(source.parentFile, cleanName)
val lockTarget = if (collisionOnly) cleanTarget else File(source.parentFile, preferredName)
val lockKey = lockTarget.absolutePath.lowercase(Locale.ROOT)
val lock = qualityVariantNameLocks.computeIfAbsent(lockKey) { Any() }
synchronized(lock) {
val selectedName = if (collisionOnly) {
resolveQualityVariantFilename(
fileName = logicalFileName,
stagingLabel = stagingLabel,
qualityLabel = qualityLabel,
collisionOnly = true,
cleanNameExists = cleanTarget.absolutePath != source.absolutePath && cleanTarget.exists(),
)
} else {
preferredName
}
input.result.put("quality_variant_file_name", selectedName)
if (selectedName == state.fileName) return@synchronized
val target = uniqueLocalFile(source.parentFile, selectedName)
if (!source.renameTo(target)) {
Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}")
return@synchronized
}
state.filePath = target.absolutePath
state.fileName = target.name
}
}
input.result.put("file_path", state.filePath)
input.result.put("file_name", state.fileName)
input.result.optJSONObject("replaygain")?.let { replayGain ->
replayGain.put("file_path", state.filePath)
replayGain.put("file_name", state.fileName)
}
}
internal fun NativeDownloadFinalizer.uniqueLocalFile(parent: File?, preferredName: String): File {
val directory = parent ?: return File(preferredName)
var candidate = File(directory, preferredName)
if (!candidate.exists()) return candidate
val dotIndex = preferredName.lastIndexOf('.')
val hasExtension = dotIndex > 0
val stem = if (hasExtension) preferredName.substring(0, dotIndex) else preferredName
val extension = if (hasExtension) preferredName.substring(dotIndex) else ""
var counter = 2
while (candidate.exists()) {
candidate = File(directory, "$stem ($counter)$extension")
counter++
}
return candidate
}
internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState) {
if (!input.request.optBoolean("embed_metadata", false) || !input.request.optBoolean("embed_lyrics", false)) return
val lyricsMode = input.request.optString("lyrics_mode", "")
if (lyricsMode != "external" && lyricsMode != "both") return
val lrc = resolveLyricsLrc(input)
if (lrc.isBlank() || lrc == "[instrumental:true]") return
val audioFileName = if (isDeferredSafRequest(input)) {
desiredFileName(input, state, File(state.filePath).extension)
} else {
state.fileName
}
val baseName = audioFileName.replace(Regex("\\.[^.]+$"), "")
if (isDeferredSafRequest(input)) {
state.pendingExternalLrc = lrc
state.pendingExternalLrcFileName = "$baseName.lrc"
return
}
if (state.filePath.startsWith("content://")) {
val treeUri = input.request.optString("saf_tree_uri", "")
val relativeDir = input.request.optString("saf_relative_dir", "")
val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc")
temp.writeText(lrc)
try {
SafDownloadHandler.writeFileToSaf(
context = context,
treeUriStr = treeUri,
relativeDir = relativeDir,
fileName = "$baseName.lrc",
mimeType = "application/octet-stream",
srcPath = temp.absolutePath,
)
} finally {
temp.delete()
}
} else {
val target = File(File(state.filePath).parentFile, "$baseName.lrc")
target.writeText(lrc)
}
}
internal fun NativeDownloadFinalizer.resolveLyricsLrc(input: NativeDownloadFinalizer.FinalizeInput): String {
val existing = input.result.optString("lyrics_lrc", "").trim()
if (existing.isNotEmpty()) return existing
val spotifyId = trackString(input, "id", input.request.optString("spotify_id", ""))
val trackName = trackString(input, "name", input.request.optString("track_name", ""))
val artistName = trackString(input, "artistName", input.request.optString("artist_name", ""))
if (trackName.isBlank() || artistName.isBlank()) return ""
return try {
val fetched = Gobackend.getLyricsLRC(
spotifyId,
trackName,
artistName,
"",
lyricsDurationMs(input),
).trim()
if (fetched.isNotEmpty()) {
input.result.put("lyrics_lrc", fetched)
}
fetched
} catch (_: Exception) {
""
}
}
internal fun NativeDownloadFinalizer.lyricsDurationMs(input: NativeDownloadFinalizer.FinalizeInput): Long {
val requestDuration = input.request.optLong("duration_ms", 0L)
val trackDuration = trackInt(input, "duration", 0).toLong()
val duration = if (requestDuration > 0L) requestDuration else trackDuration
if (duration <= 0L) return 0L
return if (duration > 10000L) duration else duration * 1000L
}
internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path: String, input: NativeDownloadFinalizer.FinalizeInput, format: String) {
if (!input.request.optBoolean("embed_metadata", false)) return
val title = resultString(input, "title").ifBlank {
trackString(input, "name", requestString(input, "track_name"))
}
val artist = resultString(input, "artist").ifBlank {
trackString(input, "artistName", requestString(input, "artist_name"))
}
val album = resultString(input, "album").ifBlank {
trackString(input, "albumName", requestString(input, "album_name"))
}
val albumArtist = resultString(input, "album_artist").ifBlank {
trackString(input, "albumArtist", requestString(input, "album_artist"))
}
val date = resultString(input, "release_date").ifBlank {
resultString(input, "date").ifBlank {
trackString(input, "releaseDate", requestString(input, "release_date"))
}
}
val trackNumberValue = positiveOrNull(input.result.optInt("track_number", 0), trackInt(input, "trackNumber", input.request.optInt("track_number", 0))) ?: 0
val totalTracksValue = positiveOrNull(input.result.optInt("total_tracks", 0), trackInt(input, "totalTracks", input.request.optInt("total_tracks", 0))) ?: 0
val discNumberValue = positiveOrNull(input.result.optInt("disc_number", 0), trackInt(input, "discNumber", input.request.optInt("disc_number", 0))) ?: 0
val totalDiscsValue = positiveOrNull(input.result.optInt("total_discs", 0), trackInt(input, "totalDiscs", input.request.optInt("total_discs", 0))) ?: 0
val trackNumber = formatIndexTag(trackNumberValue, totalTracksValue)
val discNumber = formatIndexTag(discNumberValue, totalDiscsValue)
val isrc = resultString(input, "isrc").ifBlank {
trackString(input, "isrc", requestString(input, "isrc"))
}
val composer = resultString(input, "composer").ifBlank {
trackString(input, "composer", requestString(input, "composer"))
}
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
val lyricsMode = input.request.optString("lyrics_mode", "embed")
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
(lyricsMode == "embed" || lyricsMode == "both")
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
val shouldEmbedLyrics = shouldResolveLyrics &&
lyrics.isNotBlank() &&
lyrics != "[instrumental:true]"
// FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the
// tag block atomically without an ffmpeg remux (which drops foreign
// frames and rewrites the whole container). The Go side answers
// method=ffmpeg when it cannot handle the file natively.
if (format == "flac" || format == "mp3" || format == "opus" || format == "m4a") {
val nativeCover = downloadCoverForMetadata(context, input)
val handledNatively = try {
val fields = JSONObject()
.put("title", title)
.put("artist", artist)
.put("album", album)
.put("album_artist", albumArtist)
.put("date", date)
.put("isrc", isrc)
.put("composer", composer)
.put("genre", genre)
.put("label", label)
.put("copyright", copyright)
if (trackNumberValue > 0) fields.put("track_number", trackNumberValue.toString())
if (totalTracksValue > 0) fields.put("track_total", totalTracksValue.toString())
if (discNumberValue > 0) fields.put("disc_number", discNumberValue.toString())
if (totalDiscsValue > 0) fields.put("disc_total", totalDiscsValue.toString())
if (nativeCover != null) fields.put("cover_path", nativeCover.absolutePath)
if (shouldEmbedLyrics) {
fields.put("lyrics", lyrics)
fields.put("unsyncedlyrics", lyrics)
}
val response = Gobackend.editFileMetadata(path, fields.toString())
val method = try {
JSONObject(response).optString("method", "")
} catch (_: Exception) {
""
}
method != "ffmpeg"
} catch (e: Exception) {
if (format == "flac") throw e
Log.w(TAG, "Native tag embed failed for $format: ${e.message}; falling back to ffmpeg")
false
} finally {
nativeCover?.delete()
}
if (handledNatively) return
}
val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" }
val inputFile = File(path)
// ".partial<ext>" keeps the temp invisible to library scans while FFmpeg
// still infers the muxer from the real trailing extension.
val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged.partial$ext")
val isM4a = format == "m4a"
val isOpus = format == "opus"
val coverFile = if (isM4a || isOpus) downloadCoverForMetadata(context, input) else null
val labelKey = if (isM4a) "organization" else "label"
val metadataPairs = mutableListOf(
"title" to title,
"artist" to artist,
"album" to album,
"album_artist" to albumArtist,
"date" to date,
"track" to trackNumber,
"disc" to discNumber,
"isrc" to isrc,
"composer" to composer,
"genre" to genre,
labelKey to label,
"copyright" to copyright,
"lyrics" to if (shouldEmbedLyrics) lyrics else "",
"unsyncedlyrics" to if (shouldEmbedLyrics) lyrics else "",
)
if (isOpus && coverFile != null) {
createMetadataBlockPicture(coverFile)?.let {
metadataPairs.add("METADATA_BLOCK_PICTURE" to it)
}
}
val metadataArgs = metadataPairs
.filter { it.second.isNotBlank() && it.second != "0" }
.joinToString(" ") { "-metadata ${it.first}=${q(it.second)}" }
if (metadataArgs.isBlank() && coverFile == null) return
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
var adoptedTemp = false
var originalDeleted = false
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 ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
} else {
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))
}
if (result.first && temp.exists()) {
fsyncQuietly(temp)
// Rename directly over the original: a process kill between a
// delete-first and the rename would lose the file entirely.
adoptedTemp = temp.renameTo(inputFile)
if (!adoptedTemp && inputFile.delete()) {
originalDeleted = true
adoptedTemp = temp.renameTo(inputFile)
}
}
} finally {
if (!adoptedTemp && !originalDeleted) {
temp.delete()
}
coverFile?.delete()
}
}
/**
* Best-effort fsync so a file's bytes are durable before it is renamed
* over another file; fsync on a fresh handle flushes the page cache pages
* written earlier by ffmpeg in this process.
*/
internal fun NativeDownloadFinalizer.fsyncQuietly(file: File) {
try {
RandomAccessFile(file, "rw").use { it.fd.sync() }
} catch (_: Exception) {
}
}
internal fun NativeDownloadFinalizer.createMetadataBlockPicture(coverFile: File): String? {
return try {
if (!coverFile.exists() || coverFile.length() <= 0L) return null
val imageData = coverFile.readBytes()
if (imageData.isEmpty()) return null
val mimeType = detectCoverMimeType(coverFile, imageData)
val mimeBytes = mimeType.toByteArray(Charsets.UTF_8)
val descriptionBytes = ByteArray(0)
val blockSize = 4 + 4 + mimeBytes.size + 4 + descriptionBytes.size + 4 + 4 + 4 + 4 + 4 + imageData.size
val buffer = ByteBuffer.allocate(blockSize)
buffer.putInt(3)
buffer.putInt(mimeBytes.size)
buffer.put(mimeBytes)
buffer.putInt(descriptionBytes.size)
buffer.put(descriptionBytes)
buffer.putInt(0)
buffer.putInt(0)
buffer.putInt(0)
buffer.putInt(0)
buffer.putInt(imageData.size)
buffer.put(imageData)
Base64.encodeToString(buffer.array(), Base64.NO_WRAP)
} catch (e: Exception) {
Log.w(TAG, "Failed to create Opus cover picture block: ${e.message}")
null
}
}
internal fun NativeDownloadFinalizer.detectCoverMimeType(coverFile: File, imageData: ByteArray): String {
val ext = coverFile.extension.lowercase(Locale.ROOT)
if (ext == "png") return "image/png"
if (ext == "jpg" || ext == "jpeg") return "image/jpeg"
if (imageData.size >= 8 &&
imageData[0] == 0x89.toByte() &&
imageData[1] == 0x50.toByte() &&
imageData[2] == 0x4E.toByte() &&
imageData[3] == 0x47.toByte()
) {
return "image/png"
}
return "image/jpeg"
}
internal fun NativeDownloadFinalizer.downloadCoverForMetadata(context: Context, input: NativeDownloadFinalizer.FinalizeInput): File? {
val coverUrl = metadataCoverUrl(input).ifBlank { resultString(input, "cover_url") }
if (coverUrl.isBlank()) return null
val safeItemId = input.itemId.ifBlank { "item" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
val output = File.createTempFile("native_cover_${safeItemId}_", ".jpg", context.cacheDir)
return try {
Gobackend.downloadCoverToFile(
coverUrl,
output.absolutePath,
input.request.optBoolean("embed_max_quality_cover", true)
)
if (output.exists() && output.length() > 0L) {
output
} else {
output.delete()
null
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download metadata cover: ${e.message}")
output.delete()
null
}
}
@@ -0,0 +1,192 @@
package com.zarz.spotiflac
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import android.net.Uri
import android.util.Base64
import android.util.Log
import com.antonkarpenko.ffmpegkit.FFmpegKit
import com.antonkarpenko.ffmpegkit.FFmpegKitConfig
import com.antonkarpenko.ffmpegkit.FFmpegSession
import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback
import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
import com.antonkarpenko.ffmpegkit.ReturnCode
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel
import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
import gobackend.Gobackend
import org.json.JSONObject
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.util.Locale
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.pow
// Deferred SAF publish helpers for NativeDownloadFinalizer.
internal fun NativeDownloadFinalizer.promoteStagedSafOutputIfNeeded(
context: Context,
input: NativeDownloadFinalizer.FinalizeInput,
state: NativeDownloadFinalizer.FinalizeState,
) {
if (!state.filePath.startsWith("content://")) return
if (!input.result.optBoolean("saf_staged_output", false)) return
val stagedName = input.result.optString("saf_staged_file_name", "").trim()
if (stagedName.isNotEmpty() && state.fileName != stagedName) return
val localInput = materializeForFFmpeg(context, input, state)
try {
replaceStatePath(context, input, state, localInput, deleteOld = true)
} finally {
File(localInput).delete()
}
}
internal fun NativeDownloadFinalizer.isDeferredSafPublish(input: NativeDownloadFinalizer.FinalizeInput): Boolean {
return input.request.optBoolean("defer_saf_publish", false) &&
input.result.optBoolean("saf_deferred_publish", false)
}
internal fun NativeDownloadFinalizer.isDeferredSafRequest(input: NativeDownloadFinalizer.FinalizeInput): Boolean {
return input.request.optString("storage_mode", "") == "saf" &&
input.request.optBoolean("defer_saf_publish", false)
}
internal fun NativeDownloadFinalizer.publishDeferredSafOutput(
context: Context,
input: NativeDownloadFinalizer.FinalizeInput,
state: NativeDownloadFinalizer.FinalizeState,
) {
if (!isDeferredSafPublish(input)) return
if (state.filePath.startsWith("content://")) return
val outputFile = File(state.filePath)
if (!outputFile.exists() || outputFile.length() <= 0L) {
throw IllegalStateException("deferred SAF output missing or empty")
}
val finalName = desiredFileName(input, state, outputFile.extension)
val treeUri = input.result.optString("saf_tree_uri", "")
.ifBlank { input.request.optString("saf_tree_uri", "") }
val relativeDir = input.result.optString("saf_relative_dir", "")
.ifBlank { input.request.optString("saf_relative_dir", "") }
val mimeType = mimeTypeForExt(outputFile.extension)
val preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false)
val qualityLabel = qualityVariantFilenameLabel(state).orEmpty()
val collisionOnly = preserveQualityVariant &&
input.request.optBoolean("quality_variant_collision_only", false)
val stagingLabel = input.request.optString("quality_variant", "").trim()
val logicalVariantName = input.request.optString("saf_file_name", "")
.ifBlank { finalName }
val cleanName = removeQualityVariantStagingLabel(logicalVariantName, stagingLabel)
val variantName = if (qualityLabel.isNotEmpty()) {
applyQualityVariantFilenameLabel(logicalVariantName, stagingLabel, qualityLabel)
} else {
finalName
}
var alreadyExists = false
val published = when {
collisionOnly -> SafDownloadHandler.writeFileToSafCollisionAware(
context = context,
treeUriStr = treeUri,
relativeDir = relativeDir,
cleanFileName = cleanName,
variantFileName = variantName,
mimeType = mimeType,
srcPath = outputFile.absolutePath,
preservedSuffix = qualityLabel,
)
preserveQualityVariant -> SafDownloadHandler.writeFileToSafUnique(
context = context,
treeUriStr = treeUri,
relativeDir = relativeDir,
fileName = finalName,
mimeType = mimeType,
srcPath = outputFile.absolutePath,
preservedSuffix = qualityLabel,
)
else -> SafDownloadHandler.writeFileToSafIfAbsent(
context = context,
treeUriStr = treeUri,
relativeDir = relativeDir,
fileName = finalName,
mimeType = mimeType,
srcPath = outputFile.absolutePath,
)?.let { result ->
alreadyExists = result.alreadyExists
SafDownloadHandler.UniqueWriteResult(result.uri, result.fileName)
}
} ?: throw IllegalStateException("failed to publish deferred SAF output")
val newUri = published.uri
val publishedName = published.fileName
Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}")
outputFile.delete()
state.filePath = newUri
state.fileName = publishedName
input.result.put("file_path", newUri)
input.result.put("file_name", publishedName)
if (alreadyExists) {
input.result.put("already_exists", true)
input.result.put("message", "File already exists")
input.result.put("publish_collision_existing", true)
}
input.result.optJSONObject("replaygain")?.let { replayGain ->
replayGain.put("file_path", newUri)
replayGain.put("file_name", publishedName)
}
if (state.pendingExternalLrc != null) {
state.pendingExternalLrcFileName = "${publishedName.replace(Regex("\\.[^.]+$"), "")}.lrc"
}
input.result.put("saf_deferred_published", true)
publishPendingDeferredExternalLrc(context, input, state)
}
internal fun NativeDownloadFinalizer.publishPendingDeferredExternalLrc(
context: Context,
input: NativeDownloadFinalizer.FinalizeInput,
state: NativeDownloadFinalizer.FinalizeState,
) {
val lrc = state.pendingExternalLrc ?: return
val fileName = state.pendingExternalLrcFileName ?: return
val treeUri = input.result.optString("saf_tree_uri", "")
.ifBlank { input.request.optString("saf_tree_uri", "") }
val relativeDir = input.result.optString("saf_relative_dir", "")
.ifBlank { input.request.optString("saf_relative_dir", "") }
val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc")
try {
temp.writeText(lrc)
val newUri = SafDownloadHandler.writeFileToSaf(
context = context,
treeUriStr = treeUri,
relativeDir = relativeDir,
fileName = fileName,
mimeType = "application/octet-stream",
srcPath = temp.absolutePath,
)
if (newUri == null) {
Log.w(TAG, "Failed to publish deferred external LRC: $fileName")
}
} catch (e: Exception) {
Log.w(TAG, "Failed to publish deferred external LRC: ${e.message}")
} finally {
temp.delete()
state.pendingExternalLrc = null
state.pendingExternalLrcFileName = null
}
}
@@ -0,0 +1,53 @@
package com.zarz.spotiflac
/**
* Pure album ReplayGain completion policy used by DownloadService.
*/
internal object NativeReplayGainPolicy {
private val blockingStatuses = setOf(
"failed",
"skipped",
"queued",
"downloading",
"finalizing",
)
private val pendingStatuses = setOf("queued", "downloading", "finalizing")
fun eligibleEntryIndexes(
entryAlbumKeys: List<String>,
statuses: Map<String, String>,
requestAlbumKeys: Map<String, String>,
): List<Int> {
val blockedKeys = mutableSetOf<String>()
val expectedCompletedByKey = mutableMapOf<String, Int>()
for ((itemId, albumKey) in requestAlbumKeys) {
when (statuses[itemId]) {
"completed" -> {
expectedCompletedByKey[albumKey] =
(expectedCompletedByKey[albumKey] ?: 0) + 1
}
in blockingStatuses -> blockedKeys.add(albumKey)
}
}
val indexesByKey = entryAlbumKeys.indices.groupBy { entryAlbumKeys[it] }
val eligible = mutableListOf<Int>()
for ((albumKey, indexes) in indexesByKey) {
if (
albumKey.isBlank() ||
albumKey in blockedKeys ||
indexes.size <= 1
) {
continue
}
val expected = expectedCompletedByKey[albumKey] ?: continue
if (indexes.size == expected) {
eligible.addAll(indexes)
}
}
return eligible
}
fun hasPendingWork(statuses: Map<String, String>): Boolean =
statuses.values.any { it in pendingStatuses }
}
@@ -0,0 +1,71 @@
package com.zarz.spotiflac
internal object NativeWorkerPolicy {
const val MAX_RATE_LIMIT_RETRIES = 1
private const val DEFAULT_RATE_LIMIT_DELAY_SECONDS = 30
private const val MIN_RATE_LIMIT_DELAY_SECONDS = 5
private const val MAX_RATE_LIMIT_DELAY_SECONDS = 300
private val retryAfterPattern = Regex(
"""retry[- ]?after(?: seconds)?[:= ]+(\d+)""",
RegexOption.IGNORE_CASE,
)
fun shouldRetryRateLimit(
errorType: String?,
errorMessage: String?,
attempts: Int,
): Boolean {
if (attempts >= MAX_RATE_LIMIT_RETRIES) return false
if (errorType.equals("rate_limit", ignoreCase = true)) return true
val message = errorMessage.orEmpty()
return message.contains("429") ||
message.contains("rate limit", ignoreCase = true) ||
message.contains("too many requests", ignoreCase = true)
}
fun rateLimitDelaySeconds(
retryAfterSeconds: Int?,
errorMessage: String?,
): Int {
val parsedFromMessage = retryAfterPattern
.find(errorMessage.orEmpty())
?.groupValues
?.getOrNull(1)
?.toIntOrNull()
return (retryAfterSeconds?.takeIf { it > 0 }
?: parsedFromMessage
?: DEFAULT_RATE_LIMIT_DELAY_SECONDS)
.coerceIn(
MIN_RATE_LIMIT_DELAY_SECONDS,
MAX_RATE_LIMIT_DELAY_SECONDS,
)
}
fun isVerificationRequired(
errorType: String?,
errorMessage: String?,
): Boolean {
if (errorType.equals("verification_required", ignoreCase = true)) {
return true
}
val message = errorMessage.orEmpty()
return message.contains("verification required", ignoreCase = true) ||
message.contains("challenge required", ignoreCase = true)
}
fun requiresWifi(downloadNetworkMode: String?): Boolean =
downloadNetworkMode.equals("wifi_only", ignoreCase = true)
fun shouldPauseForNetwork(
downloadNetworkMode: String?,
hasWifi: Boolean,
): Boolean = requiresWifi(downloadNetworkMode) && !hasWifi
fun shouldNotifyQueueComplete(
cancelRequested: Boolean,
completed: Int,
failed: Int,
): Boolean = !cancelRequested && completed + failed > 0
}
@@ -6,6 +6,8 @@ import android.util.Log
import androidx.documentfile.provider.DocumentFile
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.util.Locale
/**
@@ -17,6 +19,44 @@ object SafDownloadHandler {
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
// Serializes the exists-check/create/write/publish sequence per target
// file so concurrent downloads that sanitize to the same display name
// cannot interleave writes into one document. Different names keep
// downloading in parallel; the second same-name caller blocks, then hits
// the exists check and reports already_exists.
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
data class UniqueWriteResult(val uri: String, val fileName: String)
data class ExistingAwareWriteResult(
val uri: String,
val fileName: String,
val alreadyExists: Boolean,
)
private fun <T> withSafNameLock(
treeUriStr: String,
relativeDir: String,
fileName: String,
block: () -> T
): T {
val key = "$treeUriStr|$relativeDir|${fileName.lowercase(Locale.ROOT)}"
val lock = safNameLocks.computeIfAbsent(key) { Any() }
return synchronized(lock) { block() }
}
/**
* Flushes and fsyncs a SAF output stream so the copied bytes are durable
* before the staged document is renamed to its final name; without this a
* power loss right after the rename can leave a truncated "complete" file.
*/
fun syncOutputStream(output: OutputStream) {
try {
output.flush()
(output as? FileOutputStream)?.fd?.sync()
} catch (_: Exception) {
}
}
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
val req = JSONObject(requestJson)
val storageMode = req.optString("storage_mode", "")
@@ -25,13 +65,34 @@ object SafDownloadHandler {
return downloader(requestJson)
}
val treeUri = Uri.parse(treeUriStr)
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt)
return withSafNameLock(treeUriStr, relativeDir, fileName) {
handleSafLocked(context, req, downloader, treeUriStr, relativeDir, outputExt, fileName)
}
}
private fun handleSafLocked(
context: Context,
req: JSONObject,
downloader: (String) -> String,
treeUriStr: String,
relativeDir: String,
outputExt: String,
fileName: String
): String {
val treeUri = Uri.parse(treeUriStr)
val mimeType = mimeTypeForExt(outputExt)
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
// Downloads are always written under a staged ".partial" name so a
// killed process can never leave a half-written file under the final
// name (which the exists check would then accept as complete forever).
// Callers that set stage_saf_output own the promotion to the final
// name (the native finalizer); for everyone else — the foreground
// Dart queue — this handler promotes right after a successful write.
val finalizerPromotesStaged = req.optBoolean("stage_saf_output", false) && !deferSafPublish
val useStagedOutput = !deferSafPublish
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
@@ -39,9 +100,7 @@ object SafDownloadHandler {
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
if (useStagedOutput || deferSafPublish) {
deleteStaleStagedFiles(existingDir, fileName, outputExt)
}
deleteStaleStagedFiles(existingDir, fileName, outputExt)
val obj = JSONObject()
obj.put("success", true)
obj.put("message", "File already exists")
@@ -90,6 +149,11 @@ object SafDownloadHandler {
}
}
// Remove any stale partial from a previous killed attempt before
// creating the staged document: reusing it would let a shorter new
// write leave the old tail bytes in place (fd truncation is
// best-effort on some providers).
deleteStaleStagedFiles(targetDir, fileName, outputExt)
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
?: return errorJson("Failed to create SAF file")
@@ -105,6 +169,7 @@ object SafDownloadHandler {
val response = downloader(req.toString())
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
var finalFileName = fileName
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
@@ -138,11 +203,13 @@ object SafDownloadHandler {
document.delete()
document = replacement
}
finalFileName = actualFileName
}
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
syncOutputStream(output)
} ?: throw IllegalStateException("failed to open SAF output stream")
srcFile.delete()
} catch (e: Exception) {
@@ -156,9 +223,19 @@ object SafDownloadHandler {
}
respObj.put("file_path", document.uri.toString())
respObj.put("file_name", document.name ?: fileName)
if (useStagedOutput) {
if (finalizerPromotesStaged) {
respObj.put("saf_staged_output", true)
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
} else if (useStagedOutput) {
// Legacy caller (foreground Dart queue): publish here by
// renaming the staged file to its final name.
val published = replaceFinalDocument(targetDir, document, finalFileName)
if (published == null) {
document.delete()
return errorJson("Failed to publish SAF download")
}
respObj.put("file_path", published.uri.toString())
respObj.put("file_name", published.name ?: finalFileName)
}
} else {
document.delete()
@@ -177,6 +254,43 @@ object SafDownloadHandler {
}
}
/**
* Swaps [document] into place under [finalName] without a window where the
* previous file is gone but the new one is not yet in place. Any existing
* file is renamed aside first and only deleted once the staged file holds
* the final name; a failed swap restores it. Returns the published
* document, or null when the swap failed (the caller owns [document]).
*/
private fun replaceFinalDocument(
targetDir: DocumentFile,
document: DocumentFile,
finalName: String
): DocumentFile? {
val existingFinal = targetDir.findFile(finalName)
var aside: DocumentFile? = null
if (existingFinal != null && existingFinal.uri != document.uri) {
val asideName = buildReplacedSafFileName(finalName)
try {
targetDir.findFile(asideName)?.delete()
} catch (_: Exception) {
}
if (!existingFinal.renameTo(asideName)) {
return null
}
aside = targetDir.findFile(asideName) ?: existingFinal
}
if (!document.renameTo(finalName)) {
aside?.renameTo(finalName)
return null
}
aside?.delete()
return targetDir.findFile(finalName) ?: document
}
private fun buildReplacedSafFileName(fileName: String): String {
return "${sanitizeFilename(fileName)}.replaced"
}
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
return try {
val uri = Uri.parse(uriStr)
@@ -206,12 +320,197 @@ object SafDownloadHandler {
fileName: String,
mimeType: String,
srcPath: String
): String? {
val finalName = sanitizeFilename(fileName)
return withSafNameLock(treeUriStr, sanitizeRelativeDir(relativeDir), finalName) {
writeFileToSafLocked(context, treeUriStr, relativeDir, finalName, srcPath)
}
}
fun writeFileToSafUnique(
context: Context,
treeUriStr: String,
relativeDir: String,
fileName: String,
mimeType: String,
srcPath: String,
preservedSuffix: String = "",
): UniqueWriteResult? {
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
val preferredName = sanitizeFilenamePreservingSuffix(fileName, preservedSuffix)
return withSafNameLock(treeUriStr, safeRelativeDir, preferredName) {
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) ?: return@withSafNameLock null
val availableName = findAvailableFileName(
targetDir,
preferredName,
preservedSuffix,
)
val uri = writeFileToSafLocked(
context,
treeUriStr,
safeRelativeDir,
availableName,
srcPath,
) ?: return@withSafNameLock null
UniqueWriteResult(uri = uri, fileName = availableName)
}
}
fun writeFileToSafCollisionAware(
context: Context,
treeUriStr: String,
relativeDir: String,
cleanFileName: String,
variantFileName: String,
mimeType: String,
srcPath: String,
preservedSuffix: String = "",
): UniqueWriteResult? {
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
val cleanName = sanitizeFilename(cleanFileName)
val preferredVariant = sanitizeFilenamePreservingSuffix(
variantFileName,
preservedSuffix,
)
return withSafNameLock(treeUriStr, safeRelativeDir, cleanName) {
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
?: return@withSafNameLock null
val selectedName = if (targetDir.findFile(cleanName) == null) {
cleanName
} else {
findAvailableFileName(targetDir, preferredVariant, preservedSuffix)
}
val uri = writeFileToSafLocked(
context,
treeUriStr,
safeRelativeDir,
selectedName,
srcPath,
) ?: return@withSafNameLock null
UniqueWriteResult(uri = uri, fileName = selectedName)
}
}
fun writeFileToSafIfAbsent(
context: Context,
treeUriStr: String,
relativeDir: String,
fileName: String,
mimeType: String,
srcPath: String,
): ExistingAwareWriteResult? {
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
val finalName = sanitizeFilename(fileName)
return withSafNameLock(treeUriStr, safeRelativeDir, finalName) {
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
?: return@withSafNameLock null
val existing = targetDir.findFile(finalName)
if (existing != null && existing.isFile && existing.length() > 0L) {
return@withSafNameLock ExistingAwareWriteResult(
uri = existing.uri.toString(),
fileName = existing.name ?: finalName,
alreadyExists = true,
)
}
val uri = writeFileToSafLocked(
context,
treeUriStr,
safeRelativeDir,
finalName,
srcPath,
) ?: return@withSafNameLock null
ExistingAwareWriteResult(
uri = uri,
fileName = finalName,
alreadyExists = false,
)
}
}
private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
val sanitized = sanitizeFilename(fileName)
val trimmedSuffix = suffix.trim()
if (trimmedSuffix.isEmpty() || sanitized.contains(trimmedSuffix)) return sanitized
val dotIndex = fileName.lastIndexOf('.')
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
val rawStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
val rawPrefix = rawStem.replace(trimmedSuffix, "").trim(' ', '_', '-')
val safeSuffix = sanitizeFilename(trimmedSuffix)
val reserved = " - $safeSuffix$extension"
val prefixBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reserved.toByteArray(Charsets.UTF_8).size)
.coerceAtLeast(1)
val safePrefix = truncateUtf8Bytes(sanitizeFilename(rawPrefix), prefixBytes)
.trim()
.trim('.', ' ', '_', '-')
.ifBlank { "track" }
return "$safePrefix$reserved"
}
private fun findAvailableFileName(
parent: DocumentFile,
preferredName: String,
preservedSuffix: String,
): String {
if (parent.findFile(preferredName) == null) return preferredName
for (counter in 2..9999) {
val candidate = appendFilenameCounter(
preferredName,
counter.toLong(),
preservedSuffix,
)
if (parent.findFile(candidate) == null) return candidate
}
return appendFilenameCounter(
preferredName,
System.currentTimeMillis(),
preservedSuffix,
)
}
private fun appendFilenameCounter(
fileName: String,
counter: Long,
preservedSuffix: String,
): String {
val dotIndex = fileName.lastIndexOf('.')
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
val originalStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
val safePreservedSuffix = preservedSuffix.trim()
val hasPreservedSuffix = safePreservedSuffix.isNotEmpty() && originalStem.contains(safePreservedSuffix)
val stem = if (hasPreservedSuffix) {
originalStem.replace(safePreservedSuffix, "").trim(' ', '_', '-')
} else {
originalStem
}
val suffix = if (hasPreservedSuffix) {
" - $safePreservedSuffix ($counter)"
} else {
" ($counter)"
}
val reservedBytes = extension.toByteArray(Charsets.UTF_8).size +
suffix.toByteArray(Charsets.UTF_8).size
val maxStemBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reservedBytes).coerceAtLeast(1)
val safeStem = truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').ifBlank { "track" }
return "$safeStem$suffix$extension"
}
private fun writeFileToSafLocked(
context: Context,
treeUriStr: String,
relativeDir: String,
finalName: String,
srcPath: String
): String? {
var stagedDocument: DocumentFile? = null
return try {
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
val finalName = sanitizeFilename(fileName)
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
val stagedName = buildStagedSafFileName(finalName)
deleteStaleStagedFiles(targetDir, finalName, ext)
@@ -228,18 +527,16 @@ object SafDownloadHandler {
File(srcPath).inputStream().use { input ->
input.copyTo(output)
}
syncOutputStream(output)
}
val existingFinal = targetDir.findFile(finalName)
if (existingFinal != null && existingFinal.uri != document.uri) {
existingFinal.delete()
}
if (!document.renameTo(finalName)) {
val published = replaceFinalDocument(targetDir, document, finalName)
if (published == null) {
document.delete()
return null
}
stagedDocument = null
targetDir.findFile(finalName)?.uri?.toString() ?: document.uri.toString()
published.uri.toString()
} catch (e: Exception) {
stagedDocument?.delete()
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
@@ -255,21 +552,20 @@ object SafDownloadHandler {
}
}
private fun normalizeExt(ext: String?): String {
if (ext.isNullOrBlank()) return ""
return if (ext.startsWith(".")) {
ext.lowercase(Locale.ROOT)
} else {
".${ext.lowercase(Locale.ROOT)}"
}
internal fun normalizeExt(ext: String?): String {
val trimmed = ext?.trim().orEmpty()
if (trimmed.isEmpty()) return ""
return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}"
}
private fun mimeTypeForExt(ext: String?): String {
internal fun mimeTypeForExt(ext: String?): String {
return when (normalizeExt(ext)) {
".m4a", ".mp4" -> "audio/mp4"
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".opus", ".ogg" -> "audio/ogg"
".flac" -> "audio/flac"
".wav" -> "audio/wav"
".aiff", ".aif", ".aifc" -> "audio/aiff"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
@@ -313,7 +609,8 @@ object SafDownloadHandler {
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
val stagedNames = linkedSetOf(
buildStagedSafFileName(fileName),
buildLegacyStagedSafFileName(fileName, outputExt)
buildLegacyStagedSafFileName(fileName, outputExt),
buildReplacedSafFileName(fileName)
)
for (stagedName in stagedNames) {
try {
@@ -323,7 +620,7 @@ object SafDownloadHandler {
}
}
private fun sanitizeFilename(name: String): String {
internal fun sanitizeFilename(name: String): String {
var sanitized = name
.replace("/", " ")
.replace(Regex("[\\\\:*?\"<>|]"), " ")
@@ -382,7 +679,7 @@ object SafDownloadHandler {
return builder.toString()
}
private fun sanitizeRelativeDir(relativeDir: String): String {
internal fun sanitizeRelativeDir(relativeDir: String): String {
if (relativeDir.isBlank()) return ""
return relativeDir
.split("/")
@@ -391,7 +688,7 @@ object SafDownloadHandler {
.joinToString("/")
}
private fun ensureDocumentDir(
internal fun ensureDocumentDir(
context: Context,
treeUri: Uri,
relativeDir: String
@@ -423,7 +720,7 @@ object SafDownloadHandler {
}
}
private fun findDocumentDir(
internal fun findDocumentDir(
context: Context,
treeUri: Uri,
relativeDir: String
@@ -441,7 +738,7 @@ object SafDownloadHandler {
return current
}
private fun createOrReuseDocumentFile(
internal fun createOrReuseDocumentFile(
parent: DocumentFile,
mimeType: String,
fileName: String
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widgetBackground" />
<corners android:radius="16dp" />
</shape>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_download_bg"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp">
<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:contentDescription="@null"
android:src="@mipmap/ic_launcher" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widgetTextPrimary"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widgetTextSecondary"
android:textSize="11sp" />
<ProgressBar
android:id="@+id/widget_progress"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_marginTop="6dp"
android:max="100"
android:progressTint="@color/widgetAccent" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widgetBackground">@android:color/system_neutral1_900</color>
<color name="widgetTextPrimary">@android:color/system_neutral1_50</color>
<color name="widgetTextSecondary">@android:color/system_neutral2_200</color>
<color name="widgetAccent">@android:color/system_accent1_200</color>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widgetBackground">#1C1B1F</color>
<color name="widgetTextPrimary">#E6E1E5</color>
<color name="widgetTextSecondary">#CAC4D0</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widgetBackground">@android:color/system_neutral1_10</color>
<color name="widgetTextPrimary">@android:color/system_neutral1_900</color>
<color name="widgetTextSecondary">@android:color/system_neutral2_700</color>
<color name="widgetAccent">@android:color/system_accent1_600</color>
</resources>
@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
<color name="widgetBackground">#F3F3F3</color>
<color name="widgetTextPrimary">#1C1B1F</color>
<color name="widgetTextSecondary">#49454F</color>
<color name="widgetAccent">#1DB954</color>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<!--
SpotiFLAC Mobile provides an explicit, schema-aware Backup & Restore
feature. Platform backup must not copy device-bound SAF grants, paths,
queues, extension credentials, or signed sessions to another install.
-->
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- Android 11 and lower use this legacy rule format. -->
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</full-backup-content>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_download_queue"
android:minWidth="250dp"
android:minHeight="40dp"
android:resizeMode="horizontal"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />
@@ -0,0 +1,237 @@
package com.zarz.spotiflac
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeFinalizationPolicyTest {
@Test
fun matchesSharedCrossPipelineQualityCases() {
val stream = checkNotNull(
javaClass.getResourceAsStream("/finalization_quality_cases.tsv"),
)
stream.bufferedReader().useLines { lines ->
for (line in lines) {
if (line.isBlank() || line.startsWith("#")) continue
val fields = line.split('\t')
assertEquals("invalid shared fixture: $line", 6, fields.size)
val actual = NativeFinalizationPolicy.qualityVariantFilenameLabel(
measuredQuality = fields[4],
bitDepth = fields[1].toIntOrNull(),
sampleRate = fields[2].toIntOrNull(),
bitrateKbps = fields[3].toIntOrNull(),
audioCodec = fields[0],
)
val expected = fields[5].takeUnless { it == "<null>" }
assertEquals("shared fixture: $line", expected, actual)
}
}
}
@Test
fun codecAliasesDriveLossyAndLosslessDecisions() {
assertEquals("aac", NativeFinalizationPolicy.normalizeAudioCodec("mp4a"))
assertEquals("eac3", NativeFinalizationPolicy.normalizeAudioCodec("ec-3"))
assertEquals("opus", NativeFinalizationPolicy.normalizeAudioCodec("ogg"))
assertTrue(NativeFinalizationPolicy.isLossyAudioCodec("ac-4"))
assertTrue(NativeFinalizationPolicy.isLosslessAudioCodec("pcm_s24le"))
assertTrue(NativeFinalizationPolicy.isLosslessAudioCodec("ALAC"))
assertFalse(NativeFinalizationPolicy.isLosslessAudioCodec("aac"))
}
@Test
fun displayQualityUsesMeasuredLosslessSpecifications() {
assertEquals(
"24-bit/96kHz",
NativeFinalizationPolicy.displayAudioQuality(
filePath = "/music/track.flac",
fileName = "track.flac",
bitDepth = 24,
sampleRate = 96000,
bitrateKbps = null,
audioCodec = "flac",
storedQuality = "HI_RES",
),
)
assertEquals(
"24-bit/44.1kHz",
NativeFinalizationPolicy.displayAudioQuality(
filePath = "/music/track.flac",
fileName = "track.flac",
bitDepth = 24,
sampleRate = 44100,
bitrateKbps = null,
audioCodec = "flac",
storedQuality = "LOSSLESS",
),
)
}
@Test
fun displayQualityPreservesUsefulLossyLabels() {
assertEquals(
"OPUS 192kbps",
NativeFinalizationPolicy.displayAudioQuality(
filePath = "/music/track.ogg",
fileName = "track.ogg",
bitDepth = null,
sampleRate = 48000,
bitrateKbps = 192,
audioCodec = "opus",
storedQuality = "HIGH",
),
)
assertEquals(
"AAC",
NativeFinalizationPolicy.displayAudioQuality(
filePath = "/music/track.m4a",
fileName = "track.m4a",
bitDepth = null,
sampleRate = 48000,
bitrateKbps = null,
audioCodec = "aac",
storedQuality = "LOSSLESS",
),
)
}
@Test
fun qualityVariantLabelRejectsUnmeasuredPlaceholders() {
assertNull(
NativeFinalizationPolicy.qualityVariantFilenameLabel(
measuredQuality = "LOSSLESS",
bitDepth = null,
sampleRate = null,
bitrateKbps = null,
audioCodec = "flac",
),
)
assertEquals(
"24bit-96kHz",
NativeFinalizationPolicy.qualityVariantFilenameLabel(
measuredQuality = "24-bit/96kHz",
bitDepth = null,
sampleRate = null,
bitrateKbps = null,
audioCodec = "flac",
),
)
assertEquals(
"320kbps",
NativeFinalizationPolicy.qualityVariantFilenameLabel(
measuredQuality = "MP3 320kbps",
bitDepth = null,
sampleRate = null,
bitrateKbps = null,
audioCodec = "mp3",
),
)
}
@Test
fun finalQualityLabelReplacesOnlyTheStagingToken() {
assertEquals(
"Artist - Track - 24bit-96kHz.flac",
NativeFinalizationPolicy.applyQualityVariantFilenameLabel(
fileName = "Artist - Track - pending.flac",
stagingLabel = "pending",
qualityLabel = "24bit-96kHz",
),
)
assertEquals(
"Artist - Track - 24bit-96kHz.flac",
NativeFinalizationPolicy.applyQualityVariantFilenameLabel(
fileName = "Artist - Track.flac",
stagingLabel = "",
qualityLabel = "24bit-96kHz",
),
)
}
@Test
fun measuredQualityIsAddedOnlyAfterCleanNameCollision() {
val stagedName = "Artist - Track - qv_ab12cd34.flac"
assertEquals(
"Artist - Track.flac",
NativeFinalizationPolicy.removeQualityVariantStagingLabel(
fileName = stagedName,
stagingLabel = "qv_ab12cd34",
),
)
assertEquals(
"Artist - Track.flac",
NativeFinalizationPolicy.resolveQualityVariantFilename(
fileName = stagedName,
stagingLabel = "qv_ab12cd34",
qualityLabel = "24bit-96kHz",
collisionOnly = true,
cleanNameExists = false,
),
)
assertEquals(
"Artist - Track - 24bit-96kHz.flac",
NativeFinalizationPolicy.resolveQualityVariantFilename(
fileName = stagedName,
stagingLabel = "qv_ab12cd34",
qualityLabel = "24bit-96kHz",
collisionOnly = true,
cleanNameExists = true,
),
)
}
@Test
fun deferredSafNamingNeverPublishesTheNativeCacheName() {
val logicalName = NativeFinalizationPolicy.logicalOutputFileName(
deferredSafPublish = true,
resultSafFileName = "Sunidhi Chauhan - Aisa Jadoo - qv_ab12cd34.flac",
requestSafFileName = "fallback.flac",
currentFileName = "native_saf_work_603020549715656640.m4a",
)
assertEquals(
"Sunidhi Chauhan - Aisa Jadoo - 16bit-44.1kHz.flac",
NativeFinalizationPolicy.applyQualityVariantFilenameLabel(
fileName = logicalName,
stagingLabel = "qv_ab12cd34",
qualityLabel = "16bit-44.1kHz",
),
)
}
@Test
fun nonSafNamingStillFollowsTheCurrentConvertedFile() {
assertEquals(
"converted.flac",
NativeFinalizationPolicy.logicalOutputFileName(
deferredSafPublish = false,
resultSafFileName = "ignored.flac",
requestSafFileName = "ignored-too.flac",
currentFileName = "converted.flac",
),
)
}
@Test
fun decryptionExtensionAndIndexTagsHaveStableFallbacks() {
assertEquals(
".m4a",
NativeFinalizationPolicy.resolvePreferredDecryptionExtension(
inputPath = "/music/encrypted.bin",
requested = "M4A",
),
)
assertEquals(
".flac",
NativeFinalizationPolicy.resolvePreferredDecryptionExtension(
inputPath = "/music/encrypted.m4a",
requested = "",
),
)
assertEquals("3/12", NativeFinalizationPolicy.formatIndexTag(3, 12))
assertEquals("3", NativeFinalizationPolicy.formatIndexTag(3, 0))
assertEquals("0", NativeFinalizationPolicy.formatIndexTag(0, 12))
}
}
@@ -0,0 +1,91 @@
package com.zarz.spotiflac
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeReplayGainPolicyTest {
@Test
fun completeAlbumRequiresEveryCompletedRequestEntry() {
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = listOf("album:a", "album:a"),
statuses = mapOf("one" to "completed", "two" to "completed"),
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
)
assertEquals(listOf(0, 1), eligible)
}
@Test
fun incompleteOrFailedAlbumIsBlocked() {
assertTrue(
NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = listOf("album:a"),
statuses = mapOf("one" to "completed", "two" to "downloading"),
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
).isEmpty(),
)
assertTrue(
NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = listOf("album:a", "album:a"),
statuses = mapOf("one" to "completed", "two" to "failed"),
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
).isEmpty(),
)
}
@Test
fun singleTrackAndBlankAlbumKeysAreNeverWrittenAsAlbumGain() {
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = listOf("album:single", "", ""),
statuses = mapOf(
"single" to "completed",
"blank-one" to "completed",
"blank-two" to "completed",
),
requestAlbumKeys = mapOf(
"single" to "album:single",
"blank-one" to "",
"blank-two" to "",
),
)
assertTrue(eligible.isEmpty())
}
@Test
fun albumsAreEvaluatedIndependently() {
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = listOf("album:a", "album:a", "album:b", "album:b"),
statuses = mapOf(
"a1" to "completed",
"a2" to "completed",
"b1" to "completed",
"b2" to "queued",
),
requestAlbumKeys = mapOf(
"a1" to "album:a",
"a2" to "album:a",
"b1" to "album:b",
"b2" to "album:b",
),
)
assertEquals(listOf(0, 1), eligible)
}
@Test
fun pendingWorkOnlyIncludesRunnableStatuses() {
assertTrue(
NativeReplayGainPolicy.hasPendingWork(
mapOf("one" to "completed", "two" to "finalizing"),
),
)
assertFalse(
NativeReplayGainPolicy.hasPendingWork(
mapOf("one" to "completed", "two" to "failed"),
),
)
}
}
@@ -0,0 +1,152 @@
package com.zarz.spotiflac
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeWorkerPolicyTest {
@Test
fun rateLimitRetriesExactlyOnce() {
assertTrue(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "rate_limit",
errorMessage = "Too many requests",
attempts = 0,
),
)
assertFalse(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "rate_limit",
errorMessage = "Too many requests",
attempts = 1,
),
)
}
@Test
fun rateLimitDetectionFallsBackToMessage() {
assertTrue(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "network",
errorMessage = "HTTP 429: retry later",
attempts = 0,
),
)
assertFalse(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "network",
errorMessage = "Connection reset",
attempts = 0,
),
)
}
@Test
fun retryDelayUsesServerHintAndSafeBounds() {
assertEquals(
45,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = 45,
errorMessage = null,
),
)
assertEquals(
12,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = null,
errorMessage = "Retry-After seconds: 12",
),
)
assertEquals(
5,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = 1,
errorMessage = null,
),
)
assertEquals(
300,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = 900,
errorMessage = null,
),
)
}
@Test
fun wifiOnlyPausesWithoutWifi() {
assertTrue(
NativeWorkerPolicy.shouldPauseForNetwork(
downloadNetworkMode = "wifi_only",
hasWifi = false,
),
)
assertFalse(
NativeWorkerPolicy.shouldPauseForNetwork(
downloadNetworkMode = "wifi_only",
hasWifi = true,
),
)
assertFalse(
NativeWorkerPolicy.shouldPauseForNetwork(
downloadNetworkMode = "any",
hasWifi = false,
),
)
}
@Test
fun verificationDetectionUsesTypeAndMessageFallback() {
assertTrue(
NativeWorkerPolicy.isVerificationRequired(
errorType = "verification_required",
errorMessage = null,
),
)
assertTrue(
NativeWorkerPolicy.isVerificationRequired(
errorType = "unknown",
errorMessage = "Challenge required before download",
),
)
assertFalse(
NativeWorkerPolicy.isVerificationRequired(
errorType = "network",
errorMessage = "Connection reset",
),
)
}
@Test
fun completionAlertSkipsCancelledOrEmptyRuns() {
assertTrue(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = false,
completed = 1,
failed = 0,
),
)
assertTrue(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = false,
completed = 0,
failed = 1,
),
)
assertFalse(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = true,
completed = 1,
failed = 0,
),
)
assertFalse(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = false,
completed = 0,
failed = 0,
),
)
}
}
@@ -0,0 +1,6 @@
# codec bit_depth sample_rate bitrate_kbps measured_quality expected_label
flac 24 96000 LOSSLESS 24bit-96kHz
flac LOSSLESS <null>
flac 24-bit/44.1kHz 24bit-44.1kHz
mp3 320 HIGH 320kbps
opus OPUS 192kbps 192kbps
1 # codec bit_depth sample_rate bitrate_kbps measured_quality expected_label
2 flac 24 96000 LOSSLESS 24bit-96kHz
3 flac LOSSLESS <null>
4 flac 24-bit/44.1kHz 24bit-44.1kHz
5 mp3 320 HIGH 320kbps
6 opus OPUS 192kbps 192kbps
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-all.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
id("com.android.application") version "9.3.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.10" apply false
}
include(":app")
+6 -6
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.6.0",
"versionDate": "2026-06-13",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.6.0/SpotiFLAC-v4.6.0-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": 34347687
"version": "4.8.0",
"versionDate": "2026-07-25",
"downloadURL": "https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases/download/v4.8.0/SpotiFLAC-v4.8.0-ios-unsigned.ipa",
"localizedDescription": "SpotiFLAC Mobile is written in Flutter and uses installable extensions for metadata and audio downloads.",
"iconURL": "https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 37939793
}
]
}
Binary file not shown.
+2 -2
View File
@@ -5,7 +5,7 @@
# Template for the changelog body
body = """
{%- macro remote_url() -%}
https://github.com/zarzet/SpotiFLAC-Mobile
https://github.com/spotiflacapp/SpotiFLAC-Mobile
{%- endmacro -%}
{% if version %}\
@@ -99,5 +99,5 @@ tag_pattern = "v[0-9].*"
sort_commits = "newest"
[remote.github]
owner = "zarzet"
owner = "spotiflacapp"
repo = "SpotiFLAC-Mobile"
+3 -5
View File
@@ -6,18 +6,16 @@ files:
# 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
# Locales below the 70% translation threshold (ar, hi, nl,
# zh-CN, zh-TW) are unmapped so `crowdin pull` doesn't recreate
# their arb files; re-add a mapping line when one qualifies.
de: de
es-ES: es_ES
fr: fr
hi: hi
id: id
ja: ja
ko: ko
nl: nl
pt-PT: pt_PT
ru: ru
tr: tr
uk: uk
zh-CN: zh_CN
zh-TW: zh_TW
+242
View File
@@ -0,0 +1,242 @@
# SpotiFLAC Mobile Extension Development
This guide defines the extension package and manifest contract implemented by
the current SpotiFLAC Mobile codebase. The expanded runtime API reference is
available at <https://spotiflac.zarz.moe/docs>.
## Quick start
Create a directory with these two root files:
```text
my-extension/
├── manifest.json
└── index.js
```
Use the current camel-case manifest schema:
```json
{
"name": "my-extension",
"displayName": "My Extension",
"version": "1.0.0",
"description": "What this extension provides",
"homepage": "https://github.com/you/my-extension",
"type": ["metadata_provider"],
"minAppVersion": "4.2.3",
"permissions": {
"network": ["api.example.com", "*.example.com"],
"storage": false,
"file": false
},
"settings": []
}
```
Register the implementation from `index.js`:
```js
registerExtension({
searchTracks: async function (query, limit) {
const response = await http.get(
"https://api.example.com/search?q=" +
encodeURIComponent(query) +
"&limit=" +
String(limit)
);
return {
tracks: response.data.items.map((item) => ({
id: String(item.id),
name: item.title,
artist: item.artist,
album_name: item.album,
duration_ms: item.duration_ms,
cover_url: item.cover_url
})),
total: response.data.total
};
}
});
```
Package the contents—not their parent directory—as ZIP:
```bash
cd my-extension
zip -r ../my-extension.sflx manifest.json index.js
```
An extension package is a plain ZIP archive renamed to `.sflx`. The longer
`.spotiflac-ext` suffix is the legacy alias; both are accepted everywhere
(manual import, repo downloads) and the layout is identical. Use `.sflx` for
new packages.
`manifest.json` and `index.js` must be unique files at the archive root.
SpotiFLAC Mobile rejects traversal paths, symlinks, duplicate paths, oversized
manifests, and archives whose extracted size exceeds the safety limit.
## Manifest contract
The parser lives in
[`go_backend/extension_manifest.go`](../go_backend/extension_manifest.go).
Use these exact field names:
| Field | Required | Contract |
| --- | --- | --- |
| `name` | yes | Stable lowercase ID matching `^[a-z0-9][a-z0-9._-]{0,127}$` |
| `displayName` | recommended | Human-readable Store and settings label |
| `version` | yes | Numeric dotted version used by upgrade comparison |
| `description` | yes | Human-readable purpose |
| `type` | yes | Array containing `metadata_provider`, `download_provider`, or `lyrics_provider` |
| `permissions` | yes | Capability object described below |
| `homepage`, `icon`, `minAppVersion` | no | `icon` is a path inside the package |
| `settings` | no | Extension settings shown by the app |
| `qualityOptions` | download provider | Download quality IDs passed to `download()` |
| `searchBehavior` | no | Generic search-tab behavior |
| `urlHandler` | no | URL matching declarations |
| `trackMatching` | no | Generic matching strategy |
| `postProcessing` | no | Generic post-processing hooks |
| `serviceHealth` | no | Health checks shown by the app |
| `signedSession` | no | Signed-session bootstrap contract |
| `requiredRuntimeFeatures` | no | Runtime feature requirements |
| `capabilities` | no | Generic extension capability declarations |
The behavior flags currently supported are `skipMetadataEnrichment`,
`skipLyrics`, `stopProviderFallback`, and `skipBuiltInFallback`. New
extension-specific behavior must be added as a generic manifest capability;
the host must not branch on a particular provider ID.
Extensions that depend on canonical gateway/provider error ownership should
declare `requiredRuntimeFeatures: ["signedSession@2"]`. Version 2 only mutates
gateway session state for the exact `SESSION_INVALID/bootstrap_session` or
`VERIFY_REQUIRED/verify` contracts, exposes canonical error fields to JS, and
keeps provider-owned failures separate. `403 REQUEST_AUTH_INVALID` preserves
the session and only retries once when a newer session generation is already
available. BYOA
reauthentication remains a separate provider action. An active generation that
receives canonical `428 VERIFY_REQUIRED` is blocked in the shared coordinator,
so later requests join the same verification flow instead of hitting the
gateway repeatedly.
Extensions that depend on provider retry modes should require
`signedSession@3`. The runtime exposes `retryMode` and only auto-retries
`PROVIDER_UNAVAILABLE` when `retryable: true` is paired with
`retry_mode: "same_operation"`. Every such retry is newly signed with a fresh
timestamp and nonce. `new_ticket`, `poll_existing`, `none`, missing, and unknown
modes are returned to the extension without automatic replay.
Do not use legacy spellings such as `display_name`, `types`,
`permissions.network.domains`, or an object for `permissions.network`.
### Permissions
```json
{
"permissions": {
"network": ["api.example.com", "*.cdn.example.com"],
"storage": true,
"file": false,
"allowHttp": false
}
}
```
- `network` is an array of allowed host names. HTTPS is required unless
`allowHttp` is explicitly enabled.
- `storage` is required for extension storage and signed-session state.
- `file` is required for file and raw FFmpeg capabilities.
Request only what the extension needs. The runtime denies undeclared network,
storage, and file access.
## Downloading files
Extensions with `permissions.file: true` can stream a remote file into their
allowed output path:
```js
const result = file.download(downloadUrl, outputPath, {
headers: {
"User-Agent": "My Extension/1.0"
},
onProgress: function (written, total) {
log.debug("Downloaded", written, "of", total, "bytes");
},
resume: true
});
```
The third argument is optional and supports:
| Option | Type | Default | Contract |
| --- | --- | --- | --- |
| `headers` | object | `{}` | Additional request headers. Do not set `Range` when using runtime-managed resume. |
| `onProgress` | function | none | Called as `onProgress(writtenBytes, totalBytes)` when the total is known. |
| `trackItemBytes` | boolean | `true` | Publishes byte progress to the host download queue. The legacy alias `track_item_bytes` is also accepted. |
| `resume` | boolean | `false` | Allows up to three mid-body Range resumes for the normal streaming mode. |
| `chunked` | boolean or positive number | `false` | Uses sequential Range requests. `true` selects 1 MiB chunks; a positive number sets the chunk size in bytes. |
`resume` is deliberately opt-in. It is attempted only when the server returns
a strong `ETag` or `Last-Modified` validator. Resumed responses must return the
expected `206 Content-Range`; if the server returns `200`, the staged file is
truncated and restarted from byte zero. Enable it only when the origin
guarantees that the same URL and validator identify byte-identical content
across retries and network changes. A CDN can otherwise splice bytes from two
different objects into one apparently successful file.
Use `chunked` for origins that require bounded Range requests, such as some
media CDNs. Chunked mode has its own per-chunk retries and does not use the
`resume` option. In every mode SpotiFLAC Mobile writes to a staged sibling file
and publishes the final path only after the download completes successfully.
## Store registry integrity
Repository maintainers should publish a SHA-256 digest for every package:
```json
{
"version": 1,
"extensions": [
{
"id": "my-extension",
"name": "my-extension",
"display_name": "My Extension",
"version": "1.0.0",
"description": "What this extension provides",
"category": "metadata",
"download_url": "https://example.com/my-extension.sflx",
"sha256": "64-lowercase-hex-characters"
}
]
}
```
Generate the digest after building the package:
```bash
sha256sum my-extension.sflx
```
Store downloads with a published digest are written to a temporary file,
hashed, and only moved into place after the digest matches. A mismatch aborts
installation and preserves any previously cached package. Legacy registry
entries without `sha256` remain compatible but cannot provide package
integrity verification.
A checksum authenticates a package only as strongly as the HTTPS registry that
publishes it. A manually imported `.spotiflac-ext` or `.sflx` package has no
registry trust context, so install manual packages only from a publisher you
trust.
## Compatibility checklist
Before publishing:
1. Validate that `manifest.json` uses the exact current field names.
2. Keep `manifest.json` and `index.js` at the archive root.
3. Declare every network host and runtime permission used.
4. Set `minAppVersion` when relying on a recently added capability.
5. Test install, enable, disable, upgrade, and removal.
6. Publish the package SHA-256 in the repository registry.
+133 -37
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"encoding/binary"
"fmt"
"io"
"os"
)
@@ -25,13 +26,14 @@ func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
typ := string(data[pos+4 : pos+8])
hdr := int64(8)
if size == 1 {
switch size {
case 1:
if pos+16 > n {
return mp4Box{}, false
}
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
hdr = 16
} else if size == 0 {
case 0:
size = n - pos
}
if size < hdr || pos+size > n {
@@ -87,22 +89,26 @@ func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool
}
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
// entry header (from the box body start) before child boxes begin.
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) int64 {
// 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 8 + 20
return 0, false
}
version := binary.BigEndian.Uint16(data[base+8 : base+10])
hdrLen = 8 + 20
switch version {
case 1:
return 8 + 20 + 16
hdrLen += 16
case 2:
return 8 + 20 + 36
default:
return 8 + 20
hdrLen += 36
}
if base+hdrLen > entry.end() {
return 0, false
}
return hdrLen, true
}
type ac4Location struct {
@@ -202,23 +208,33 @@ func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
})
}
// 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"))
}
// normalizeQuickTimeBrandsInBuf rewrites a "qt " ftyp brand to isom/mp42 in
// place. Works on any buffer whose top level contains the ftyp box (whole file
// or the ftyp box alone). Returns whether anything changed.
func normalizeQuickTimeBrandsInBuf(data []byte) bool {
ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp")
if !ok {
return false
}
changed := false
if ftyp.body()+4 <= int64(len(data)) && string(data[ftyp.body():ftyp.body()+4]) != "mp42" {
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
changed = true
}
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"))
changed = true
}
}
return changed
}
// normalizeQuickTimeAudioEntry rewrites a version-1 QuickTime sound sample
// entry into a plain version-0 AudioSampleEntry, dropping the 16-byte v1
// extension. base is the buffer's absolute file offset (0 for a whole-file
// buffer, the moov offset for a moov-only buffer).
func normalizeQuickTimeAudioEntry(data []byte, base int64) []byte {
loc, ok := locateAC4Entry(data)
if !ok {
return data
@@ -232,14 +248,17 @@ func normalizeQuickTimeAudioToMP4(data []byte) []byte {
return data // already v0 (or v2, left untouched)
}
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
// 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)
shiftChunkOffsets(data, loc.chain[0], extStart, delta)
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
shiftChunkOffsets(data, loc.chain[0], base+extStart, delta)
for _, b := range loc.chain {
growBoxSize(data, b, delta)
}
@@ -251,53 +270,114 @@ func normalizeQuickTimeAudioToMP4(data []byte) []byte {
return out
}
// 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 {
normalizeQuickTimeBrandsInBuf(data)
return normalizeQuickTimeAudioEntry(data, 0)
}
// 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.
// moov still carries it). No-op when the file has no AC-4 track. Only the ftyp
// and moov boxes are held in memory; the audio bulk is streamed.
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
dst, err := os.ReadFile(decryptedPath)
f, err := os.Open(decryptedPath)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
f.Close()
return err
}
// A non-MP4 decrypt output (e.g. a raw FLAC stream) is not an AC-4 file;
// bail out before the box parser reports it as a corrupt MP4. A real
// ISO-BMFF box type is four printable ASCII bytes.
var head [8]byte
if _, err := f.ReadAt(head[:], 0); err != nil {
f.Close()
if err == io.EOF || err == io.ErrUnexpectedEOF {
return nil
}
return err
}
for _, c := range head[4:8] {
if c < 0x20 || c > 0x7e {
f.Close()
return nil
}
}
moovBuf, moovOffset, moovFound, err := loadTopLevelMP4Box(f, info.Size(), "moov")
if err != nil || !moovFound {
f.Close()
return err // parse/read failure, or no moov: nothing to do
}
ftypBuf, ftypOffset, ftypFound, err := loadTopLevelMP4Box(f, info.Size(), "ftyp")
f.Close()
if err != nil {
return err
}
moovLen := int64(len(moovBuf))
if _, ok := locateAC4Entry(dst); !ok {
if _, ok := locateAC4Entry(moovBuf); !ok {
return nil // not an AC-4 file; nothing to do
}
dst = normalizeQuickTimeAudioToMP4(dst)
ftypChanged := ftypFound && normalizeQuickTimeBrandsInBuf(ftypBuf)
dst := normalizeQuickTimeAudioEntry(moovBuf, moovOffset)
loc, ok := locateAC4Entry(dst)
if !ok {
return nil
}
hdrLen := audioSampleEntryHeaderLen(dst, loc.entry)
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)
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, dst, moovOffset, moovLen)
}
src, err := os.ReadFile(sourcePath)
srcF, err := os.Open(sourcePath)
if err != nil {
return err
}
srcMoov, ok := findChildMP4(src, 0, int64(len(src)), "moov")
srcInfo, err := srcF.Stat()
if err != nil {
srcF.Close()
return err
}
srcMoovBuf, _, srcFound, err := loadTopLevelMP4Box(srcF, srcInfo.Size(), "moov")
srcF.Close()
if err != nil {
return err
}
if !srcFound {
return fmt.Errorf("source has no moov")
}
srcMoov, ok := readMP4Box(srcMoovBuf, 0)
if !ok {
return fmt.Errorf("source has no moov")
}
dac4Box, ok := findBoxBySignature(src, srcMoov.body(), srcMoov.end(), "dac4")
dac4Box, ok := findBoxBySignature(srcMoovBuf, srcMoov.body(), srcMoov.end(), "dac4")
if !ok {
return fmt.Errorf("dac4 not found in source")
}
dac4 := append([]byte{}, src[dac4Box.offset:dac4Box.end()]...)
dac4 := append([]byte{}, srcMoovBuf[dac4Box.offset:dac4Box.end()]...)
insertPos := childStart
delta := int64(len(dac4))
shiftChunkOffsets(dst, loc.chain[0], insertPos, delta)
shiftChunkOffsets(dst, loc.chain[0], moovOffset+insertPos, delta)
for _, b := range loc.chain {
growBoxSize(dst, b, delta)
}
@@ -308,5 +388,21 @@ func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
out = append(out, dac4...)
out = append(out, dst[insertPos:]...)
return os.WriteFile(decryptedPath, out, 0o644)
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, out, moovOffset, moovLen)
}
// writeAC4Sections streams the edited moov (and, when changed, ftyp) back into
// the file.
func writeAC4Sections(path string, ftypChanged bool, ftypBuf []byte, ftypOffset int64, moovBuf []byte, moovOffset, origMoovLen int64) error {
sections := []fileSection{
{start: moovOffset, end: moovOffset + origMoovLen, data: moovBuf},
}
if ftypChanged {
sections = append(sections, fileSection{
start: ftypOffset,
end: ftypOffset + int64(len(ftypBuf)),
data: ftypBuf,
})
}
return replaceFileSectionsStreaming(path, sections)
}
+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")
}
}
+32 -13
View File
@@ -67,12 +67,12 @@ func itunesCoverTag(image []byte) []byte {
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) // 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
payload = append(payload, 0) // empty name
return buildM4AAtom("hdlr", payload)
}
@@ -121,8 +121,10 @@ func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
}
// 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 {
// the moov of an MP4 buffer and returns the rewritten bytes. base is the
// buffer's absolute file offset (0 for a whole-file buffer) so stco/co64
// shifts compare against the absolute positions the entries hold.
func writeMP4iTunesMetadata(data []byte, base int64, md ac4Metadata, cover []byte) []byte {
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
if !ok {
return data
@@ -131,7 +133,7 @@ func writeMP4iTunesMetadata(data []byte, md ac4Metadata, cover []byte) []byte {
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
delta := int64(len(newUdta)) - udta.size
shiftChunkOffsets(data, moov, udta.offset, delta)
shiftChunkOffsets(data, moov, base+udta.offset, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:udta.offset]...)
@@ -142,7 +144,7 @@ func writeMP4iTunesMetadata(data []byte, md ac4Metadata, cover []byte) []byte {
delta := int64(len(newUdta))
insertPos := moov.end()
shiftChunkOffsets(data, moov, insertPos, delta)
shiftChunkOffsets(data, moov, base+insertPos, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:insertPos]...)
@@ -154,12 +156,27 @@ func writeMP4iTunesMetadata(data []byte, md ac4Metadata, cover []byte) []byte {
// 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).
// Only the moov box is held in memory; the audio bulk is streamed.
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
data, err := os.ReadFile(decryptedPath)
f, err := os.Open(decryptedPath)
if err != nil {
return false, err
}
if _, ok := locateAC4Entry(data); !ok {
info, err := f.Stat()
if err != nil {
f.Close()
return false, err
}
moovBuf, moovOffset, found, err := loadTopLevelMP4Box(f, info.Size(), "moov")
f.Close()
if err != nil {
return false, err
}
if !found {
return false, nil
}
moovLen := int64(len(moovBuf))
if _, ok := locateAC4Entry(moovBuf); !ok {
return false, nil
}
@@ -174,8 +191,10 @@ func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string)
}
}
out := writeMP4iTunesMetadata(data, md, cover)
if err := os.WriteFile(decryptedPath, out, 0o644); err != nil {
out := writeMP4iTunesMetadata(moovBuf, moovOffset, md, cover)
if err := replaceFileSectionsStreaming(decryptedPath, []fileSection{
{start: moovOffset, end: moovOffset + moovLen, data: out},
}); err != nil {
return false, err
}
return true, nil
+3 -4
View File
@@ -16,9 +16,8 @@ const (
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
apeTagFlagReadOnly = 1 << 0
// Item flags: bits 1-2 encode content type
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
apeItemFlagBinary = 1 << 1 // 01: binary data
apeItemFlagLink = 2 << 1 // 10: external link
// (00: UTF-8 text, 01: binary data, 10: external link)
apeItemFlagBinary = 1 << 1
)
// APETagItem represents a single key-value item in an APEv2 tag.
@@ -563,7 +562,7 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
return nil, fmt.Errorf("no APEv2 tag found")
}
func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) {
func parseAPETagFromFooter(r io.ReaderAt, _, footerOffset int64, footer []byte) (*APETag, error) {
version := binary.LittleEndian.Uint32(footer[8:12])
tagSize := binary.LittleEndian.Uint32(footer[12:16])
itemCount := binary.LittleEndian.Uint32(footer[16:20])
+6 -894
View File
@@ -5,9 +5,7 @@ import (
"encoding/binary"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strconv"
"strings"
)
@@ -185,7 +183,7 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
case "TCR":
metadata.Copyright = value
case "ULT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXX":
@@ -306,11 +304,11 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
case "TCOP":
metadata.Copyright = value
case "COMM":
if v := extractCommentFrame(frameData); v != "" {
if v := extractLangTextFrame(frameData); v != "" {
metadata.Comment = v
}
case "USLT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXXX":
@@ -390,7 +388,9 @@ func extractTextFrame(data []byte) string {
}
}
func extractCommentFrame(data []byte) string {
// extractLangTextFrame decodes ID3 frames with an encoding byte, 3-byte
// language code, and null-terminated descriptor before the text (COMM, USLT).
func extractLangTextFrame(data []byte) string {
if len(data) < 5 {
return ""
}
@@ -425,42 +425,6 @@ func extractCommentFrame(data []byte) string {
return extractTextFrame(framed)
}
func extractLyricsFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
rest := data[4:]
var text []byte
switch encoding {
case 1, 2:
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:]
break
}
}
default:
idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:]
} else {
text = rest
}
}
if len(text) == 0 {
return ""
}
framed := make([]byte, 1+len(text))
framed[0] = encoding
copy(framed[1:], text)
return extractTextFrame(framed)
}
func extractUserTextFrame(data []byte) (string, string) {
if len(data) < 2 {
return "", ""
@@ -581,11 +545,6 @@ func cleanGenre(genre string) string {
return genre
}
func parseTrackNumber(s string) int {
num, _ := parseIndexPair(s)
return num
}
func parseIndexPair(s string) (int, int) {
s = strings.TrimSpace(s)
if s == "" {
@@ -813,414 +772,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
return quality, nil
}
func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
metadata := &AudioMetadata{}
packets, err := collectOggPackets(file, 30, 80)
if err != nil && len(packets) == 0 {
return nil, err
}
streamType := detectOggStreamType(packets)
for _, pkt := range packets {
if streamType == oggStreamOpus {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
parseVorbisComments(pkt[8:], metadata)
break
}
continue
}
if streamType == oggStreamVorbis || streamType == oggStreamUnknown {
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
parseVorbisComments(pkt[7:], metadata)
break
}
}
if streamType == oggStreamUnknown {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
parseVorbisComments(pkt[8:], metadata)
break
}
}
}
if metadata.Title == "" && metadata.Artist == "" {
return nil, fmt.Errorf("no Vorbis comments found")
}
return metadata, nil
}
type oggPage struct {
headerType byte
segmentTable []byte
data []byte
}
func readOggPageWithHeader(file *os.File) (*oggPage, error) {
header := make([]byte, 27)
if _, err := io.ReadFull(file, header); err != nil {
return nil, err
}
if string(header[0:4]) != "OggS" {
return nil, fmt.Errorf("not an Ogg page")
}
headerType := header[5]
numSegments := int(header[26])
segmentTable := make([]byte, numSegments)
if _, err := io.ReadFull(file, segmentTable); err != nil {
return nil, err
}
var pageSize int
for _, seg := range segmentTable {
pageSize += int(seg)
}
pageData := make([]byte, pageSize)
if _, err := io.ReadFull(file, pageData); err != nil {
return nil, err
}
return &oggPage{
headerType: headerType,
segmentTable: segmentTable,
data: pageData,
}, nil
}
func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) {
const maxPacketSize = 10 * 1024 * 1024
var packets [][]byte
var cur []byte
skipPacket := false
for pageNum := 0; pageNum < maxPages && len(packets) < maxPackets; pageNum++ {
page, err := readOggPageWithHeader(file)
if err != nil {
if len(packets) > 0 {
return packets, nil
}
return nil, err
}
if page.headerType&0x01 == 0 && len(cur) > 0 {
cur = nil
skipPacket = false
}
offset := 0
for _, seg := range page.segmentTable {
segLen := int(seg)
if offset+segLen > len(page.data) {
return packets, fmt.Errorf("invalid ogg segment size")
}
if skipPacket {
offset += segLen
if segLen < 255 {
skipPacket = false
}
continue
}
if len(cur)+segLen > maxPacketSize {
cur = nil
skipPacket = true
offset += segLen
if segLen < 255 {
skipPacket = false
}
continue
}
cur = append(cur, page.data[offset:offset+segLen]...)
offset += segLen
if segLen < 255 {
if len(cur) > 0 {
packets = append(packets, cur)
}
cur = nil
if len(packets) >= maxPackets {
return packets, nil
}
}
}
}
return packets, nil
}
type oggStreamType int
const (
oggStreamUnknown oggStreamType = iota
oggStreamOpus
oggStreamVorbis
)
func detectOggStreamType(packets [][]byte) oggStreamType {
for _, p := range packets {
if len(p) >= 8 && string(p[0:8]) == "OpusHead" {
return oggStreamOpus
}
if len(p) > 7 && p[0] == 0x01 && string(p[1:7]) == "vorbis" {
return oggStreamVorbis
}
}
return oggStreamUnknown
}
func parseVorbisComments(data []byte, metadata *AudioMetadata) {
if len(data) < 4 {
return
}
reader := bytes.NewReader(data)
artistValues := make([]string, 0, 1)
albumArtistValues := make([]string, 0, 1)
var vendorLen uint32
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
return
}
if vendorLen > uint32(len(data)-4) {
return
}
vendor := make([]byte, vendorLen)
if _, err := reader.Read(vendor); err != nil {
return
}
var commentCount uint32
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
return
}
for i := uint32(0); i < commentCount && i < 100; i++ {
var commentLen uint32
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
break
}
remaining := uint32(reader.Len())
if commentLen > remaining {
break
}
if commentLen > 512*1024 {
reader.Seek(int64(commentLen), io.SeekCurrent)
continue
}
comment := make([]byte, commentLen)
if _, err := reader.Read(comment); err != nil {
break
}
parts := strings.SplitN(string(comment), "=", 2)
if len(parts) != 2 {
continue
}
key := strings.ToUpper(parts[0])
value := parts[1]
switch key {
case "TITLE":
metadata.Title = value
case "ARTIST":
artistValues = append(artistValues, value)
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
albumArtistValues = append(albumArtistValues, value)
case "ALBUM":
metadata.Album = value
case "DATE", "YEAR":
metadata.Date = value
if len(value) >= 4 {
metadata.Year = value[:4]
}
case "GENRE":
metadata.Genre = value
case "TRACKNUMBER", "TRACK":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "DISCNUMBER", "DISC":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "ISRC":
metadata.ISRC = value
case "COMPOSER":
metadata.Composer = value
case "COMMENT", "DESCRIPTION":
metadata.Comment = value
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = value
case "REPLAYGAIN_TRACK_PEAK":
metadata.ReplayGainTrackPeak = value
case "REPLAYGAIN_ALBUM_GAIN":
metadata.ReplayGainAlbumGain = value
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = value
}
}
if len(artistValues) > 0 {
metadata.Artist = joinVorbisCommentValues(artistValues)
}
if len(albumArtistValues) > 0 {
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
}
}
func GetOggQuality(filePath string) (*OggQuality, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
quality := &OggQuality{}
packets, err := collectOggPackets(file, 5, 10)
if err != nil && len(packets) == 0 {
return nil, err
}
streamType := detectOggStreamType(packets)
if streamType == oggStreamUnknown {
if strings.HasSuffix(strings.ToLower(filePath), ".opus") {
streamType = oggStreamOpus
} else {
streamType = oggStreamVorbis
}
}
isOpus := streamType == oggStreamOpus
var preSkip int
if isOpus {
for _, pkt := range packets {
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
if quality.SampleRate == 0 {
quality.SampleRate = 48000
}
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
break
}
}
} else {
for _, pkt := range packets {
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
break
}
}
}
stat, err := file.Stat()
if err != nil {
return quality, nil
}
fileSize := stat.Size()
granule := readLastOggGranulePosition(file, fileSize)
if granule > 0 {
if isOpus {
totalSamples := granule - int64(preSkip)
if totalSamples > 0 {
durationSec := float64(totalSamples) / 48000.0
if durationSec > 0 {
quality.Duration = int(math.Round(durationSec))
quality.Bitrate = int(float64(fileSize*8) / durationSec)
}
}
} else if quality.SampleRate > 0 {
durationSec := float64(granule) / float64(quality.SampleRate)
if durationSec > 0 {
quality.Duration = int(math.Round(durationSec))
quality.Bitrate = int(float64(fileSize*8) / durationSec)
}
}
}
if quality.Bitrate <= 0 && quality.Duration > 0 {
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
}
if quality.Duration > 24*60*60 {
quality.Duration = 0
quality.Bitrate = 0
}
if quality.Bitrate > 0 && quality.Bitrate < 8000 {
quality.Bitrate = 0
}
return quality, nil
}
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
searchSize := int64(65536)
if searchSize > fileSize {
searchSize = fileSize
}
buf := make([]byte, searchSize)
offset := fileSize - searchSize
if offset < 0 {
offset = 0
}
n, err := file.ReadAt(buf, offset)
if err != nil && n == 0 {
return 0
}
buf = buf[:n]
for i := n - 4; i >= 0; i-- {
if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' {
continue
}
if i+27 > n {
continue
}
version := buf[i+4]
headerType := buf[i+5]
if version != 0 || headerType > 0x07 {
continue
}
segmentCount := int(buf[i+26])
headerLen := 27 + segmentCount
if i+headerLen > n {
continue
}
payloadLen := 0
for s := 0; s < segmentCount; s++ {
payloadLen += int(buf[i+27+s])
}
if i+headerLen+payloadLen > n {
continue
}
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
}
return 0
}
var id3v1Genres = []string{
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
@@ -1250,442 +801,3 @@ var id3v1Genres = []string{
"Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
}
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, "", err
}
defer file.Close()
header := make([]byte, 10)
if _, err := io.ReadFull(file, header); err != nil {
return nil, "", err
}
if string(header[0:3]) != "ID3" {
return nil, "", fmt.Errorf("no ID3v2 header")
}
majorVersion := header[3]
size := int(header[6])<<21 | int(header[7])<<14 | int(header[8])<<7 | int(header[9])
tagData := make([]byte, size)
if _, err := io.ReadFull(file, tagData); err != nil {
return nil, "", err
}
pos := 0
var frameIDLen, headerLen int
if majorVersion == 2 {
frameIDLen = 3
headerLen = 6
} else {
frameIDLen = 4
headerLen = 10
}
for pos+headerLen < len(tagData) {
frameID := string(tagData[pos : pos+frameIDLen])
if frameID[0] == 0 {
break
}
var frameSize int
switch majorVersion {
case 2:
frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5])
case 4:
frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7])
default:
frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7])
}
if frameSize <= 0 || pos+headerLen+frameSize > len(tagData) {
break
}
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
if len(imageData) > 0 {
return imageData, mimeType, nil
}
}
pos += headerLen + frameSize
}
return nil, "", fmt.Errorf("no cover art found")
}
func parseAPICFrame(data []byte, version byte) ([]byte, string) {
if len(data) < 4 {
return nil, ""
}
pos := 0
encoding := data[pos]
pos++
var mimeType string
if version == 2 {
if pos+3 > len(data) {
return nil, ""
}
format := string(data[pos : pos+3])
pos += 3
switch format {
case "JPG":
mimeType = "image/jpeg"
case "PNG":
mimeType = "image/png"
default:
mimeType = "image/jpeg"
}
} else {
end := pos
for end < len(data) && data[end] != 0 {
end++
}
mimeType = string(data[pos:end])
pos = end + 1
}
if pos >= len(data) {
return nil, ""
}
pos++
if encoding == 0 || encoding == 3 {
for pos < len(data) && data[pos] != 0 {
pos++
}
pos++
} else {
for pos+1 < len(data) {
if data[pos] == 0 && data[pos+1] == 0 {
pos += 2
break
}
pos++
}
}
if pos >= len(data) {
return nil, ""
}
return data[pos:], mimeType
}
func extractOggCoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, "", err
}
defer file.Close()
packets, err := collectOggPackets(file, 30, 80)
if err != nil && len(packets) == 0 {
return nil, "", err
}
streamType := detectOggStreamType(packets)
for _, pkt := range packets {
var comments []byte
if streamType == oggStreamOpus {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
comments = pkt[8:]
}
} else {
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
comments = pkt[7:]
}
}
if len(comments) == 0 && streamType == oggStreamUnknown {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
comments = pkt[8:]
} else if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
comments = pkt[7:]
}
}
if len(comments) > 0 {
imageData, mimeType := extractPictureFromVorbisComments(comments)
if len(imageData) > 0 {
return imageData, mimeType, nil
}
}
}
return nil, "", fmt.Errorf("no cover art found")
}
func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
if len(data) < 8 {
return nil, ""
}
reader := bytes.NewReader(data)
var vendorLen uint32
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
return nil, ""
}
if vendorLen > uint32(len(data)-4) {
return nil, ""
}
reader.Seek(int64(vendorLen), io.SeekCurrent)
var commentCount uint32
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
return nil, ""
}
for i := uint32(0); i < commentCount && i < 100; i++ {
var commentLen uint32
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
break
}
if commentLen > 10000000 {
break
}
comment := make([]byte, commentLen)
if _, err := reader.Read(comment); err != nil {
break
}
key := "METADATA_BLOCK_PICTURE="
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
b64Data := comment[len(key):]
decoded := make([]byte, base64StdDecodeLen(len(b64Data)))
n, err := base64StdDecode(decoded, b64Data)
if err != nil {
continue
}
decoded = decoded[:n]
imageData, mimeType := parseFLACPictureBlock(decoded)
if len(imageData) > 0 {
return imageData, mimeType
}
}
}
return nil, ""
}
func parseFLACPictureBlock(data []byte) ([]byte, string) {
if len(data) < 32 {
return nil, ""
}
reader := bytes.NewReader(data)
var pictureType uint32
binary.Read(reader, binary.BigEndian, &pictureType)
var mimeLen uint32
binary.Read(reader, binary.BigEndian, &mimeLen)
if mimeLen > 256 {
return nil, ""
}
mimeBytes := make([]byte, mimeLen)
reader.Read(mimeBytes)
mimeType := string(mimeBytes)
var descLen uint32
binary.Read(reader, binary.BigEndian, &descLen)
if descLen > 10000 {
return nil, ""
}
reader.Seek(int64(descLen), io.SeekCurrent)
reader.Seek(16, io.SeekCurrent)
var dataLen uint32
binary.Read(reader, binary.BigEndian, &dataLen)
if dataLen > 10000000 {
return nil, ""
}
imageData := make([]byte, dataLen)
reader.Read(imageData)
return imageData, mimeType
}
func base64StdDecodeLen(n int) int {
return n * 6 / 8
}
func base64StdDecode(dst, src []byte) (int, error) {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
decodeMap := make([]byte, 256)
for i := range decodeMap {
decodeMap[i] = 0xFF
}
for i := 0; i < len(alphabet); i++ {
decodeMap[alphabet[i]] = byte(i)
}
si, di := 0, 0
for si < len(src) {
for si < len(src) && (src[si] == '\n' || src[si] == '\r' || src[si] == ' ' || src[si] == '\t') {
si++
}
if si >= len(src) {
break
}
var vals [4]byte
var valCount int
for valCount < 4 && si < len(src) {
c := src[si]
si++
if c == '=' {
vals[valCount] = 0
valCount++
} else if c == '\n' || c == '\r' || c == ' ' || c == '\t' {
continue
} else if decodeMap[c] != 0xFF {
vals[valCount] = decodeMap[c]
valCount++
}
}
if valCount < 2 {
break
}
if di < len(dst) {
dst[di] = vals[0]<<2 | vals[1]>>4
di++
}
if valCount >= 3 && di < len(dst) {
dst[di] = vals[1]<<4 | vals[2]>>2
di++
}
if valCount >= 4 && di < len(dst) {
dst[di] = vals[2]<<6 | vals[3]
di++
}
}
return di, nil
}
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
return extractAnyCoverArtWithHint(filePath, "")
}
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == "" {
ext = strings.ToLower(filepath.Ext(displayNameHint))
}
switch ext {
case ".flac":
data, err := ExtractCoverArt(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) > 8 && string(data[1:4]) == "PNG" {
mimeType = "image/png"
}
return data, mimeType, nil
case ".mp3":
return extractMP3CoverArt(filePath)
case ".opus", ".ogg":
return extractOggCoverArt(filePath)
case ".m4a":
data, err := extractCoverFromM4A(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) >= 8 &&
data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4E &&
data[3] == 0x47 {
mimeType = "image/png"
}
return data, mimeType, nil
case ".wav", ".aiff", ".aif", ".aifc":
return extractWAVAIFFCover(filePath)
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
}
}
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
}
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
}
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
return explicitKey
}
cacheKey := filePath
if stat, err := os.Stat(filePath); err == nil {
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
}
return cacheKey
}
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
hash := hashString(cacheKey)
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
if _, err := os.Stat(jpgPath); err == nil {
return jpgPath, nil
}
if _, err := os.Stat(pngPath); err == nil {
return pngPath, nil
}
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
if err != nil {
return "", err
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", fmt.Errorf("failed to create cache dir: %w", err)
}
var cachePath string
if strings.Contains(mimeType, "png") {
cachePath = pngPath
} else {
cachePath = jpgPath
}
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover: %w", err)
}
return cachePath, nil
}
+402
View File
@@ -0,0 +1,402 @@
package gobackend
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, "", err
}
defer file.Close()
header := make([]byte, 10)
if _, err := io.ReadFull(file, header); err != nil {
return nil, "", err
}
if string(header[0:3]) != "ID3" {
return nil, "", fmt.Errorf("no ID3v2 header")
}
majorVersion := header[3]
size := int(header[6])<<21 | int(header[7])<<14 | int(header[8])<<7 | int(header[9])
tagData := make([]byte, size)
if _, err := io.ReadFull(file, tagData); err != nil {
return nil, "", err
}
pos := 0
var frameIDLen, headerLen int
if majorVersion == 2 {
frameIDLen = 3
headerLen = 6
} else {
frameIDLen = 4
headerLen = 10
}
for pos+headerLen < len(tagData) {
frameID := string(tagData[pos : pos+frameIDLen])
if frameID[0] == 0 {
break
}
var frameSize int
switch majorVersion {
case 2:
frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5])
case 4:
frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7])
default:
frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7])
}
if frameSize <= 0 || pos+headerLen+frameSize > len(tagData) {
break
}
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
if len(imageData) > 0 {
return imageData, mimeType, nil
}
}
pos += headerLen + frameSize
}
return nil, "", fmt.Errorf("no cover art found")
}
func parseAPICFrame(data []byte, version byte) ([]byte, string) {
if len(data) < 4 {
return nil, ""
}
pos := 0
encoding := data[pos]
pos++
var mimeType string
if version == 2 {
if pos+3 > len(data) {
return nil, ""
}
format := string(data[pos : pos+3])
pos += 3
switch format {
case "JPG":
mimeType = "image/jpeg"
case "PNG":
mimeType = "image/png"
default:
mimeType = "image/jpeg"
}
} else {
end := pos
for end < len(data) && data[end] != 0 {
end++
}
mimeType = string(data[pos:end])
pos = end + 1
}
if pos >= len(data) {
return nil, ""
}
pos++
if encoding == 0 || encoding == 3 {
for pos < len(data) && data[pos] != 0 {
pos++
}
pos++
} else {
for pos+1 < len(data) {
if data[pos] == 0 && data[pos+1] == 0 {
pos += 2
break
}
pos++
}
}
if pos >= len(data) {
return nil, ""
}
return data[pos:], mimeType
}
func extractOggCoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, "", err
}
defer file.Close()
packets, err := collectOggPackets(file, 30, 80)
if err != nil && len(packets) == 0 {
return nil, "", err
}
streamType := detectOggStreamType(packets)
for _, pkt := range packets {
var comments []byte
if streamType == oggStreamOpus {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
comments = pkt[8:]
}
} else {
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
comments = pkt[7:]
}
}
if len(comments) == 0 && streamType == oggStreamUnknown {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
comments = pkt[8:]
} else if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
comments = pkt[7:]
}
}
if len(comments) > 0 {
imageData, mimeType := extractPictureFromVorbisComments(comments)
if len(imageData) > 0 {
return imageData, mimeType, nil
}
}
}
return nil, "", fmt.Errorf("no cover art found")
}
func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
if len(data) < 8 {
return nil, ""
}
reader := bytes.NewReader(data)
var vendorLen uint32
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
return nil, ""
}
if vendorLen > uint32(len(data)-4) {
return nil, ""
}
reader.Seek(int64(vendorLen), io.SeekCurrent)
var commentCount uint32
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
return nil, ""
}
for i := uint32(0); i < commentCount && i < 100; i++ {
var commentLen uint32
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
break
}
if commentLen > 10000000 {
break
}
comment := make([]byte, commentLen)
if _, err := reader.Read(comment); err != nil {
break
}
key := "METADATA_BLOCK_PICTURE="
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
cleaned := strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', ' ', '\t':
return -1
}
return r
}, string(comment[len(key):]))
decoded, err := base64.StdEncoding.DecodeString(cleaned)
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(cleaned)
}
if err != nil {
continue
}
imageData, mimeType := parseFLACPictureBlock(decoded)
if len(imageData) > 0 {
return imageData, mimeType
}
}
}
return nil, ""
}
func parseFLACPictureBlock(data []byte) ([]byte, string) {
if len(data) < 32 {
return nil, ""
}
reader := bytes.NewReader(data)
var pictureType uint32
binary.Read(reader, binary.BigEndian, &pictureType)
var mimeLen uint32
binary.Read(reader, binary.BigEndian, &mimeLen)
if mimeLen > 256 {
return nil, ""
}
mimeBytes := make([]byte, mimeLen)
reader.Read(mimeBytes)
mimeType := string(mimeBytes)
var descLen uint32
binary.Read(reader, binary.BigEndian, &descLen)
if descLen > 10000 {
return nil, ""
}
reader.Seek(int64(descLen), io.SeekCurrent)
reader.Seek(16, io.SeekCurrent)
var dataLen uint32
binary.Read(reader, binary.BigEndian, &dataLen)
if dataLen > 10000000 {
return nil, ""
}
imageData := make([]byte, dataLen)
reader.Read(imageData)
return imageData, mimeType
}
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == "" {
ext = strings.ToLower(filepath.Ext(displayNameHint))
}
switch ext {
case ".flac":
data, err := ExtractCoverArt(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) > 8 && string(data[1:4]) == "PNG" {
mimeType = "image/png"
}
return data, mimeType, nil
case ".mp3":
return extractMP3CoverArt(filePath)
case ".opus", ".ogg":
return extractOggCoverArt(filePath)
case ".m4a":
data, err := extractCoverFromM4A(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) >= 8 &&
data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4E &&
data[3] == 0x47 {
mimeType = "image/png"
}
return data, mimeType, nil
case ".wav", ".aiff", ".aif", ".aifc":
return extractWAVAIFFCover(filePath)
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
}
}
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
return explicitKey
}
cacheKey := filePath
if stat, err := os.Stat(filePath); err == nil {
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
}
return cacheKey
}
func libraryCoverCachePaths(cacheDir, cacheKey string) (string, string) {
hash := hashString(cacheKey)
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
return jpgPath, pngPath
}
func existingLibraryCoverCachePath(cacheDir, cacheKey string) string {
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
if _, err := os.Stat(jpgPath); err == nil {
return jpgPath
}
if _, err := os.Stat(pngPath); err == nil {
return pngPath
}
return ""
}
func saveLibraryCoverDataToCache(cacheDir, cacheKey string, imageData []byte, mimeType string) (string, error) {
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
return existing, nil
}
if len(imageData) == 0 {
return "", fmt.Errorf("cover data is empty")
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", fmt.Errorf("failed to create cache dir: %w", err)
}
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
cachePath := jpgPath
if strings.Contains(mimeType, "png") {
cachePath = pngPath
}
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover: %w", err)
}
return cachePath, nil
}
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
return existing, nil
}
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
if err != nil {
return "", err
}
return saveLibraryCoverDataToCache(cacheDir, cacheKey, imageData, mimeType)
}
+446
View File
@@ -0,0 +1,446 @@
package gobackend
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"math"
"os"
"strconv"
"strings"
)
func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
metadata := &AudioMetadata{}
packets, err := collectOggPackets(file, 30, 80)
if err != nil && len(packets) == 0 {
return nil, err
}
streamType := detectOggStreamType(packets)
for _, pkt := range packets {
if streamType == oggStreamOpus {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
parseVorbisComments(pkt[8:], metadata)
break
}
continue
}
if streamType == oggStreamVorbis || streamType == oggStreamUnknown {
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
parseVorbisComments(pkt[7:], metadata)
break
}
}
if streamType == oggStreamUnknown {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
parseVorbisComments(pkt[8:], metadata)
break
}
}
}
if metadata.Title == "" && metadata.Artist == "" {
return nil, fmt.Errorf("no Vorbis comments found")
}
return metadata, nil
}
type oggPage struct {
headerType byte
segmentTable []byte
data []byte
}
func readOggPageWithHeader(file *os.File) (*oggPage, error) {
header := make([]byte, 27)
if _, err := io.ReadFull(file, header); err != nil {
return nil, err
}
if string(header[0:4]) != "OggS" {
return nil, fmt.Errorf("not an Ogg page")
}
headerType := header[5]
numSegments := int(header[26])
segmentTable := make([]byte, numSegments)
if _, err := io.ReadFull(file, segmentTable); err != nil {
return nil, err
}
var pageSize int
for _, seg := range segmentTable {
pageSize += int(seg)
}
pageData := make([]byte, pageSize)
if _, err := io.ReadFull(file, pageData); err != nil {
return nil, err
}
return &oggPage{
headerType: headerType,
segmentTable: segmentTable,
data: pageData,
}, nil
}
func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) {
const maxPacketSize = 10 * 1024 * 1024
var packets [][]byte
var cur []byte
skipPacket := false
for pageNum := 0; pageNum < maxPages && len(packets) < maxPackets; pageNum++ {
page, err := readOggPageWithHeader(file)
if err != nil {
if len(packets) > 0 {
return packets, nil
}
return nil, err
}
if page.headerType&0x01 == 0 && len(cur) > 0 {
cur = nil
skipPacket = false
}
offset := 0
for _, seg := range page.segmentTable {
segLen := int(seg)
if offset+segLen > len(page.data) {
return packets, fmt.Errorf("invalid ogg segment size")
}
if skipPacket {
offset += segLen
if segLen < 255 {
skipPacket = false
}
continue
}
if len(cur)+segLen > maxPacketSize {
cur = nil
skipPacket = true
offset += segLen
if segLen < 255 {
skipPacket = false
}
continue
}
cur = append(cur, page.data[offset:offset+segLen]...)
offset += segLen
if segLen < 255 {
if len(cur) > 0 {
packets = append(packets, cur)
}
cur = nil
if len(packets) >= maxPackets {
return packets, nil
}
}
}
}
return packets, nil
}
type oggStreamType int
const (
oggStreamUnknown oggStreamType = iota
oggStreamOpus
oggStreamVorbis
)
func detectOggStreamType(packets [][]byte) oggStreamType {
for _, p := range packets {
if len(p) >= 8 && string(p[0:8]) == "OpusHead" {
return oggStreamOpus
}
if len(p) > 7 && p[0] == 0x01 && string(p[1:7]) == "vorbis" {
return oggStreamVorbis
}
}
return oggStreamUnknown
}
func parseVorbisComments(data []byte, metadata *AudioMetadata) {
if len(data) < 4 {
return
}
reader := bytes.NewReader(data)
artistValues := make([]string, 0, 1)
albumArtistValues := make([]string, 0, 1)
var vendorLen uint32
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
return
}
if vendorLen > uint32(len(data)-4) {
return
}
vendor := make([]byte, vendorLen)
if _, err := reader.Read(vendor); err != nil {
return
}
var commentCount uint32
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
return
}
for i := uint32(0); i < commentCount && i < 100; i++ {
var commentLen uint32
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
break
}
remaining := uint32(reader.Len())
if commentLen > remaining {
break
}
if commentLen > 512*1024 {
reader.Seek(int64(commentLen), io.SeekCurrent)
continue
}
comment := make([]byte, commentLen)
if _, err := reader.Read(comment); err != nil {
break
}
parts := strings.SplitN(string(comment), "=", 2)
if len(parts) != 2 {
continue
}
key := strings.ToUpper(parts[0])
value := parts[1]
switch key {
case "TITLE":
metadata.Title = value
case "ARTIST":
artistValues = append(artistValues, value)
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
albumArtistValues = append(albumArtistValues, value)
case "ALBUM":
metadata.Album = value
case "DATE", "YEAR":
metadata.Date = value
if len(value) >= 4 {
metadata.Year = value[:4]
}
case "GENRE":
metadata.Genre = value
case "TRACKNUMBER", "TRACK":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "DISCNUMBER", "DISC":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "ISRC":
metadata.ISRC = value
case "COMPOSER":
metadata.Composer = value
case "COMMENT", "DESCRIPTION":
metadata.Comment = value
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = value
case "REPLAYGAIN_TRACK_PEAK":
metadata.ReplayGainTrackPeak = value
case "REPLAYGAIN_ALBUM_GAIN":
metadata.ReplayGainAlbumGain = value
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = value
// Opus gain tags (RFC 7845): Q7.8 fixed point on the R128 -23 LUFS
// reference. Exposed as ReplayGain 2 dB (-18 LUFS reference) so
// consumers see one representation; explicit REPLAYGAIN_* wins.
case "R128_TRACK_GAIN":
if metadata.ReplayGainTrackGain == "" {
if db, ok := r128ToReplayGainDb(value); ok {
metadata.ReplayGainTrackGain = db
}
}
case "R128_ALBUM_GAIN":
if metadata.ReplayGainAlbumGain == "" {
if db, ok := r128ToReplayGainDb(value); ok {
metadata.ReplayGainAlbumGain = db
}
}
}
}
if len(artistValues) > 0 {
metadata.Artist = joinVorbisCommentValues(artistValues)
}
if len(albumArtistValues) > 0 {
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
}
}
// r128ToReplayGainDb converts an R128_*_GAIN value (integer, 1/256 dB steps,
// -23 LUFS reference) to a ReplayGain 2 dB string (-18 LUFS reference):
// rg = q/256 + 5. Inverse of the writer's replayGainDbToR128.
func r128ToReplayGainDb(raw string) (string, bool) {
q, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return "", false
}
return fmt.Sprintf("%.2f dB", float64(q)/256.0+5.0), true
}
func GetOggQuality(filePath string) (*OggQuality, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
quality := &OggQuality{}
packets, err := collectOggPackets(file, 5, 10)
if err != nil && len(packets) == 0 {
return nil, err
}
streamType := detectOggStreamType(packets)
if streamType == oggStreamUnknown {
if strings.HasSuffix(strings.ToLower(filePath), ".opus") {
streamType = oggStreamOpus
} else {
streamType = oggStreamVorbis
}
}
isOpus := streamType == oggStreamOpus
var preSkip int
if isOpus {
for _, pkt := range packets {
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
if quality.SampleRate == 0 {
quality.SampleRate = 48000
}
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
break
}
}
} else {
for _, pkt := range packets {
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
break
}
}
}
stat, err := file.Stat()
if err != nil {
return quality, nil
}
fileSize := stat.Size()
granule := readLastOggGranulePosition(file, fileSize)
if granule > 0 {
if isOpus {
totalSamples := granule - int64(preSkip)
if totalSamples > 0 {
durationSec := float64(totalSamples) / 48000.0
if durationSec > 0 {
quality.Duration = int(math.Round(durationSec))
quality.Bitrate = int(float64(fileSize*8) / durationSec)
}
}
} else if quality.SampleRate > 0 {
durationSec := float64(granule) / float64(quality.SampleRate)
if durationSec > 0 {
quality.Duration = int(math.Round(durationSec))
quality.Bitrate = int(float64(fileSize*8) / durationSec)
}
}
}
if quality.Bitrate <= 0 && quality.Duration > 0 {
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
}
if quality.Duration > 24*60*60 {
quality.Duration = 0
quality.Bitrate = 0
}
if quality.Bitrate > 0 && quality.Bitrate < 8000 {
quality.Bitrate = 0
}
return quality, nil
}
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
searchSize := int64(65536)
if searchSize > fileSize {
searchSize = fileSize
}
buf := make([]byte, searchSize)
offset := fileSize - searchSize
if offset < 0 {
offset = 0
}
n, err := file.ReadAt(buf, offset)
if err != nil && n == 0 {
return 0
}
buf = buf[:n]
for i := n - 4; i >= 0; i-- {
if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' {
continue
}
if i+27 > n {
continue
}
version := buf[i+4]
headerType := buf[i+5]
if version != 0 || headerType > 0x07 {
continue
}
segmentCount := int(buf[i+26])
headerLen := 27 + segmentCount
if i+headerLen > n {
continue
}
payloadLen := 0
for s := 0; s < segmentCount; s++ {
payloadLen += int(buf[i+27+s])
}
if i+headerLen+payloadLen > n {
continue
}
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
}
return 0
}
+13 -23
View File
@@ -85,9 +85,6 @@ func TestAudioMetadataID3ParsingBranches(t *testing.T) {
if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 {
t.Fatalf("parseIndexPair = %d/%d", n, total)
}
if got := parseTrackNumber("9/11"); got != 9 {
t.Fatalf("parseTrackNumber = %d", got)
}
if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) {
t.Fatalf("removeUnsync = %#v", got)
}
@@ -164,12 +161,6 @@ func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
if commentMIME != "image/png" || !bytes.Equal(commentImage, png) {
t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage)
}
decoded := make([]byte, base64StdDecodeLen(len("SGV sbG8="))+4)
n, err := base64StdDecode(decoded, []byte("SGV sbG8="))
if err != nil || strings.TrimRight(string(decoded[:n]), "\x00") != "Hello" {
t.Fatalf("base64 decode = %q/%v", decoded[:n], err)
}
if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus {
t.Fatal("expected opus stream")
}
@@ -430,9 +421,6 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 {
t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err)
}
if image, mime, err := extractAnyCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
t.Fatalf("extractAnyCoverArt = %s/%#v/%v", mime, image, err)
}
extractedCoverPath := filepath.Join(dir, "extracted.png")
if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil {
t.Fatalf("ExtractCoverToFile = %v", err)
@@ -444,17 +432,6 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
if err != nil || cachePath == "" {
t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err)
}
cacheDir := filepath.Join(dir, "cache")
if path, err := SaveCoverToCache(coverPath, cacheDir); err != nil || !strings.HasSuffix(path, ".png") {
t.Fatalf("SaveCoverToCache = %q/%v", path, err)
}
if path, err := SaveCoverToCacheWithHint(coverPath, "cover.opus", cacheDir); err != nil || path == "" {
t.Fatalf("SaveCoverToCacheWithHint = %q/%v", path, err)
}
hitPath, err := SaveCoverToCache(coverPath, cacheDir)
if err != nil || hitPath == "" {
t.Fatalf("SaveCoverToCache cache hit = %q/%v", hitPath, err)
}
if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil {
t.Fatal("expected missing cover cache error")
}
@@ -515,3 +492,16 @@ func buildTestFLACPictureBlock(image []byte, mime string) []byte {
picture.Write(image)
return picture.Bytes()
}
func TestR128ToReplayGainDb(t *testing.T) {
// -1280/256 = -5 dB on the R128 (-23 LUFS) scale -> 0 dB ReplayGain (-18).
if db, ok := r128ToReplayGainDb("-1280"); !ok || db != "0.00 dB" {
t.Fatalf("got %q ok=%v", db, ok)
}
if db, ok := r128ToReplayGainDb(" -2944 "); !ok || db != "-6.50 dB" {
t.Fatalf("got %q ok=%v", db, ok)
}
if _, ok := r128ToReplayGainDb("abc"); ok {
t.Fatal("expected failure for non-numeric input")
}
}
+106 -106
View File
@@ -20,23 +20,25 @@ type cancelEntry struct {
refs int
}
var (
cancelMu sync.Mutex
cancelMap = make(map[string]*cancelEntry)
type cancelRegistry struct {
mu sync.Mutex
entries map[string]*cancelEntry
}
extensionRequestCancelMu sync.Mutex
extensionRequestCancelMap = make(map[string]*cancelEntry)
var (
downloadCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)}
extensionRequestCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)}
)
func initDownloadCancel(itemID string) context.Context {
if itemID == "" {
func (r *cancelRegistry) init(id string) context.Context {
if id == "" {
return context.Background()
}
cancelMu.Lock()
defer cancelMu.Unlock()
r.mu.Lock()
defer r.mu.Unlock()
if entry, ok := cancelMap[itemID]; ok {
if entry, ok := r.entries[id]; ok {
if entry.ctx == nil {
ctx, cancel := context.WithCancel(context.Background())
entry.ctx = ctx
@@ -50,7 +52,7 @@ func initDownloadCancel(itemID string) context.Context {
}
ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{
r.entries[id] = &cancelEntry{
ctx: ctx,
cancel: cancel,
canceled: false,
@@ -59,124 +61,122 @@ func initDownloadCancel(itemID string) context.Context {
return ctx
}
func (r *cancelRegistry) context(id string) context.Context {
if id == "" {
return context.Background()
}
r.mu.Lock()
defer r.mu.Unlock()
if entry, ok := r.entries[id]; ok && entry.ctx != nil {
return entry.ctx
}
return context.Background()
}
func (r *cancelRegistry) requestCancel(id string) {
if id == "" {
return
}
r.mu.Lock()
if entry, ok := r.entries[id]; ok {
entry.canceled = true
if entry.cancel != nil {
entry.cancel()
}
} else {
r.entries[id] = &cancelEntry{canceled: true}
}
r.mu.Unlock()
}
func (r *cancelRegistry) isCancelled(id string) bool {
if id == "" {
return false
}
r.mu.Lock()
entry, ok := r.entries[id]
canceled := ok && entry.canceled
r.mu.Unlock()
return canceled
}
// resetIfIdle removes a cancellation entry that has no active work attached
// (refs <= 0). Such entries exist to catch an item that is just about to
// start, but if the item never starts the flag lingers and the next explicit
// retry would consume it and abort immediately.
func (r *cancelRegistry) resetIfIdle(id string) {
if id == "" {
return
}
r.mu.Lock()
if entry, ok := r.entries[id]; ok && entry.refs <= 0 {
delete(r.entries, id)
}
r.mu.Unlock()
}
func (r *cancelRegistry) release(id string) {
if id == "" {
return
}
r.mu.Lock()
if entry, ok := r.entries[id]; ok {
entry.refs--
if entry.refs <= 0 {
delete(r.entries, id)
}
}
r.mu.Unlock()
}
func initDownloadCancel(itemID string) context.Context {
return downloadCancels.init(itemID)
}
func downloadCancelContext(itemID string) context.Context {
return downloadCancels.context(itemID)
}
func cancelDownload(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
if ok {
entry.canceled = true
if entry.cancel != nil {
entry.cancel()
}
} else {
cancelMap[itemID] = &cancelEntry{canceled: true}
}
cancelMu.Unlock()
downloadCancels.requestCancel(itemID)
RemoveItemProgress(itemID)
}
func isDownloadCancelled(itemID string) bool {
if itemID == "" {
return false
}
return downloadCancels.isCancelled(itemID)
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
canceled := ok && entry.canceled
cancelMu.Unlock()
return canceled
func resetDownloadCancel(itemID string) {
downloadCancels.resetIfIdle(itemID)
}
func clearDownloadCancel(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
if entry, ok := cancelMap[itemID]; ok {
entry.refs--
if entry.refs <= 0 {
delete(cancelMap, itemID)
}
}
cancelMu.Unlock()
downloadCancels.release(itemID)
}
func initExtensionRequestCancel(requestID string) context.Context {
if requestID == "" {
return context.Background()
}
return extensionRequestCancels.init(requestID)
}
extensionRequestCancelMu.Lock()
defer extensionRequestCancelMu.Unlock()
if entry, ok := extensionRequestCancelMap[requestID]; ok {
if entry.ctx == nil {
ctx, cancel := context.WithCancel(context.Background())
entry.ctx = ctx
entry.cancel = cancel
if entry.canceled && entry.cancel != nil {
entry.cancel()
}
}
entry.refs++
return entry.ctx
}
ctx, cancel := context.WithCancel(context.Background())
extensionRequestCancelMap[requestID] = &cancelEntry{
ctx: ctx,
cancel: cancel,
canceled: false,
refs: 1,
}
return ctx
func extensionRequestCancelContext(requestID string) context.Context {
return extensionRequestCancels.context(requestID)
}
func cancelExtensionRequest(requestID string) {
if requestID == "" {
return
}
extensionRequestCancelMu.Lock()
if entry, ok := extensionRequestCancelMap[requestID]; ok {
entry.canceled = true
if entry.cancel != nil {
entry.cancel()
}
} else {
extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true}
}
extensionRequestCancelMu.Unlock()
extensionRequestCancels.requestCancel(requestID)
}
func isExtensionRequestCancelled(requestID string) bool {
if requestID == "" {
return false
}
extensionRequestCancelMu.Lock()
entry, ok := extensionRequestCancelMap[requestID]
canceled := ok && entry.canceled
extensionRequestCancelMu.Unlock()
return canceled
return extensionRequestCancels.isCancelled(requestID)
}
func clearExtensionRequestCancel(requestID string) {
if requestID == "" {
return
}
extensionRequestCancelMu.Lock()
if entry, ok := extensionRequestCancelMap[requestID]; ok {
entry.refs--
if entry.refs <= 0 {
delete(extensionRequestCancelMap, requestID)
}
}
extensionRequestCancelMu.Unlock()
extensionRequestCancels.release(requestID)
}
+246 -40
View File
@@ -1,11 +1,17 @@
package gobackend
import (
"bytes"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
const (
@@ -14,8 +20,11 @@ const (
spotifySizeMax = "ab67616d000082c1"
)
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
// Square CDN covers using this path shape may return an image whose decoded
// dimensions differ from the dimensions advertised in the URL. Max-quality
// selection therefore probes both useful high-resolution variants and checks
// the image headers instead of trusting the filename.
var squareCoverSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
@@ -40,18 +49,237 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
GoLog("[Cover] Upgraded 300x300 → 640x640")
}
if maxQuality {
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
if !maxQuality {
GoLog("[Cover] Final URL: %s", downloadURL)
data, err := fetchCoverCached(downloadURL)
if err != nil {
return nil, err
}
return append([]byte(nil), data...), nil
}
GoLog("[Cover] Final URL: %s", downloadURL)
candidates := maxQualityCoverCandidateURLs(downloadURL)
data, selectedURL, width, height, err := fetchBestCoverCandidate(candidates)
if err != nil {
// A CDN can reject an upgraded size while the provider-supplied URL is
// still valid. Preserve that URL as the final fallback.
if len(candidates) == 1 && candidates[0] == downloadURL {
return nil, err
}
data, err = fetchCoverCached(downloadURL)
if err != nil {
return nil, err
}
selectedURL = downloadURL
width, height = coverDimensions(data)
}
GoLog("[Cover] Selected URL: %s (%dx%d, %d KB)", selectedURL, width, height, len(data)/1024)
// Cached bytes are shared across goroutines and must never be mutated;
// hand callers their own copy.
return append([]byte(nil), data...), nil
}
type fetchedCoverCandidate struct {
url string
data []byte
width, height int
err error
}
func maxQualityCoverCandidateURLs(coverURL string) []string {
upgraded := upgradeToMaxQuality(coverURL)
candidates := []string{upgraded}
// This is deliberately based on the URL capability rather than a provider
// or extension ID. Any metadata source returning the same square-cover URL
// shape receives the same verified candidate selection.
if squareCoverSizeRegex.MatchString(coverURL) {
candidate1500 := squareCoverSizeRegex.ReplaceAllString(
coverURL,
"/1500x1500-000000-80-0-0.jpg",
)
if candidate1500 != upgraded {
candidates = append(candidates, candidate1500)
}
}
return uniqueNonEmptyStrings(candidates)
}
func uniqueNonEmptyStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func fetchBestCoverCandidate(urls []string) ([]byte, string, int, int, error) {
if len(urls) == 0 {
return nil, "", 0, 0, fmt.Errorf("no cover candidates available")
}
results := make(chan fetchedCoverCandidate, len(urls))
for _, candidateURL := range urls {
go func(url string) {
data, err := fetchCoverCached(url)
width, height := coverDimensions(data)
results <- fetchedCoverCandidate{
url: url, data: data, width: width, height: height, err: err,
}
}(candidateURL)
}
var best *fetchedCoverCandidate
var firstErr error
for range urls {
candidate := <-results
if candidate.err != nil || len(candidate.data) == 0 {
if firstErr == nil {
firstErr = candidate.err
}
continue
}
if best == nil || coverCandidateBetter(candidate, *best) {
copy := candidate
best = &copy
}
}
if best == nil {
if firstErr == nil {
firstErr = fmt.Errorf("cover candidates returned no image data")
}
return nil, "", 0, 0, firstErr
}
return best.data, best.url, best.width, best.height, nil
}
func coverDimensions(data []byte) (int, int) {
if len(data) == 0 {
return 0, 0
}
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || config.Width <= 0 || config.Height <= 0 {
return 0, 0
}
return config.Width, config.Height
}
func coverCandidateBetter(candidate, current fetchedCoverCandidate) bool {
candidatePixels := int64(candidate.width) * int64(candidate.height)
currentPixels := int64(current.width) * int64(current.height)
if candidatePixels != currentPixels {
return candidatePixels > currentPixels
}
return len(candidate.data) > len(current.data)
}
const (
coverCacheMaxBytes = 24 * 1024 * 1024
coverCacheTTL = 15 * time.Minute
)
type coverCacheEntry struct {
data []byte
expiresAt time.Time
}
type coverInflightCall struct {
wg sync.WaitGroup
data []byte
err error
}
var (
coverMu sync.Mutex
coverCache = map[string]*coverCacheEntry{}
coverCacheBytes int
coverInflight = map[string]*coverInflightCall{}
coverFetch = fetchCoverBytes
)
func clearCoverMemoryCache() {
coverMu.Lock()
coverCache = map[string]*coverCacheEntry{}
coverCacheBytes = 0
coverMu.Unlock()
}
// fetchCoverCached returns cover bytes for a final URL, collapsing concurrent
// requests for the same URL into a single fetch (singleflight) and caching
// results in memory for the duration of an album batch. The returned slice is
// shared; callers must copy before mutating.
func fetchCoverCached(downloadURL string) ([]byte, error) {
coverMu.Lock()
if e, ok := coverCache[downloadURL]; ok {
if time.Now().Before(e.expiresAt) {
data := e.data
coverMu.Unlock()
return data, nil
}
delete(coverCache, downloadURL)
coverCacheBytes -= len(e.data)
}
if call, ok := coverInflight[downloadURL]; ok {
coverMu.Unlock()
call.wg.Wait()
return call.data, call.err
}
call := &coverInflightCall{}
// Default error so a panicking fetch never strands waiters with a
// (nil, nil) "success"; overwritten on normal completion.
call.err = fmt.Errorf("cover fetch aborted")
call.wg.Add(1)
coverInflight[downloadURL] = call
coverMu.Unlock()
defer func() {
call.wg.Done()
coverMu.Lock()
delete(coverInflight, downloadURL)
coverMu.Unlock()
}()
data, err := coverFetch(downloadURL)
call.data, call.err = data, err
if err == nil {
coverCachePut(downloadURL, data)
}
return data, err
}
func coverCachePut(downloadURL string, data []byte) {
if len(data) == 0 || len(data) > coverCacheMaxBytes {
return
}
coverMu.Lock()
defer coverMu.Unlock()
if e, ok := coverCache[downloadURL]; ok {
coverCacheBytes -= len(e.data)
}
coverCache[downloadURL] = &coverCacheEntry{data: data, expiresAt: time.Now().Add(coverCacheTTL)}
coverCacheBytes += len(data)
for coverCacheBytes > coverCacheMaxBytes && len(coverCache) > 1 {
var oldestKey string
var oldest time.Time
first := true
for k, e := range coverCache {
if first || e.expiresAt.Before(oldest) {
oldest, oldestKey, first = e.expiresAt, k, false
}
}
coverCacheBytes -= len(coverCache[oldestKey].data)
delete(coverCache, oldestKey)
}
}
func fetchCoverBytes(downloadURL string) ([]byte, error) {
client := NewHTTPClientWithTimeout(DefaultTimeout)
req, err := http.NewRequest("GET", downloadURL, nil)
@@ -74,16 +302,8 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("failed to read cover data: %w", err)
}
sizeKB := len(data) / 1024
var resolution string
if sizeKB > 200 {
resolution = "~2000x2000 (hi-res)"
} else if sizeKB > 50 {
resolution = "~640x640"
} else {
resolution = "~300x300"
}
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
width, height := coverDimensions(data)
GoLog("[Cover] Downloaded %d KB (%dx%d)", len(data)/1024, width, height)
return data, nil
}
@@ -93,8 +313,8 @@ func upgradeToMaxQuality(coverURL string) string {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
if squareCoverSizeRegex.MatchString(coverURL) {
return upgradeSquareCover(coverURL)
}
if strings.Contains(coverURL, "resources.tidal.com") {
@@ -108,14 +328,14 @@ func upgradeToMaxQuality(coverURL string) string {
return coverURL
}
func upgradeDeezerCover(coverURL string) string {
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
func upgradeSquareCover(coverURL string) string {
if !squareCoverSizeRegex.MatchString(coverURL) {
return coverURL
}
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
upgraded := squareCoverSizeRegex.ReplaceAllString(coverURL, "/1900x1900-000000-80-0-0.jpg")
if upgraded != coverURL {
GoLog("[Cover] Deezer: upgraded to 1800x1800")
GoLog("[Cover] Square CDN: probing 1900x1900 and 1500x1500")
}
return upgraded
}
@@ -143,17 +363,3 @@ func upgradeQobuzCover(coverURL string) string {
}
return upgraded
}
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
return ""
}
result := convertSmallToMedium(imageURL)
if maxQuality {
result = upgradeToMaxQuality(result)
}
return result
}
+187
View File
@@ -0,0 +1,187 @@
package gobackend
import (
"bytes"
"errors"
"image"
"image/color"
"image/png"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func testPNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 100, A: 255})
}
}
var buffer bytes.Buffer
if err := png.Encode(&buffer, img); err != nil {
t.Fatalf("encode test cover: %v", err)
}
return buffer.Bytes()
}
func resetCoverCache() {
coverMu.Lock()
coverCache = map[string]*coverCacheEntry{}
coverInflight = map[string]*coverInflightCall{}
coverCacheBytes = 0
coverMu.Unlock()
}
func TestFetchCoverCachedSingleflight(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
var calls int32
entered := make(chan struct{})
release := make(chan struct{})
coverFetch = func(string) ([]byte, error) {
if atomic.AddInt32(&calls, 1) == 1 {
close(entered)
}
<-release
return []byte("coverbytes"), nil
}
const url = "https://cdn.example/cover_max.jpg"
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if _, err := fetchCoverCached(url); err != nil {
t.Errorf("leader fetch error: %v", err)
}
}()
<-entered // leader has registered inflight and is blocked in coverFetch
wg.Add(1)
go func() {
defer wg.Done()
if _, err := fetchCoverCached(url); err != nil {
t.Errorf("follower fetch error: %v", err)
}
}()
close(release)
wg.Wait()
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected 1 fetch for concurrent requests, got %d", got)
}
}
func TestFetchCoverCachedTTLExpiry(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
var calls int32
coverFetch = func(string) ([]byte, error) {
atomic.AddInt32(&calls, 1)
return []byte("data"), nil
}
const url = "https://cdn.example/ttl.jpg"
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("first fetch error: %v", err)
}
// second call served from cache
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("second fetch error: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected cache hit, got %d fetches", got)
}
// expire the entry and confirm a refetch
coverMu.Lock()
coverCache[url].expiresAt = time.Now().Add(-time.Minute)
coverMu.Unlock()
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("third fetch error: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("expected refetch after TTL expiry, got %d fetches", got)
}
}
func TestMaxQualityCoverCandidatesProbe1900And1500(t *testing.T) {
url := "https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg"
candidates := maxQualityCoverCandidateURLs(url)
if len(candidates) != 2 {
t.Fatalf("expected two candidates, got %#v", candidates)
}
if !strings.Contains(candidates[0], "1900x1900") {
t.Fatalf("first candidate = %q", candidates[0])
}
if !strings.Contains(candidates[1], "1500x1500") {
t.Fatalf("second candidate = %q", candidates[1])
}
}
func TestDownloadCoverSelectsDecodedDimensionsBeforeByteSize(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
advertised1900ButSmaller := append(testPNG(t, 12, 12), bytes.Repeat([]byte{0}, 4096)...)
actualLargerDimensions := testPNG(t, 15, 15)
coverFetch = func(url string) ([]byte, error) {
switch {
case strings.Contains(url, "1900x1900"):
return advertised1900ButSmaller, nil
case strings.Contains(url, "1500x1500"):
return actualLargerDimensions, nil
default:
return nil, errors.New("unexpected URL")
}
}
url := "https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg"
got, err := downloadCoverToMemory(url, true)
if err != nil {
t.Fatalf("download max cover: %v", err)
}
if !bytes.Equal(got, actualLargerDimensions) {
t.Fatal("expected decoded 15x15 candidate instead of larger-byte 12x12 candidate")
}
}
func TestBestCoverCandidateUsesByteSizeAsDimensionTiebreaker(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
base := testPNG(t, 10, 10)
largerFile := append(append([]byte(nil), base...), bytes.Repeat([]byte{0}, 512)...)
coverFetch = func(url string) ([]byte, error) {
if strings.Contains(url, "large") {
return largerFile, nil
}
return base, nil
}
got, selected, width, height, err := fetchBestCoverCandidate(
[]string{"https://covers.test/small", "https://covers.test/large"},
)
if err != nil {
t.Fatalf("select cover: %v", err)
}
if selected != "https://covers.test/large" || width != 10 || height != 10 {
t.Fatalf("selected=%q dimensions=%dx%d", selected, width, height)
}
if !bytes.Equal(got, largerFile) {
t.Fatal("expected larger file when decoded dimensions are equal")
}
}
+2 -1
View File
@@ -73,7 +73,8 @@ function track(id) {
genre: "Pop",
composer: "Composer",
audioQuality: "FLAC 24-bit",
audioModes: "DOLBY_ATMOS"
audioModes: "DOLBY_ATMOS",
explicit: true
};
}
+4 -14
View File
@@ -241,7 +241,7 @@ func searchCollectionCandidates(provider *extensionProviderWrapper, itemType str
}
if filter != "" {
tracks, err := provider.CustomSearch(query, map[string]interface{}{
tracks, err := provider.CustomSearch(query, map[string]any{
"filter": filter,
"limit": 10,
})
@@ -333,7 +333,7 @@ func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *Ext
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
return url
}
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
if url := templateShareURL(ext, "album", firstNonEmptyTrimmed(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
return url
}
return ""
@@ -350,7 +350,7 @@ func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *Ext
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
return url
}
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
if url := templateShareURL(ext, "artist", firstNonEmptyTrimmed(track.ArtistID, collectionID(*track, "artist"))); url != "" {
return url
}
return ""
@@ -409,7 +409,7 @@ func templateShareURL(ext *loadedExtension, itemType string, id string) string {
return ""
}
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]any)
if !ok {
return ""
}
@@ -430,13 +430,3 @@ func stripProviderPrefix(id string) string {
}
return id
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
+4 -4
View File
@@ -5,8 +5,8 @@ import "testing"
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
Capabilities: map[string]any{
"shareUrlTemplates": map[string]any{
"album": "https://music.apple.com/us/album/{id}",
},
},
@@ -33,8 +33,8 @@ func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
Capabilities: map[string]any{
"shareUrlTemplates": map[string]any{
"artist": "https://music.youtube.com/browse/{id}",
},
},
+131 -1
View File
@@ -117,6 +117,135 @@ func TestCueParserEndToEnd(t *testing.T) {
}
}
func writeTestFlacWithISRC(t *testing.T, path, isrc string) {
t.Helper()
le32 := func(v int) []byte {
return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)}
}
vendor := "test-vendor"
comments := [][]byte{
[]byte("TITLE=Song"),
[]byte("ISRC=" + isrc),
}
var vorbis []byte
vorbis = append(vorbis, le32(len(vendor))...)
vorbis = append(vorbis, vendor...)
vorbis = append(vorbis, le32(len(comments))...)
for _, comment := range comments {
vorbis = append(vorbis, le32(len(comment))...)
vorbis = append(vorbis, comment...)
}
blockHeader := func(last bool, blockType byte, length int) []byte {
first := blockType
if last {
first |= 0x80
}
return []byte{first, byte(length >> 16), byte(length >> 8), byte(length)}
}
var data []byte
data = append(data, "fLaC"...)
data = append(data, blockHeader(false, 0, 34)...) // STREAMINFO
data = append(data, make([]byte, 34)...)
picture := make([]byte, 4096) // stands in for embedded cover art
data = append(data, blockHeader(false, 6, len(picture))...)
data = append(data, picture...)
data = append(data, blockHeader(true, 4, len(vorbis))...)
data = append(data, vorbis...)
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
}
func TestISRCIndexCoversNonFlacFormats(t *testing.T) {
dir := t.TempDir()
flacPath := filepath.Join(dir, "a.flac")
mp3Path := filepath.Join(dir, "b.mp3")
writeTestFlacWithISRC(t, flacPath, "USAA00000011")
mp3Data := buildID3v23Tag(
id3TextFrame("TIT2", "Song"),
id3TextFrame("TSRC", "usbb00000022"),
)
if err := os.WriteFile(mp3Path, mp3Data, 0600); err != nil {
t.Fatal(err)
}
// Unsupported/untagged formats must stay invisible to the index.
if err := os.WriteFile(filepath.Join(dir, "c.wav"), []byte("RIFF"), 0600); err != nil {
t.Fatal(err)
}
defer InvalidateISRCCache(dir)
if got := readFileISRC(mp3Path); got != "usbb00000022" {
t.Fatalf("readFileISRC mp3 = %q", got)
}
if got := readFileISRC(filepath.Join(dir, "c.wav")); got != "" {
t.Fatalf("readFileISRC wav = %q", got)
}
idx := buildISRCIndex(dir)
if path, ok := idx.lookup("USAA00000011"); !ok || path != flacPath {
t.Fatalf("flac lookup = %q/%v", path, ok)
}
if path, ok := idx.lookup("USBB00000022"); !ok || path != mp3Path {
t.Fatalf("mp3 lookup = %q/%v", path, ok)
}
}
func TestISRCIndexIncrementalRebuild(t *testing.T) {
dir := t.TempDir()
trackA := filepath.Join(dir, "a.flac")
trackB := filepath.Join(dir, "b.flac")
writeTestFlacWithISRC(t, trackA, "USAA00000001")
writeTestFlacWithISRC(t, trackB, "USBB00000002")
defer InvalidateISRCCache(dir)
if got := readFlacISRC(trackA); got != "USAA00000001" {
t.Fatalf("readFlacISRC = %q", got)
}
if got := readFlacISRC(filepath.Join(dir, "missing.flac")); got != "" {
t.Fatalf("expected empty ISRC for missing file, got %q", got)
}
idx := buildISRCIndex(dir)
if path, ok := idx.lookup("usaa00000001"); !ok || path != trackA {
t.Fatalf("lookup A = %q/%v", path, ok)
}
if path, ok := idx.lookup("USBB00000002"); !ok || path != trackB {
t.Fatalf("lookup B = %q/%v", path, ok)
}
// Change one file's tag (and mtime); an incremental rebuild must pick up
// the change while adopting the untouched file from the cache.
writeTestFlacWithISRC(t, trackB, "USBB00000099")
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(trackB, future, future); err != nil {
t.Fatal(err)
}
rebuilt := buildISRCIndex(dir)
if _, ok := rebuilt.lookup("USBB00000002"); ok {
t.Fatal("expected stale ISRC to disappear after rebuild")
}
if path, ok := rebuilt.lookup("USBB00000099"); !ok || path != trackB {
t.Fatalf("rebuilt lookup = %q/%v", path, ok)
}
if path, ok := rebuilt.lookup("USAA00000001"); !ok || path != trackA {
t.Fatalf("cached entry lost on rebuild: %q/%v", path, ok)
}
// Add() keeps the index fresh so the TTL never forces a rebuild while
// downloads are actively maintaining it.
rebuilt.buildTime.Store(time.Now().Add(-isrcIndexTTL - time.Minute).UnixNano())
if rebuilt.isFresh() {
t.Fatal("expected stale index")
}
rebuilt.Add("USCC00000003", trackA)
if !rebuilt.isFresh() {
t.Fatal("expected Add to refresh the index timestamp")
}
}
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "song.flac")
@@ -124,7 +253,8 @@ func TestDuplicateIndexAndParallelExistence(t *testing.T) {
t.Fatal(err)
}
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir, buildTime: time.Now()}
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir}
idx.buildTime.Store(time.Now().UnixNano())
idx.Add("usrc17607839", filePath)
if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath {
t.Fatalf("lookup = %q/%v", got, ok)
+18 -718
View File
@@ -14,13 +14,12 @@ import (
)
const (
deezerBaseURL = "https://api.deezer.com/2.0"
deezerSearchURL = deezerBaseURL + "/search"
deezerTrackURL = deezerBaseURL + "/track/%s"
deezerAlbumURL = deezerBaseURL + "/album/%s"
deezerArtistURL = deezerBaseURL + "/artist/%s"
deezerArtistRelatedURL = deezerBaseURL + "/artist/%s/related"
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerBaseURL = "https://api.deezer.com/2.0"
deezerSearchURL = deezerBaseURL + "/search"
deezerTrackURL = deezerBaseURL + "/track/%s"
deezerAlbumURL = deezerBaseURL + "/album/%s"
deezerArtistURL = deezerBaseURL + "/artist/%s"
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerCacheTTL = 10 * time.Minute
@@ -68,240 +67,6 @@ func GetDeezerClient() *DeezerClient {
return deezerClient
}
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
cache map[string]*cacheEntry,
now time.Time,
) {
for key, entry := range cache {
if entry == nil || now.After(entry.expiresAt) {
delete(cache, key)
}
}
}
func (c *DeezerClient) trimCacheEntriesLocked(
cache map[string]*cacheEntry,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
for len(cache) > maxEntries {
var oldestKey string
var oldestExpiry time.Time
first := true
for key, entry := range cache {
expiry := time.Time{}
if entry != nil {
expiry = entry.expiresAt
}
if first || expiry.Before(oldestExpiry) {
first = false
oldestKey = key
oldestExpiry = expiry
}
}
if oldestKey == "" {
return
}
delete(cache, oldestKey)
}
}
func (c *DeezerClient) trimStringCacheEntriesLocked(
cache map[string]string,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
toRemove := len(cache) - maxEntries
for key := range cache {
delete(cache, key)
toRemove--
if toRemove <= 0 {
return
}
}
}
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
(c.lastCacheCleanup.IsZero() ||
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
if periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
c.lastCacheCleanup = now
}
if len(c.searchCache) > deezerMaxSearchCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
}
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
}
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
}
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
}
if len(c.artistCache) > deezerMaxArtistCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
}
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
}
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
}
}
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
Duration int `json:"duration"`
TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
Link string `json:"link"`
ReleaseDate string `json:"release_date"`
Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"`
}
type deezerArtist struct {
ID int64 `json:"id"`
Name string `json:"name"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbFan int `json:"nb_fan"`
}
type deezerAlbumSimple struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"`
RecordType string `json:"record_type"`
}
// deezerTrackArtistDisplay returns the display artist string for a track,
// preferring the Contributors list (comma-joined) when available, falling
// back to the primary Artist.Name.
func deezerTrackArtistDisplay(track deezerTrack) string {
if len(track.Contributors) > 0 {
names := make([]string, len(track.Contributors))
for i, a := range track.Contributors {
names[i] = a.Name
}
return strings.Join(names, ", ")
}
return track.Artist.Name
}
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
artistName := deezerTrackArtistDisplay(track)
albumImage := track.Album.CoverXL
if albumImage == "" {
albumImage = track.Album.CoverBig
}
if albumImage == "" {
albumImage = track.Album.CoverMedium
}
if albumImage == "" {
albumImage = track.Album.Cover
}
releaseDate := track.ReleaseDate
if releaseDate == "" {
releaseDate = track.Album.ReleaseDate
}
return TrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: artistName,
Name: track.Title,
AlbumName: track.Album.Title,
AlbumArtist: track.Artist.Name,
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: releaseDate,
TrackNumber: track.TrackPosition,
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
ISRC: track.ISRC,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
}
}
type deezerGenre struct {
ID int `json:"id"`
Name string `json:"name"`
}
type deezerAlbumFull struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
Artist deezerArtist `json:"artist"`
Contributors []deezerArtist `json:"contributors"`
Tracks struct {
Data []deezerTrack `json:"data"`
} `json:"tracks"`
}
type deezerArtistFull struct {
ID int64 `json:"id"`
Name string `json:"name"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbFan int `json:"nb_fan"`
NbAlbum int `json:"nb_album"`
}
type deezerPlaylistFull struct {
ID int64 `json:"id"`
Title string `json:"title"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbTracks int `json:"nb_tracks"`
Creator struct {
Name string `json:"name"`
} `json:"creator"`
Tracks struct {
Data []deezerTrack `json:"data"`
} `json:"tracks"`
}
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
@@ -553,441 +318,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
}, nil
}
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*AlbumResponsePayload), nil
}
c.cacheMu.RUnlock()
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
var album deezerAlbumFull
if err := c.getJSON(ctx, albumURL, &album); err != nil {
return nil, err
}
albumImage := c.getBestAlbumImage(album)
artistName := album.Artist.Name
if len(album.Contributors) > 0 {
names := make([]string, len(album.Contributors))
for i, a := range album.Contributors {
names[i] = a.Name
}
artistName = strings.Join(names, ", ")
}
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
genres = append(genres, g.Name)
}
}
genreStr := strings.Join(genres, ", ")
info := AlbumInfoMetadata{
TotalTracks: album.NbTracks,
Name: album.Title,
ReleaseDate: album.ReleaseDate,
Artists: artistName,
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage,
Genre: genreStr,
Label: album.Label,
}
allTracks := album.Tracks.Data
if album.NbTracks > len(allTracks) {
GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerAlbumURL, albumID), len(allTracks))
for len(allTracks) < album.NbTracks {
var tracksResp struct {
Data []deezerTrack `json:"data"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
GoLog("[Deezer] Warning: failed to fetch album tracks page: %v", err)
break
}
if len(tracksResp.Data) == 0 {
break
}
allTracks = append(allTracks, tracksResp.Data...)
if tracksResp.Next == "" {
break
}
tracksURL = tracksResp.Next
}
GoLog("[Deezer] Fetched total %d tracks for album", len(allTracks))
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
totalDiscs := 0
for _, track := range allTracks {
if track.DiskNumber > totalDiscs {
totalDiscs = track.DiskNumber
}
}
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
}
for i, track := range allTracks {
trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr]
trackNum := track.TrackPosition
if trackNum == 0 {
trackNum = i + 1
}
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: deezerTrackArtistDisplay(track),
Name: track.Title,
AlbumName: album.Title,
AlbumArtist: artistName,
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: album.ReleaseDate,
TrackNumber: trackNum,
TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber,
TotalDiscs: totalDiscs,
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
AlbumType: albumType,
})
}
result := &AlbumResponsePayload{
AlbumInfo: info,
TrackList: tracks,
}
c.cacheMu.Lock()
now := time.Now()
c.albumCache[albumID] = &cacheEntry{
data: result,
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
}
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*ArtistResponsePayload), nil
}
c.cacheMu.RUnlock()
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
var artist deezerArtistFull
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
return nil, err
}
artistInfo := ArtistInfoMetadata{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: c.getBestArtistImageFull(artist),
Followers: artist.NbFan,
Popularity: 0,
}
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
var albumsResp struct {
Data []struct {
ID int64 `json:"id"`
Title string `json:"title"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
RecordType string `json:"record_type"`
} `json:"data"`
}
albums := make([]ArtistAlbumMetadata, 0)
if err := c.getJSON(ctx, albumsURL, &albumsResp); err == nil {
for _, album := range albumsResp.Data {
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
}
coverURL := album.CoverXL
if coverURL == "" {
coverURL = album.CoverBig
}
if coverURL == "" {
coverURL = album.CoverMedium
}
if coverURL == "" {
coverURL = album.Cover
}
albums = append(albums, ArtistAlbumMetadata{
ID: fmt.Sprintf("deezer:%d", album.ID),
Name: album.Title,
ReleaseDate: album.ReleaseDate,
TotalTracks: album.NbTracks,
Images: coverURL,
AlbumType: albumType,
Artists: artist.Name,
})
}
// The Deezer /artist/{id}/albums endpoint does not return nb_tracks.
// Fetch track counts in parallel from individual /album/{id} endpoints.
c.fetchAlbumTrackCounts(ctx, albums)
}
result := &ArtistResponsePayload{
ArtistInfo: artistInfo,
Albums: albums,
}
c.cacheMu.Lock()
now := time.Now()
c.artistCache[artistID] = &cacheEntry{
data: result,
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
}
// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using
// individual /album/{id} calls, since the /artist/{id}/albums endpoint does
// not include this field. Albums whose track count is already known (non-zero)
// are skipped.
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
type indexedID struct {
idx int
albumID string
}
var toFetch []indexedID
for i, a := range albums {
if a.TotalTracks == 0 {
rawID := strings.TrimPrefix(a.ID, "deezer:")
if rawID != "" {
toFetch = append(toFetch, indexedID{idx: i, albumID: rawID})
}
}
}
if len(toFetch) == 0 {
return
}
const maxParallel = 10
sem := make(chan struct{}, maxParallel)
var mu sync.Mutex
var wg sync.WaitGroup
for _, item := range toFetch {
wg.Add(1)
go func(it indexedID) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID)
var resp struct {
NbTracks int `json:"nb_tracks"`
}
if err := c.getJSON(ctx, albumURL, &resp); err != nil {
return
}
mu.Lock()
albums[it.idx].TotalTracks = resp.NbTracks
mu.Unlock()
}(item)
}
wg.Wait()
}
func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:"))
if normalizedArtistID == "" {
return nil, fmt.Errorf("invalid Deezer artist ID")
}
effectiveLimit := limit
if effectiveLimit <= 0 {
effectiveLimit = 12
}
relatedURL := fmt.Sprintf("%s?limit=%d", fmt.Sprintf(deezerArtistRelatedURL, normalizedArtistID), effectiveLimit)
var relatedResp struct {
Data []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbFan int `json:"nb_fan"`
} `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error,omitempty"`
}
if err := c.getJSON(ctx, relatedURL, &relatedResp); err != nil {
return nil, err
}
if relatedResp.Error != nil {
return nil, fmt.Errorf("deezer related artists error: %s", relatedResp.Error.Message)
}
result := make([]SearchArtistResult, 0, len(relatedResp.Data))
for _, artist := range relatedResp.Data {
imageURL := artist.PictureXL
if imageURL == "" {
imageURL = artist.PictureBig
}
if imageURL == "" {
imageURL = artist.PictureMedium
}
if imageURL == "" {
imageURL = artist.Picture
}
result = append(result, SearchArtistResult{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: imageURL,
Followers: artist.NbFan,
Popularity: 0,
})
}
return result, nil
}
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
var playlist deezerPlaylistFull
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
return nil, err
}
playlistImage := playlist.PictureXL
if playlistImage == "" {
playlistImage = playlist.PictureBig
}
if playlistImage == "" {
playlistImage = playlist.PictureMedium
}
var info PlaylistInfoMetadata
info.Tracks.Total = playlist.NbTracks
info.Owner.DisplayName = playlist.Creator.Name
info.Owner.Name = playlist.Title
info.Owner.Images = playlistImage
allTracks := playlist.Tracks.Data
if playlist.NbTracks > len(allTracks) {
GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerPlaylistURL, playlistID), len(allTracks))
for len(allTracks) < playlist.NbTracks {
var tracksResp struct {
Data []deezerTrack `json:"data"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
GoLog("[Deezer] Warning: failed to fetch playlist tracks page: %v", err)
break
}
if len(tracksResp.Data) == 0 {
break
}
allTracks = append(allTracks, tracksResp.Data...)
if tracksResp.Next == "" {
break
}
tracksURL = tracksResp.Next
}
GoLog("[Deezer] Fetched total %d tracks for playlist", len(allTracks))
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
for _, track := range allTracks {
albumImage := track.Album.CoverXL
if albumImage == "" {
albumImage = track.Album.CoverBig
}
if albumImage == "" {
albumImage = track.Album.CoverMedium
}
trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr]
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: deezerTrackArtistDisplay(track),
Name: track.Title,
AlbumName: track.Album.Title,
AlbumArtist: track.Artist.Name,
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: "",
TrackNumber: track.TrackPosition,
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
})
}
return &PlaylistResponsePayload{
PlaylistInfo: info,
TrackList: tracks,
}, nil
}
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
@@ -1120,45 +450,6 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
return fullTrack.ISRC, nil
}
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
if artist.PictureXL != "" {
return artist.PictureXL
}
if artist.PictureBig != "" {
return artist.PictureBig
}
if artist.PictureMedium != "" {
return artist.PictureMedium
}
return artist.Picture
}
func (c *DeezerClient) getBestArtistImageFull(artist deezerArtistFull) string {
if artist.PictureXL != "" {
return artist.PictureXL
}
if artist.PictureBig != "" {
return artist.PictureBig
}
if artist.PictureMedium != "" {
return artist.PictureMedium
}
return artist.Picture
}
func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
if album.CoverXL != "" {
return album.CoverXL
}
if album.CoverBig != "" {
return album.CoverBig
}
if album.CoverMedium != "" {
return album.CoverMedium
}
return album.Cover
}
type AlbumExtendedMetadata struct {
Genre string
Label string
@@ -1251,12 +542,16 @@ func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc strin
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
}
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst any) error {
var lastErr error
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if attempt > 0 {
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
var apiErr *deezerAPIError
if errors.As(lastErr, &apiErr) && apiErr.RetryAfter > 0 {
delay = apiErr.RetryAfter
}
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay)
}
@@ -1280,6 +575,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
type deezerAPIError struct {
StatusCode int
Body string
RetryAfter time.Duration
}
func (e *deezerAPIError) Error() string {
@@ -1297,13 +593,17 @@ func isDeezerRetryableError(err error) bool {
return false
}
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
// Without an explicit language Deezer localizes artist/genre names by
// the caller's IP geolocation (issue #480: Arabic metadata on an
// English device). Follow the app's display language instead.
req.Header.Set("Accept-Language", metadataAcceptLanguage())
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -1317,7 +617,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
}
if resp.StatusCode != http.StatusOK {
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)}
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: getRetryAfterDuration(resp)}
}
return json.Unmarshal(body, dst)
+386
View File
@@ -0,0 +1,386 @@
package gobackend
import (
"context"
"fmt"
"strings"
"sync"
"time"
)
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*AlbumResponsePayload), nil
}
c.cacheMu.RUnlock()
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
var album deezerAlbumFull
if err := c.getJSON(ctx, albumURL, &album); err != nil {
return nil, err
}
albumImage := c.getBestAlbumImage(album)
artistName := album.Artist.Name
if len(album.Contributors) > 0 {
names := make([]string, len(album.Contributors))
for i, a := range album.Contributors {
names[i] = a.Name
}
artistName = strings.Join(names, ", ")
}
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
genres = append(genres, g.Name)
}
}
genreStr := strings.Join(genres, ", ")
info := AlbumInfoMetadata{
TotalTracks: album.NbTracks,
Name: album.Title,
ReleaseDate: album.ReleaseDate,
Artists: artistName,
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage,
Genre: genreStr,
Label: album.Label,
}
allTracks := album.Tracks.Data
if album.NbTracks > len(allTracks) {
GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerAlbumURL, albumID), len(allTracks))
for len(allTracks) < album.NbTracks {
var tracksResp struct {
Data []deezerTrack `json:"data"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
GoLog("[Deezer] Warning: failed to fetch album tracks page: %v", err)
break
}
if len(tracksResp.Data) == 0 {
break
}
allTracks = append(allTracks, tracksResp.Data...)
if tracksResp.Next == "" {
break
}
tracksURL = tracksResp.Next
}
GoLog("[Deezer] Fetched total %d tracks for album", len(allTracks))
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
totalDiscs := 0
for _, track := range allTracks {
if track.DiskNumber > totalDiscs {
totalDiscs = track.DiskNumber
}
}
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
}
for i, track := range allTracks {
trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr]
trackNum := track.TrackPosition
if trackNum == 0 {
trackNum = i + 1
}
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: deezerTrackArtistDisplay(track),
Name: track.Title,
AlbumName: album.Title,
AlbumArtist: artistName,
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: album.ReleaseDate,
TrackNumber: trackNum,
TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber,
TotalDiscs: totalDiscs,
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
AlbumType: albumType,
Explicit: deezerTrackIsExplicit(track),
})
}
result := &AlbumResponsePayload{
AlbumInfo: info,
TrackList: tracks,
}
c.cacheMu.Lock()
now := time.Now()
c.albumCache[albumID] = &cacheEntry{
data: result,
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
}
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*ArtistResponsePayload), nil
}
c.cacheMu.RUnlock()
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
var artist deezerArtistFull
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
return nil, err
}
artistInfo := ArtistInfoMetadata{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: c.getBestArtistImageFull(artist),
Followers: artist.NbFan,
Popularity: 0,
}
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
var albumsResp struct {
Data []struct {
ID int64 `json:"id"`
Title string `json:"title"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
RecordType string `json:"record_type"`
} `json:"data"`
}
albums := make([]ArtistAlbumMetadata, 0)
if err := c.getJSON(ctx, albumsURL, &albumsResp); err == nil {
for _, album := range albumsResp.Data {
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
}
coverURL := album.CoverXL
if coverURL == "" {
coverURL = album.CoverBig
}
if coverURL == "" {
coverURL = album.CoverMedium
}
if coverURL == "" {
coverURL = album.Cover
}
albums = append(albums, ArtistAlbumMetadata{
ID: fmt.Sprintf("deezer:%d", album.ID),
Name: album.Title,
ReleaseDate: album.ReleaseDate,
TotalTracks: album.NbTracks,
Images: coverURL,
AlbumType: albumType,
Artists: artist.Name,
})
}
// The Deezer /artist/{id}/albums endpoint does not return nb_tracks.
// Fetch track counts in parallel from individual /album/{id} endpoints.
c.fetchAlbumTrackCounts(ctx, albums)
}
result := &ArtistResponsePayload{
ArtistInfo: artistInfo,
Albums: albums,
}
c.cacheMu.Lock()
now := time.Now()
c.artistCache[artistID] = &cacheEntry{
data: result,
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
}
// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using
// individual /album/{id} calls, since the /artist/{id}/albums endpoint does
// not include this field. Albums whose track count is already known (non-zero)
// are skipped.
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
type indexedID struct {
idx int
albumID string
}
var toFetch []indexedID
for i, a := range albums {
if a.TotalTracks == 0 {
rawID := strings.TrimPrefix(a.ID, "deezer:")
if rawID != "" {
toFetch = append(toFetch, indexedID{idx: i, albumID: rawID})
}
}
}
if len(toFetch) == 0 {
return
}
const maxParallel = 10
sem := make(chan struct{}, maxParallel)
var mu sync.Mutex
var wg sync.WaitGroup
for _, item := range toFetch {
wg.Add(1)
go func(it indexedID) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID)
var resp struct {
NbTracks int `json:"nb_tracks"`
}
if err := c.getJSON(ctx, albumURL, &resp); err != nil {
return
}
mu.Lock()
albums[it.idx].TotalTracks = resp.NbTracks
mu.Unlock()
}(item)
}
wg.Wait()
}
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
var playlist deezerPlaylistFull
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
return nil, err
}
playlistImage := playlist.PictureXL
if playlistImage == "" {
playlistImage = playlist.PictureBig
}
if playlistImage == "" {
playlistImage = playlist.PictureMedium
}
var info PlaylistInfoMetadata
info.Tracks.Total = playlist.NbTracks
info.Owner.DisplayName = playlist.Creator.Name
info.Owner.Name = playlist.Title
info.Owner.Images = playlistImage
allTracks := playlist.Tracks.Data
if playlist.NbTracks > len(allTracks) {
GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerPlaylistURL, playlistID), len(allTracks))
for len(allTracks) < playlist.NbTracks {
var tracksResp struct {
Data []deezerTrack `json:"data"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
GoLog("[Deezer] Warning: failed to fetch playlist tracks page: %v", err)
break
}
if len(tracksResp.Data) == 0 {
break
}
allTracks = append(allTracks, tracksResp.Data...)
if tracksResp.Next == "" {
break
}
tracksURL = tracksResp.Next
}
GoLog("[Deezer] Fetched total %d tracks for playlist", len(allTracks))
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
for _, track := range allTracks {
albumImage := track.Album.CoverXL
if albumImage == "" {
albumImage = track.Album.CoverBig
}
if albumImage == "" {
albumImage = track.Album.CoverMedium
}
trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr]
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: deezerTrackArtistDisplay(track),
Name: track.Title,
AlbumName: track.Album.Title,
AlbumArtist: track.Artist.Name,
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: "",
TrackNumber: track.TrackPosition,
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
Explicit: deezerTrackIsExplicit(track),
})
}
return &PlaylistResponsePayload{
PlaylistInfo: info,
TrackList: tracks,
}, nil
}
+97
View File
@@ -0,0 +1,97 @@
package gobackend
import "time"
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
cache map[string]*cacheEntry,
now time.Time,
) {
for key, entry := range cache {
if entry == nil || now.After(entry.expiresAt) {
delete(cache, key)
}
}
}
func (c *DeezerClient) trimCacheEntriesLocked(
cache map[string]*cacheEntry,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
for len(cache) > maxEntries {
var oldestKey string
var oldestExpiry time.Time
first := true
for key, entry := range cache {
expiry := time.Time{}
if entry != nil {
expiry = entry.expiresAt
}
if first || expiry.Before(oldestExpiry) {
first = false
oldestKey = key
oldestExpiry = expiry
}
}
if oldestKey == "" {
return
}
delete(cache, oldestKey)
}
}
func (c *DeezerClient) trimStringCacheEntriesLocked(
cache map[string]string,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
toRemove := len(cache) - maxEntries
for key := range cache {
delete(cache, key)
toRemove--
if toRemove <= 0 {
return
}
}
}
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
(c.lastCacheCleanup.IsZero() ||
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
if periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
c.lastCacheCleanup = now
}
if len(c.searchCache) > deezerMaxSearchCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
}
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
}
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
}
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
}
if len(c.artistCache) > deezerMaxArtistCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
}
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
}
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
}
}
+195
View File
@@ -0,0 +1,195 @@
package gobackend
import (
"fmt"
"strings"
)
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
Duration int `json:"duration"`
TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
Link string `json:"link"`
ReleaseDate string `json:"release_date"`
ExplicitLyrics bool `json:"explicit_lyrics"`
ExplicitContentLyrics int `json:"explicit_content_lyrics"`
Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"`
}
// deezerTrackIsExplicit maps Deezer's parental-advisory fields to a boolean:
// explicit_lyrics is the boolean flag, explicit_content_lyrics uses 1 to mean
// explicit (0 = clean, 2 = unknown).
func deezerTrackIsExplicit(track deezerTrack) bool {
return track.ExplicitLyrics || track.ExplicitContentLyrics == 1
}
type deezerArtist struct {
ID int64 `json:"id"`
Name string `json:"name"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbFan int `json:"nb_fan"`
}
type deezerAlbumSimple struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"`
RecordType string `json:"record_type"`
}
// deezerTrackArtistDisplay returns the display artist string for a track,
// preferring the Contributors list (comma-joined) when available, falling
// back to the primary Artist.Name.
func deezerTrackArtistDisplay(track deezerTrack) string {
if len(track.Contributors) > 0 {
names := make([]string, len(track.Contributors))
for i, a := range track.Contributors {
names[i] = a.Name
}
return strings.Join(names, ", ")
}
return track.Artist.Name
}
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
artistName := deezerTrackArtistDisplay(track)
albumImage := track.Album.CoverXL
if albumImage == "" {
albumImage = track.Album.CoverBig
}
if albumImage == "" {
albumImage = track.Album.CoverMedium
}
if albumImage == "" {
albumImage = track.Album.Cover
}
releaseDate := track.ReleaseDate
if releaseDate == "" {
releaseDate = track.Album.ReleaseDate
}
return TrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: artistName,
Name: track.Title,
AlbumName: track.Album.Title,
AlbumArtist: track.Artist.Name,
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: releaseDate,
TrackNumber: track.TrackPosition,
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
ISRC: track.ISRC,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
Explicit: deezerTrackIsExplicit(track),
}
}
type deezerGenre struct {
ID int `json:"id"`
Name string `json:"name"`
}
type deezerAlbumFull struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
Artist deezerArtist `json:"artist"`
Contributors []deezerArtist `json:"contributors"`
Tracks struct {
Data []deezerTrack `json:"data"`
} `json:"tracks"`
}
type deezerArtistFull struct {
ID int64 `json:"id"`
Name string `json:"name"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbFan int `json:"nb_fan"`
NbAlbum int `json:"nb_album"`
}
type deezerPlaylistFull struct {
ID int64 `json:"id"`
Title string `json:"title"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbTracks int `json:"nb_tracks"`
Creator struct {
Name string `json:"name"`
} `json:"creator"`
Tracks struct {
Data []deezerTrack `json:"data"`
} `json:"tracks"`
}
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
if artist.PictureXL != "" {
return artist.PictureXL
}
if artist.PictureBig != "" {
return artist.PictureBig
}
if artist.PictureMedium != "" {
return artist.PictureMedium
}
return artist.Picture
}
func (c *DeezerClient) getBestArtistImageFull(artist deezerArtistFull) string {
if artist.PictureXL != "" {
return artist.PictureXL
}
if artist.PictureBig != "" {
return artist.PictureBig
}
if artist.PictureMedium != "" {
return artist.PictureMedium
}
return artist.Picture
}
func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
if album.CoverXL != "" {
return album.CoverXL
}
if album.CoverBig != "" {
return album.CoverBig
}
if album.CoverMedium != "" {
return album.CoverMedium
}
return album.Cover
}
-11
View File
@@ -78,17 +78,6 @@ func TestDeezerClientWithFakeHTTP(t *testing.T) {
t.Fatalf("cached artist = %#v/%v", cachedArtist, err)
}
related, err := client.GetRelatedArtists(ctx, "deezer:301", 3)
if err != nil {
t.Fatalf("GetRelatedArtists: %v", err)
}
if len(related) != 1 || related[0].ID != "deezer:302" {
t.Fatalf("related = %#v", related)
}
if _, err := client.GetRelatedArtists(ctx, "", 0); err == nil {
t.Fatal("expected invalid related artist ID")
}
playlist, err := client.GetPlaylist(ctx, "401")
if err != nil {
t.Fatalf("GetPlaylist: %v", err)
+237
View File
@@ -0,0 +1,237 @@
package gobackend
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
"golang.org/x/net/dns/dnsmessage"
)
// DNS-over-HTTPS fallback for DNS-level ISP blocking. The OS resolver stays
// the primary path; only when it fails with a DNS error (NXDOMAIN, SERVFAIL,
// refused, resolver timeout) is the host re-resolved over DoH to hardcoded
// resolver IPs and dialed directly. TLS verification still runs against the
// original hostname, so a bad answer cannot silently redirect traffic.
var dohUpstreams = []string{
"https://1.1.1.1/dns-query",
"https://8.8.8.8/dns-query",
}
// Upstream URLs are literal IPs, so this client never needs DNS itself.
var dohClient = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
MaxIdleConnsPerHost: 1,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ForceAttemptHTTP2: true,
TLSClientConfig: newTLSCompatibilityConfig(false),
},
Timeout: 10 * time.Second,
}
const (
dohCacheMaxEntries = 256
dohCacheMinTTL = time.Minute
dohCacheMaxTTL = 30 * time.Minute
dohCacheErrorTTL = 30 * time.Second
)
type dohCacheEntry struct {
ips []net.IP
expiresAt time.Time
}
var (
dohMu sync.Mutex
dohCache = map[string]dohCacheEntry{}
)
// dialWithDoHFallback dials addr normally and, when the failure is a DNS
// error, retries with DoH-resolved addresses. Non-DNS failures pass through
// untouched.
func dialWithDoHFallback(ctx context.Context, dialer *net.Dialer, network, addr string) (net.Conn, error) {
conn, err := dialer.DialContext(ctx, network, addr)
if err == nil {
return conn, nil
}
var dnsErr *net.DNSError
if !errors.As(err, &dnsErr) {
return nil, err
}
host, port, splitErr := net.SplitHostPort(addr)
if splitErr != nil || net.ParseIP(host) != nil {
return nil, err
}
ips, dohErr := dohResolve(ctx, host)
if dohErr != nil {
// Surface the OS resolver's error, not the fallback's.
return nil, err
}
GoLog("[DoH] OS resolver failed for %s (%v), dialing DoH answer\n", host, err)
lastErr := err
for _, ip := range ips {
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
}
// dohResolve resolves host over DoH, IPv4 first. Failures are negative-cached
// briefly so a burst of dials does not hammer the resolvers.
func dohResolve(ctx context.Context, host string) ([]net.IP, error) {
if ips, ok := dohCachedIPs(host); ok {
if len(ips) == 0 {
return nil, fmt.Errorf("doh: cached failure for %s", host)
}
return ips, nil
}
var lastErr error
for _, upstream := range dohUpstreams {
ips, ttl, err := dohQuery(ctx, upstream, host, dnsmessage.TypeA)
if err == nil && len(ips) == 0 {
ips, ttl, err = dohQuery(ctx, upstream, host, dnsmessage.TypeAAAA)
}
if err != nil {
lastErr = err
continue
}
ips = filterDialableIPs(ips)
if len(ips) == 0 {
break
}
dohCachePut(host, ips, min(max(ttl, dohCacheMinTTL), dohCacheMaxTTL))
return ips, nil
}
dohCachePut(host, nil, dohCacheErrorTTL)
if lastErr == nil {
lastErr = fmt.Errorf("doh: no address for %s", host)
}
return nil, lastErr
}
func dohQuery(ctx context.Context, upstream, host string, qtype dnsmessage.Type) ([]net.IP, time.Duration, error) {
name, err := dnsmessage.NewName(host + ".")
if err != nil {
return nil, 0, fmt.Errorf("doh: invalid host %q: %w", host, err)
}
msg := dnsmessage.Message{
Header: dnsmessage.Header{RecursionDesired: true},
Questions: []dnsmessage.Question{{
Name: name,
Type: qtype,
Class: dnsmessage.ClassINET,
}},
}
packed, err := msg.Pack()
if err != nil {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstream, bytes.NewReader(packed))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/dns-message")
req.Header.Set("Accept", "application/dns-message")
resp, err := dohClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("doh: %s answered HTTP %d", upstream, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil, 0, err
}
var reply dnsmessage.Message
if err := reply.Unpack(body); err != nil {
return nil, 0, err
}
if reply.RCode != dnsmessage.RCodeSuccess {
return nil, 0, fmt.Errorf("doh: rcode %v for %s", reply.RCode, host)
}
var ips []net.IP
ttl := dohCacheMaxTTL
for _, ans := range reply.Answers {
var ip net.IP
switch r := ans.Body.(type) {
case *dnsmessage.AResource:
ip = net.IP(r.A[:])
case *dnsmessage.AAAAResource:
ip = net.IP(r.AAAA[:])
default:
continue
}
ips = append(ips, ip)
if t := time.Duration(ans.Header.TTL) * time.Second; t < ttl {
ttl = t
}
}
return ips, ttl, nil
}
// filterDialableIPs drops private/loopback/link-local answers unless the user
// opted into private-network access — a DoH answer must not bypass the SSRF
// guard the OS-resolver path enforces.
func filterDialableIPs(ips []net.IP) []net.IP {
if IsPrivateNetworkAllowed() {
return ips
}
kept := ips[:0]
for _, ip := range ips {
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
continue
}
kept = append(kept, ip)
}
return kept
}
func dohCachedIPs(host string) ([]net.IP, bool) {
dohMu.Lock()
defer dohMu.Unlock()
e, ok := dohCache[host]
if !ok || time.Now().After(e.expiresAt) {
return nil, false
}
return e.ips, true
}
func dohCachePut(host string, ips []net.IP, ttl time.Duration) {
dohMu.Lock()
defer dohMu.Unlock()
if len(dohCache) >= dohCacheMaxEntries {
now := time.Now()
for k, e := range dohCache {
if now.After(e.expiresAt) {
delete(dohCache, k)
}
}
for k := range dohCache {
if len(dohCache) < dohCacheMaxEntries {
break
}
delete(dohCache, k)
}
}
dohCache[host] = dohCacheEntry{ips: ips, expiresAt: time.Now().Add(ttl)}
}
+33
View File
@@ -0,0 +1,33 @@
package gobackend
import (
"path/filepath"
"strings"
"sync"
)
// downloadPathLocks serializes writes per final output path so concurrent
// downloads that resolve to the same file cannot interleave bytes into one
// output or race the staged-promote rename. Keys are normalized case-folded
// cleaned paths; entries live for the process lifetime (bounded by the number
// of distinct output files in a session).
var downloadPathLocks sync.Map
// lockDownloadOutputPath locks the given final output path and returns the
// unlock function. Different paths keep downloading in parallel; a second
// download of the same path blocks until the first finishes.
func lockDownloadOutputPath(path string) func() {
key := strings.ToLower(filepath.Clean(path))
value, _ := downloadPathLocks.LoadOrStore(key, &sync.Mutex{})
mu := value.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
// stagedDownloadPath returns the sibling name downloads are streamed into
// before being promoted to the final path with an atomic rename. The suffix
// keeps the staged file invisible to extension-based duplicate checks, which
// match on the final audio extension.
func stagedDownloadPath(finalPath string) string {
return finalPath + ".partial"
}
+129
View File
@@ -0,0 +1,129 @@
package gobackend
import (
"strings"
"sync"
"time"
)
const (
downloadPreparationCacheTTL = 5 * time.Minute
downloadPreparationCacheMax = 128
)
type preparedDownloadRequestEntry struct {
key string
request DownloadRequest
metadataPrepared bool
createdAt time.Time
}
var (
preparedDownloadRequests = make(map[string]preparedDownloadRequestEntry)
preparedDownloadRequestsMu sync.Mutex
)
func downloadPreparationKey(req DownloadRequest) string {
return strings.Join([]string{
strings.TrimSpace(req.ItemID),
strings.ToLower(strings.TrimSpace(req.Service)),
strings.ToLower(strings.TrimSpace(req.Source)),
strings.TrimSpace(req.SpotifyID),
strings.TrimSpace(req.TidalID),
strings.TrimSpace(req.QobuzID),
strings.TrimSpace(req.DeezerID),
strings.ToLower(strings.TrimSpace(req.TrackName)),
strings.ToLower(strings.TrimSpace(req.ArtistName)),
}, "\n")
}
func prunePreparedDownloadRequestsLocked(now time.Time) {
for itemID, entry := range preparedDownloadRequests {
if now.Sub(entry.createdAt) >= downloadPreparationCacheTTL {
delete(preparedDownloadRequests, itemID)
}
}
for len(preparedDownloadRequests) >= downloadPreparationCacheMax {
var oldestID string
var oldestAt time.Time
for itemID, entry := range preparedDownloadRequests {
if oldestID == "" || entry.createdAt.Before(oldestAt) {
oldestID = itemID
oldestAt = entry.createdAt
}
}
if oldestID == "" {
break
}
delete(preparedDownloadRequests, oldestID)
}
}
func cacheDownloadRequestForVerification(key string, req DownloadRequest, metadataPrepared bool) {
itemID := strings.TrimSpace(req.ItemID)
if itemID == "" || strings.TrimSpace(key) == "" {
return
}
preparedDownloadRequestsMu.Lock()
defer preparedDownloadRequestsMu.Unlock()
now := time.Now()
prunePreparedDownloadRequestsLocked(now)
preparedDownloadRequests[itemID] = preparedDownloadRequestEntry{
key: key,
request: req,
metadataPrepared: metadataPrepared,
createdAt: now,
}
}
func cachePreparedDownloadRequest(key string, req DownloadRequest) {
cacheDownloadRequestForVerification(key, req, true)
}
func cacheUnpreparedDownloadRequest(key string, req DownloadRequest) {
cacheDownloadRequestForVerification(key, req, false)
}
func takePreparedDownloadRequest(key string, fresh DownloadRequest) (DownloadRequest, bool, bool) {
itemID := strings.TrimSpace(fresh.ItemID)
if itemID == "" || strings.TrimSpace(key) == "" {
return fresh, false, false
}
preparedDownloadRequestsMu.Lock()
defer preparedDownloadRequestsMu.Unlock()
now := time.Now()
prunePreparedDownloadRequestsLocked(now)
entry, ok := preparedDownloadRequests[itemID]
if !ok {
return fresh, false, false
}
delete(preparedDownloadRequests, itemID)
if entry.key != key {
return fresh, false, false
}
prepared := entry.request
fresh.ISRC = prepared.ISRC
fresh.SpotifyID = prepared.SpotifyID
fresh.TrackName = prepared.TrackName
fresh.ArtistName = prepared.ArtistName
fresh.AlbumName = prepared.AlbumName
fresh.AlbumArtist = prepared.AlbumArtist
fresh.CoverURL = prepared.CoverURL
fresh.TrackNumber = prepared.TrackNumber
fresh.DiscNumber = prepared.DiscNumber
fresh.TotalTracks = prepared.TotalTracks
fresh.TotalDiscs = prepared.TotalDiscs
fresh.ReleaseDate = prepared.ReleaseDate
fresh.DurationMS = prepared.DurationMS
fresh.Genre = prepared.Genre
fresh.Label = prepared.Label
fresh.Copyright = prepared.Copyright
fresh.Composer = prepared.Composer
fresh.TidalID = prepared.TidalID
fresh.QobuzID = prepared.QobuzID
fresh.DeezerID = prepared.DeezerID
return fresh, entry.metadataPrepared, true
}
@@ -0,0 +1,104 @@
package gobackend
import (
"testing"
"time"
)
func resetPreparedDownloadRequestCacheForTest() {
preparedDownloadRequestsMu.Lock()
preparedDownloadRequests = make(map[string]preparedDownloadRequestEntry)
preparedDownloadRequestsMu.Unlock()
}
func TestPreparedDownloadRequestCache(t *testing.T) {
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
resetPreparedDownloadRequestCacheForTest()
fresh := DownloadRequest{
ItemID: "item-1",
Service: "provider-a",
Source: "source-a",
SpotifyID: "spotify-1",
TrackName: "Track",
ArtistName: "Artist",
OutputDir: "/new/output",
OutputPath: "/new/output/current.flac",
OutputFD: 42,
Quality: "lossless",
EmbedMetadata: true,
}
key := downloadPreparationKey(fresh)
prepared := fresh
prepared.ISRC = "USRC17607839"
prepared.AlbumName = "Resolved Album"
prepared.AlbumArtist = "Resolved Album Artist"
prepared.DeezerID = "deezer-1"
prepared.Genre = "Pop"
prepared.OutputDir = "/stale/output"
prepared.OutputPath = "/stale/output/old.flac"
prepared.OutputFD = 7
prepared.Quality = "stale-quality"
prepared.EmbedMetadata = false
cachePreparedDownloadRequest(key, prepared)
got, metadataPrepared, ok := takePreparedDownloadRequest(key, fresh)
if !ok {
t.Fatal("expected prepared request cache hit")
}
if !metadataPrepared {
t.Fatal("prepared request should be marked as metadata-prepared")
}
if got.ISRC != prepared.ISRC || got.AlbumName != prepared.AlbumName || got.DeezerID != prepared.DeezerID || got.Genre != prepared.Genre {
t.Fatalf("prepared metadata was not restored: %#v", got)
}
if got.OutputDir != fresh.OutputDir || got.OutputPath != fresh.OutputPath || got.OutputFD != fresh.OutputFD || got.Quality != fresh.Quality || got.EmbedMetadata != fresh.EmbedMetadata {
t.Fatalf("fresh output/settings fields were overwritten: %#v", got)
}
if _, _, ok := takePreparedDownloadRequest(key, fresh); ok {
t.Fatal("prepared request should be consumed after one retry")
}
cacheUnpreparedDownloadRequest(key, fresh)
_, metadataPrepared, ok = takePreparedDownloadRequest(key, fresh)
if !ok || metadataPrepared {
t.Fatalf("unprepared verification request = hit:%v metadataPrepared:%v", ok, metadataPrepared)
}
}
func TestPreparedDownloadRequestCacheRejectsChangedTrackAndExpiry(t *testing.T) {
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
resetPreparedDownloadRequestCacheForTest()
req := DownloadRequest{
ItemID: "item-2",
Service: "provider-a",
SpotifyID: "spotify-2",
TrackName: "Track",
ArtistName: "Artist",
}
key := downloadPreparationKey(req)
cachePreparedDownloadRequest(key, req)
changed := req
changed.SpotifyID = "spotify-other"
if _, _, ok := takePreparedDownloadRequest(downloadPreparationKey(changed), changed); ok {
t.Fatal("changed track must not reuse another track's prepared metadata")
}
preparedDownloadRequestsMu.Lock()
_, staleEntryExists := preparedDownloadRequests[req.ItemID]
preparedDownloadRequestsMu.Unlock()
if staleEntryExists {
t.Fatal("mismatched prepared request should be discarded")
}
cachePreparedDownloadRequest(key, req)
preparedDownloadRequestsMu.Lock()
entry := preparedDownloadRequests[req.ItemID]
entry.createdAt = time.Now().Add(-downloadPreparationCacheTTL)
preparedDownloadRequests[req.ItemID] = entry
preparedDownloadRequestsMu.Unlock()
if _, _, ok := takePreparedDownloadRequest(key, req); ok {
t.Fatal("expired prepared request must not be reused")
}
}
+234 -43
View File
@@ -1,19 +1,31 @@
package gobackend
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// isrcFileEntry caches the parse result for one file so index rebuilds only
// re-read files whose size or mtime changed.
type isrcFileEntry struct {
size int64
modTime int64 // UnixNano
isrc string // uppercase; empty when the file carries no ISRC tag
}
type ISRCIndex struct {
index map[string]string // ISRC (uppercase) -> file path
index map[string]string // ISRC (uppercase) -> file path
files map[string]isrcFileEntry // file path -> cached parse result
outputDir string
buildTime time.Time
buildTime atomic.Int64 // UnixNano of the last build or write
mu sync.RWMutex
}
@@ -22,14 +34,20 @@ var (
isrcIndexCacheMu sync.RWMutex
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
isrcIndexTTL = 5 * time.Minute
isrcIndexBuildWorkers = 4
)
func (idx *ISRCIndex) isFresh() bool {
return time.Since(time.Unix(0, idx.buildTime.Load())) < isrcIndexTTL
}
func GetISRCIndex(outputDir string) *ISRCIndex {
isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
if exists && idx.isFresh() {
return idx
}
@@ -42,7 +60,7 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
if exists && idx.isFresh() {
return idx
}
@@ -52,16 +70,37 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{
index: make(map[string]string),
files: make(map[string]isrcFileEntry),
outputDir: outputDir,
buildTime: time.Now(),
}
idx.buildTime.Store(time.Now().UnixNano())
if outputDir == "" {
return idx
}
// Reuse the previous build's per-file cache: files whose size and mtime
// are unchanged are adopted without touching their content, so a rebuild
// is normally just a stat walk.
prevFiles := map[string]isrcFileEntry{}
isrcIndexCacheMu.RLock()
if prev, ok := isrcIndexCache[outputDir]; ok {
prev.mu.RLock()
for path, entry := range prev.files {
prevFiles[path] = entry
}
prev.mu.RUnlock()
}
isrcIndexCacheMu.RUnlock()
startTime := time.Now()
fileCount := 0
type parseTask struct {
path string
size int64
modTime int64
}
var toParse []parseTask
reused := 0
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
@@ -69,22 +108,60 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".flac" {
if !isrcIndexExts[ext] {
return nil
}
metadata, err := ReadMetadata(path)
if err != nil || metadata.ISRC == "" {
size := info.Size()
modTime := info.ModTime().UnixNano()
if entry, ok := prevFiles[path]; ok && entry.size == size && entry.modTime == modTime {
idx.files[path] = entry
if entry.isrc != "" {
idx.index[entry.isrc] = path
}
reused++
return nil
}
idx.index[strings.ToUpper(metadata.ISRC)] = path
fileCount++
toParse = append(toParse, parseTask{path: path, size: size, modTime: modTime})
return nil
})
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
if len(toParse) > 0 {
// New/changed files: read only their Vorbis comment block, in
// parallel. Embedded cover art (megabytes per file) is never loaded.
isrcs := make([]string, len(toParse))
workerCount := isrcIndexBuildWorkers
if len(toParse) < workerCount {
workerCount = len(toParse)
}
tasks := make(chan int)
var wg sync.WaitGroup
for w := 0; w < workerCount; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range tasks {
isrcs[i] = strings.ToUpper(readFileISRC(toParse[i].path))
}
}()
}
for i := range toParse {
tasks <- i
}
close(tasks)
wg.Wait()
for i, task := range toParse {
entry := isrcFileEntry{size: task.size, modTime: task.modTime, isrc: isrcs[i]}
idx.files[task.path] = entry
if entry.isrc != "" {
idx.index[entry.isrc] = task.path
}
}
}
fmt.Printf("[ISRCIndex] Built index for %s: %d files (%d parsed, %d cached) in %v\n",
outputDir, len(idx.files), len(toParse), reused, time.Since(startTime).Round(time.Millisecond))
isrcIndexCacheMu.Lock()
isrcIndexCache[outputDir] = idx
@@ -93,6 +170,110 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return idx
}
// isrcIndexExts are the formats the download pipeline can produce; each has
// a native tag reader that stops at the metadata blocks.
var isrcIndexExts = map[string]bool{
".flac": true,
".mp3": true,
".m4a": true,
".ogg": true,
".opus": true,
}
// readFileISRC reads the ISRC tag using the native reader for the format.
// Returns "" for unsupported formats, unreadable files, or missing tags.
func readFileISRC(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".flac":
return readFlacISRC(path)
case ".mp3":
if meta, err := ReadID3Tags(path); err == nil && meta != nil {
return strings.TrimSpace(meta.ISRC)
}
case ".m4a":
if meta, err := ReadM4ATags(path); err == nil && meta != nil {
return strings.TrimSpace(meta.ISRC)
}
case ".ogg", ".opus":
if meta, err := ReadOggVorbisComments(path); err == nil && meta != nil {
return strings.TrimSpace(meta.ISRC)
}
}
return ""
}
// readFlacISRC extracts the ISRC Vorbis comment from a FLAC file by walking
// the metadata block headers and reading only the VORBIS_COMMENT payload;
// picture and padding blocks are seeked past, never loaded. Returns "" when
// the file is not FLAC or carries no ISRC tag.
func readFlacISRC(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
magic := make([]byte, 4)
if _, err := io.ReadFull(f, magic); err != nil || string(magic) != "fLaC" {
return ""
}
header := make([]byte, 4)
for {
if _, err := io.ReadFull(f, header); err != nil {
return ""
}
last := header[0]&0x80 != 0
blockType := header[0] & 0x7F
length := int64(header[1])<<16 | int64(header[2])<<8 | int64(header[3])
if blockType == 4 { // VORBIS_COMMENT
if length > 16<<20 {
return ""
}
payload := make([]byte, length)
if _, err := io.ReadFull(f, payload); err != nil {
return ""
}
return vorbisCommentISRC(payload)
}
if last {
return ""
}
if _, err := f.Seek(length, io.SeekCurrent); err != nil {
return ""
}
}
}
func vorbisCommentISRC(payload []byte) string {
if len(payload) < 8 {
return ""
}
offset := int(binary.LittleEndian.Uint32(payload[0:4])) + 4
if offset < 4 || offset+4 > len(payload) {
return ""
}
count := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
offset += 4
for i := 0; i < count; i++ {
if offset+4 > len(payload) {
return ""
}
commentLen := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
offset += 4
if commentLen < 0 || offset+commentLen > len(payload) {
return ""
}
comment := payload[offset : offset+commentLen]
offset += commentLen
eq := strings.IndexByte(string(comment), '=')
if eq > 0 && strings.EqualFold(string(comment[:eq]), "ISRC") {
return strings.TrimSpace(string(comment[eq+1:]))
}
}
return ""
}
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
if isrc == "" {
return "", false
@@ -126,10 +307,30 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
upper := strings.ToUpper(isrc)
var entry *isrcFileEntry
if info, err := os.Stat(filePath); err == nil {
entry = &isrcFileEntry{
size: info.Size(),
modTime: info.ModTime().UnixNano(),
isrc: upper,
}
}
idx.index[strings.ToUpper(isrc)] = filePath
idx.mu.Lock()
idx.index[upper] = filePath
if entry != nil {
if idx.files == nil {
idx.files = make(map[string]isrcFileEntry)
}
idx.files[filePath] = *entry
}
idx.mu.Unlock()
// The index is write-maintained after every successful download;
// refreshing the timestamp keeps the TTL from forcing a full rebuild in
// the middle of the exact workload the index exists to serve.
idx.buildTime.Store(time.Now().UnixNano())
}
func InvalidateISRCCache(outputDir string) {
@@ -193,35 +394,25 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
isrcIdx := GetISRCIndex(outputDir)
var wg sync.WaitGroup
// A lookup is a single map read. Holding one read lock for the batch avoids
// one goroutine and one lock/unlock pair per track, which was slower and
// could create thousands of goroutines for large playlists.
isrcIdx.mu.RLock()
for i, track := range tracks {
wg.Add(1)
go func(resultIdx int, t struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
}) {
defer wg.Done()
result := FileExistenceResult{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
Exists: false,
result := FileExistenceResult{
ISRC: track.ISRC,
TrackName: track.TrackName,
ArtistName: track.ArtistName,
}
if track.ISRC != "" {
if filePath, exists := isrcIdx.index[strings.ToUpper(track.ISRC)]; exists {
result.Exists = true
result.FilePath = filePath
}
if t.ISRC != "" {
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
result.Exists = true
result.FilePath = filePath
}
}
results[resultIdx] = result
}(i, track)
}
results[i] = result
}
wg.Wait()
isrcIdx.mu.RUnlock()
resultJSON, err := json.Marshal(results)
if err != nil {
+55 -4050
View File
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
package gobackend
import (
"context"
"fmt"
"strings"
"time"
)
// GetTrackCacheSize and ClearTrackIDCache back the Settings cache screen. The
// track-ID cache is currently a no-op, so these report an empty cache and clear
// nothing, but the gomobile export contract is kept for the Dart/Kotlin callers.
func GetTrackCacheSize() int {
return 0
}
func ClearTrackIDCache() {
}
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := GetDeezerClient()
var data any
var err error
switch resourceType {
case "track":
data, err = client.GetTrack(ctx, resourceID)
case "album":
data, err = client.GetAlbum(ctx, resourceID)
case "artist":
data, err = client.GetArtist(ctx, resourceID)
case "playlist":
data, err = client.GetPlaylist(ctx, resourceID)
default:
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
}
if err != nil {
return "", err
}
return marshalJSONString(data)
}
func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := GetDeezerClient()
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
if err != nil {
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
return "", err
}
result := buildDeezerExtendedMetadataResult(metadata)
return marshalJSONString(result)
}
func SearchDeezerByISRC(isrc string) (string, error) {
return SearchDeezerByISRCForItemID(isrc, "")
}
func SearchDeezerByISRCForItemID(isrc string, itemID string) (string, error) {
parentCtx := context.Background()
if itemID != "" {
parentCtx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
if isDownloadCancelled(itemID) {
return "", ErrDownloadCancelled
}
}
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
client := GetDeezerClient()
track, err := client.SearchByISRC(ctx, isrc)
if err != nil {
if isDownloadCancelled(itemID) {
return "", ErrDownloadCancelled
}
return "", err
}
if isDownloadCancelled(itemID) {
return "", ErrDownloadCancelled
}
result := buildDeezerISRCSearchResult(track)
return marshalJSONString(result)
}
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
if metadata == nil {
return map[string]string{
"genre": "",
"label": "",
"copyright": "",
}
}
return map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
"copyright": metadata.Copyright,
}
}
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]any {
if track == nil {
return map[string]any{}
}
result := map[string]any{
"spotify_id": track.SpotifyID,
"artists": track.Artists,
"name": track.Name,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.Images,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
"disc_number": track.DiscNumber,
"total_discs": track.TotalDiscs,
"external_urls": track.ExternalURL,
"isrc": track.ISRC,
"album_id": track.AlbumID,
"artist_id": track.ArtistID,
"album_type": track.AlbumType,
"composer": track.Composer,
}
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
result["id"] = deezerID
result["track_id"] = deezerID
result["success"] = true
}
return result
}
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
songlink := NewSongLinkClient()
deezerClient := GetDeezerClient()
if resourceType == "track" {
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
}
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
}
return marshalJSONString(trackResp)
}
if resourceType == "album" {
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer album: %w", err)
}
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
}
return marshalJSONString(albumResp)
}
return "", fmt.Errorf("spotify to Deezer conversion only supported for tracks and albums: please search by name for %s", resourceType)
}
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetSpotifyIDFromDeezer(deezerTrackID)
}
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetTidalURLFromDeezer(deezerTrackID)
}
+505
View File
@@ -0,0 +1,505 @@
package gobackend
import (
"context"
"encoding/json"
"strings"
"time"
)
type DownloadRequest struct {
ContractVersion int `json:"contract_version,omitempty"`
ISRC string `json:"isrc"`
Service string `json:"service"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
CoverURL string `json:"cover_url"`
OutputDir string `json:"output_dir"`
OutputPath string `json:"output_path,omitempty"`
OutputFD int `json:"output_fd,omitempty"`
OutputExt string `json:"output_ext,omitempty"`
FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"`
EmbedMetadata bool `json:"embed_metadata"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
EmbedReplayGain bool `json:"embed_replaygain,omitempty"`
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"`
ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"`
DurationMS int `json:"duration_ms"`
Source string `json:"source"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
LyricsMode string `json:"lyrics_mode,omitempty"`
UseExtensions bool `json:"use_extensions,omitempty"`
UseFallback bool `json:"use_fallback,omitempty"`
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
AllowQualityVariant bool `json:"allow_quality_variant,omitempty"`
QualityVariant string `json:"quality_variant,omitempty"`
SongLinkRegion string `json:"songlink_region,omitempty"`
}
type DownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
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"`
AudioCodec string `json:"audio_codec,omitempty"`
ActualExtension string `json:"actual_extension,omitempty"`
ActualContainer string `json:"actual_container,omitempty"`
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
Service string `json:"service,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ISRC string `json:"isrc,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
}
type DownloadResult struct {
FilePath string
BitDepth int
SampleRate int
AudioCodec string
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
TotalTracks int
DiscNumber int
TotalDiscs int
ISRC string
CoverURL string
Genre string
Label string
Copyright string
Composer string
LyricsLRC string
DecryptionKey string
Decryption *DownloadDecryptionInfo
ActualExtension string
ActualContainer string
RequiresContainerConversion bool
}
func buildDownloadSuccessResponse(
req DownloadRequest,
result DownloadResult,
service string,
message string,
filePath string,
alreadyExists bool,
) DownloadResponse {
title := result.Title
if title == "" {
title = req.TrackName
}
artist := result.Artist
if artist == "" {
artist = req.ArtistName
}
// Preserve requested release metadata when available so mixed-provider
// fallback downloads from the same source album do not get split into
// different albums just because Tidal/Qobuz report variant titles/dates.
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
req,
result.Album,
result.ReleaseDate,
result.TrackNumber,
result.DiscNumber,
)
isrc := result.ISRC
if isrc == "" {
isrc = req.ISRC
}
genre := result.Genre
if genre == "" {
genre = req.Genre
}
label := result.Label
if label == "" {
label = req.Label
}
copyright := result.Copyright
if copyright == "" {
copyright = req.Copyright
}
composer := result.Composer
if composer == "" {
composer = req.Composer
}
coverURL := strings.TrimSpace(result.CoverURL)
if coverURL == "" {
coverURL = strings.TrimSpace(req.CoverURL)
}
return DownloadResponse{
Success: true,
Message: message,
FilePath: filePath,
AlreadyExists: alreadyExists,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
AudioCodec: result.AudioCodec,
ActualExtension: result.ActualExtension,
ActualContainer: result.ActualContainer,
RequiresContainerConversion: result.RequiresContainerConversion,
Service: service,
Title: title,
Artist: artist,
Album: album,
AlbumArtist: req.AlbumArtist,
ReleaseDate: releaseDate,
TrackNumber: trackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: discNumber,
TotalDiscs: req.TotalDiscs,
ISRC: isrc,
CoverURL: coverURL,
Genre: genre,
Label: label,
Copyright: copyright,
Composer: composer,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
}
}
func shouldSkipQualityProbe(filePath string) bool {
path := strings.TrimSpace(filePath)
if path == "" {
return true
}
if strings.HasPrefix(path, "/proc/self/fd/") {
return true
}
// Content URI and other non-filesystem schemes cannot be read directly by os.Open.
if strings.Contains(path, "://") {
return true
}
return false
}
func enrichResultQualityFromFile(result *DownloadResult) {
if result == nil {
return
}
path := strings.TrimSpace(result.FilePath)
if shouldSkipQualityProbe(path) {
if strings.HasPrefix(path, "/proc/self/fd/") {
LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path)
}
return
}
quality, qErr := GetAudioQuality(path)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.AudioCodec = quality.Codec
if quality.Codec != "" {
GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
return
}
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
}
func applyExtendedMetadataFields(
genre *string,
label *string,
copyright *string,
extMeta *AlbumExtendedMetadata,
) {
if extMeta == nil {
return
}
if genre != nil && *genre == "" && extMeta.Genre != "" {
*genre = extMeta.Genre
}
if label != nil && *label == "" && extMeta.Label != "" {
*label = extMeta.Label
}
if copyright != nil && *copyright == "" && extMeta.Copyright != "" {
*copyright = extMeta.Copyright
}
}
func enrichExtraMetadataByISRC(
logPrefix string,
isrc string,
genre *string,
label *string,
copyright *string,
) {
normalizedISRC := strings.TrimSpace(isrc)
if normalizedISRC == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
extMeta, err := fetchDeezerExtendedMetadataByISRC(ctx, normalizedISRC)
if err != nil {
GoLog("[%s] Failed to get extended metadata from Deezer: %v\n", logPrefix, err)
}
applyExtendedMetadataFields(genre, label, copyright, extMeta)
if genre != nil && *genre == "" {
musicBrainzGenre, err := fetchMusicBrainzGenreByISRC(normalizedISRC)
if err != nil {
GoLog("[%s] Failed to get genre from MusicBrainz: %v\n", logPrefix, err)
} else if musicBrainzGenre != "" {
*genre = musicBrainzGenre
GoLog("[%s] Genre fallback from MusicBrainz: %s\n", logPrefix, *genre)
}
}
currentGenre := ""
currentLabel := ""
currentCopyright := ""
if genre != nil {
currentGenre = *genre
}
if label != nil {
currentLabel = *label
}
if copyright != nil {
currentCopyright = *copyright
}
if currentGenre != "" || currentLabel != "" || currentCopyright != "" {
GoLog("[%s] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", logPrefix, currentGenre, currentLabel, currentCopyright)
}
}
func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req == nil {
return
}
if req.ISRC == "" {
return
}
if strings.TrimSpace(req.AlbumArtist) == "" {
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
if err != nil {
GoLog("[DownloadWithFallback] Failed to get album artist from MusicBrainz: %v\n", err)
} else if strings.TrimSpace(albumArtist) != "" {
req.AlbumArtist = strings.TrimSpace(albumArtist)
GoLog("[DownloadWithFallback] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
}
}
if req.Genre == "" || req.Label == "" || req.Copyright == "" {
enrichExtraMetadataByISRC(
"DownloadWithFallback",
req.ISRC,
&req.Genre,
&req.Label,
&req.Copyright,
)
}
}
func applySongLinkRegionFromRequest(req *DownloadRequest) {
if req == nil {
return
}
SetSongLinkRegion(req.SongLinkRegion)
}
// DownloadByStrategy routes all download requests through extension providers.
func DownloadByStrategy(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
normalizedBytes, err := json.Marshal(req)
if err != nil {
return errorResponse("Invalid request: " + err.Error())
}
normalizedJSON := string(normalizedBytes)
if req.UseExtensions {
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
if err != nil {
return errorResponse(err.Error())
}
return resp, nil
}
return errorResponse("Extension providers are disabled; built-in download providers have been retired")
}
func GetAllDownloadProgress() string {
return GetMultiProgress()
}
func GetAllDownloadProgressDelta(sinceSeq int64) string {
return GetMultiProgressDelta(sinceSeq)
}
func InitItemProgress(itemID string) {
StartItemProgress(itemID)
}
func ClearItemProgress(itemID string) {
RemoveItemProgress(itemID)
}
func CancelDownload(itemID string) {
cancelDownload(itemID)
}
// ResetDownloadCancel drops a pre-registered cancellation flag for an item
// with no active download, so a user-initiated retry does not consume a stale
// cancel and abort instantly. Entries with live references are left alone.
func ResetDownloadCancel(itemID string) {
resetDownloadCancel(itemID)
}
func CleanupConnections() {
CloseIdleConnections()
}
func errorResponse(msg string) (string, error) {
errorType := classifyDownloadErrorType(msg)
resp := DownloadResponse{
Success: false,
Error: msg,
ErrorType: errorType,
}
s, _ := marshalJSONString(resp)
return s, nil
}
func classifyDownloadErrorType(msg string) string {
lowerMsg := strings.ToLower(msg)
if strings.Contains(lowerMsg, "isp blocking") ||
strings.Contains(lowerMsg, "try using vpn") ||
strings.Contains(lowerMsg, "change dns") {
return "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
return "cancelled"
} else if strings.Contains(lowerMsg, "verification_required") ||
strings.Contains(lowerMsg, "session is not authenticated") ||
strings.Contains(lowerMsg, "signed session is not authenticated") ||
strings.Contains(lowerMsg, "signed session expired") {
return "verification_required"
} else if strings.Contains(lowerMsg, "byoa_provider_reauth_required") ||
strings.Contains(lowerMsg, "reauth_provider") {
return "provider_reauth_required"
} else if strings.Contains(lowerMsg, "request_auth_invalid") {
return "request_auth_invalid"
} else if strings.Contains(lowerMsg, "provider_auth_failed") {
return "provider_auth_failed"
} else if strings.Contains(lowerMsg, "provider_unavailable") {
return "provider_unavailable"
} else if strings.Contains(lowerMsg, "rate limit") ||
messageHasHTTPStatusCode(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") {
return "rate_limit"
} else if strings.Contains(lowerMsg, "permission") ||
strings.Contains(lowerMsg, "operation not permitted") ||
strings.Contains(lowerMsg, "access denied") ||
strings.Contains(lowerMsg, "failed to create file") ||
strings.Contains(lowerMsg, "failed to create directory") {
return "permission"
} else if strings.Contains(lowerMsg, "not found") ||
strings.Contains(lowerMsg, "not available") ||
strings.Contains(lowerMsg, "no results") ||
strings.Contains(lowerMsg, "track not found") ||
strings.Contains(lowerMsg, "all services failed") {
return "not_found"
} else if strings.Contains(lowerMsg, "network") ||
strings.Contains(lowerMsg, "connection") ||
strings.Contains(lowerMsg, "timeout") ||
strings.Contains(lowerMsg, "dial") {
return "network"
}
return "unknown"
}
// isOutputStorageWriteFailure distinguishes an unwritable destination from a
// provider-specific failure. Provider fallback cannot repair the former: all
// providers receive the same output path, so continuing only delays the
// storage fallback and can replace the useful permission error with an
// unrelated error from the last provider.
func isOutputStorageWriteFailure(errorType, message string) bool {
if strings.EqualFold(strings.TrimSpace(errorType), "permission") {
return true
}
lowerMsg := strings.ToLower(strings.TrimSpace(message))
if lowerMsg == "" {
return false
}
return strings.Contains(lowerMsg, "operation not permitted") ||
strings.Contains(lowerMsg, "permission denied") ||
strings.Contains(lowerMsg, "read-only file system") ||
strings.Contains(lowerMsg, "failed to create file") ||
strings.Contains(lowerMsg, "failed to create directory")
}
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+";")
}
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
package gobackend
func SetLibraryCoverCacheDirJSON(cacheDir string) {
SetLibraryCoverCacheDir(cacheDir)
}
func ScanLibraryFolderJSON(folderPath string) (string, error) {
return ScanLibraryFolder(folderPath)
}
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
}
func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) {
return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath)
}
func GetLibraryScanProgressJSON() string {
return GetLibraryScanProgress()
}
func CancelLibraryScanJSON() {
CancelLibraryScan()
}
func ReadAudioMetadataJSON(filePath string) (string, error) {
return ReadAudioMetadata(filePath)
}
func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) {
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey)
}
+172
View File
@@ -0,0 +1,172 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"strings"
)
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && rawLyricsHasUsableContent(lyrics) {
return lyrics, nil
}
return "", nil
}
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
if lyricsData.Instrumental {
return "[instrumental:true]", nil
}
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil
}
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && rawLyricsHasUsableContent(lyrics) {
source := extractLyricsSourceFromLRC(lyrics)
if source == "" {
source = "Embedded"
}
result := map[string]any{
"lyrics": lyrics,
"source": source,
"sync_type": "EMBEDDED",
"instrumental": isInstrumentalLyricsMarker(lyrics),
}
return marshalJSONString(result)
}
result := map[string]any{
"lyrics": "",
"source": "",
"sync_type": "",
"instrumental": false,
}
return marshalJSONString(result)
}
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
lrcContent := ""
if lyricsData.Instrumental {
lrcContent = "[instrumental:true]"
} else {
lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName)
}
result := map[string]any{
"lyrics": lrcContent,
"source": lyricsData.Source,
"sync_type": lyricsData.SyncType,
"instrumental": lyricsData.Instrumental,
}
return marshalJSONString(result)
}
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
err := EmbedLyrics(filePath, lyrics)
if err != nil {
return errorResponse("Failed to embed lyrics: " + err.Error())
}
resp := map[string]any{
"success": true,
"message": "Lyrics embedded successfully",
}
s, _ := marshalJSONString(resp)
return s, nil
}
func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string, audioFilePath string) error {
// If the audio file already has embedded lyrics or a sidecar .lrc,
// use those directly instead of making redundant network requests.
if audioFilePath != "" {
existing, err := ExtractLyrics(audioFilePath)
if err == nil && rawLyricsHasUsableContent(existing) {
if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil {
return fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC from embedded/sidecar to: %s\n", outputPath)
return nil
}
}
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return fmt.Errorf("lyrics not found: %w", err)
}
if lyrics.Instrumental {
return fmt.Errorf("track is instrumental, no lyrics available")
}
lrcContent := convertToLRCWithMetadata(lyrics, trackName, artistName)
if lrcContent == "" {
return fmt.Errorf("failed to generate LRC content")
}
if err := os.WriteFile(outputPath, []byte(lrcContent), 0644); err != nil {
return fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC to: %s (%d lines)\n", outputPath, len(lyrics.Lines))
return nil
}
func SetLyricsProvidersJSON(providersJSON string) error {
var providers []string
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
return err
}
SetLyricsProviderOrder(providers)
return nil
}
func GetLyricsProvidersJSON() (string, error) {
providers := GetLyricsProviderOrder()
return marshalJSONString(providers)
}
func GetAvailableLyricsProvidersJSON() (string, error) {
providers := GetAvailableLyricsProviders()
return marshalJSONString(providers)
}
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
opts := GetLyricsFetchOptions()
if strings.TrimSpace(optionsJSON) != "" {
if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil {
return err
}
}
SetLyricsFetchOptions(opts)
return nil
}
func GetLyricsFetchOptionsJSON() (string, error) {
opts := GetLyricsFetchOptions()
return marshalJSONString(opts)
}
+588
View File
@@ -0,0 +1,588 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func applyAudioMetadataToResult(result map[string]any, meta *AudioMetadata) {
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
}
func successMethodJSON(method string) (string, error) {
return marshalJSONString(map[string]any{"success": true, "method": method})
}
func ReadFileMetadata(filePath string) (string, error) {
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac")
isMp3 := strings.HasSuffix(lower, ".mp3")
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
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]any{
"title": "",
"artist": "",
"album": "",
"album_artist": "",
"date": "",
"track_number": 0,
"total_tracks": 0,
"disc_number": 0,
"total_discs": 0,
"isrc": "",
"lyrics": "",
"genre": "",
"label": "",
"copyright": "",
"composer": "",
"comment": "",
"duration": 0,
"format": "",
"audio_codec": "",
}
if isFlac {
result["format"] = "flac"
result["audio_codec"] = "flac"
metadata, err := ReadMetadata(filePath)
if err != nil {
// File may have wrong extension (e.g. opus saved as .flac).
// Try Ogg/Opus parser as fallback before giving up.
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
oggMeta, oggErr := ReadOggVorbisComments(filePath)
if oggErr == nil && oggMeta != nil {
result["title"] = oggMeta.Title
result["artist"] = oggMeta.Artist
result["album"] = oggMeta.Album
result["album_artist"] = oggMeta.AlbumArtist
result["date"] = oggMeta.Date
if oggMeta.Date == "" {
result["date"] = oggMeta.Year
}
result["track_number"] = oggMeta.TrackNumber
result["total_tracks"] = oggMeta.TotalTracks
result["disc_number"] = oggMeta.DiscNumber
result["total_discs"] = oggMeta.TotalDiscs
result["isrc"] = oggMeta.ISRC
result["lyrics"] = oggMeta.Lyrics
result["genre"] = oggMeta.Genre
result["composer"] = oggMeta.Composer
result["comment"] = oggMeta.Comment
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
if quality.Bitrate > 0 {
result["bitrate"] = quality.Bitrate / 1000
}
}
result["format"] = "opus"
result["audio_codec"] = "opus"
} else {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
} else {
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["total_tracks"] = metadata.TotalTracks
result["disc_number"] = metadata.DiscNumber
result["total_discs"] = metadata.TotalDiscs
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
result["replaygain_track_gain"] = metadata.ReplayGainTrackGain
result["replaygain_track_peak"] = metadata.ReplayGainTrackPeak
result["replaygain_album_gain"] = metadata.ReplayGainAlbumGain
result["replaygain_album_peak"] = metadata.ReplayGainAlbumPeak
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.Codec != "" {
result["audio_codec"] = quality.Codec
}
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
// Average bitrate from file size: helps spot lossy audio
// repackaged as "24-bit" FLAC (real hi-res sits well above
// ~1500 kbps, upconverted files far below).
durationSec := float64(quality.TotalSamples) / float64(quality.SampleRate)
if info, statErr := os.Stat(filePath); statErr == nil && info.Size() > 0 && durationSec > 0 {
result["bitrate"] = int(float64(info.Size()) * 8 / durationSec / 1000)
}
}
}
}
} else if isM4A {
result["format"] = "m4a"
meta, err := ReadM4ATags(filePath)
if err == nil && meta != nil {
applyAudioMetadataToResult(result, meta)
}
quality, qualityErr := GetM4AQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
result["audio_codec"] = quality.Codec
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
result["format"] = format
}
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
result["bitrate"] = quality.Bitrate
} else if quality.Duration > 0 {
if info, statErr := os.Stat(filePath); statErr == nil && info.Size() > 0 {
result["bitrate"] = int(float64(info.Size()) * 8 / float64(quality.Duration) / 1000)
}
}
}
} else if isMp3 {
result["format"] = "mp3"
result["audio_codec"] = "mp3"
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
applyAudioMetadataToResult(result, meta)
}
quality, qualityErr := GetMP3Quality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
if quality.Bitrate > 0 {
result["bitrate"] = quality.Bitrate / 1000
}
}
} else if isOgg {
result["format"] = "opus"
result["audio_codec"] = "opus"
meta, err := ReadOggVorbisComments(filePath)
if err == nil && meta != nil {
applyAudioMetadataToResult(result, meta)
}
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
if quality.Bitrate > 0 {
result["bitrate"] = quality.Bitrate / 1000
}
}
} else if isApe || isWv || isMpc {
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
result["audio_codec"] = result["format"]
apeTag, apeErr := ReadAPETags(filePath)
if apeErr == nil && apeTag != nil {
meta := APETagToAudioMetadata(apeTag)
if meta != nil {
applyAudioMetadataToResult(result, meta)
}
}
} 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 {
applyAudioMetadataToResult(result, meta)
}
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)
}
return marshalJSONString(result)
}
// ParseCueSheet is called from Dart to get track listing and timing data for CUE splitting.
// audioDir, if non-empty, overrides the directory used for resolving the
// referenced audio file (useful for SAF temp file scenarios).
func ParseCueSheet(cuePath string, audioDir string) (string, error) {
return ParseCueFileJSON(cuePath, audioDir)
}
// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of
// LibraryScanResult entries (one per track). This is the SAF-friendly variant:
// - audioDir overrides where the referenced audio file is resolved
// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI)
// - fileModTime is stamped on every result (pass 0 to stat cuePath instead)
func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
if err != nil {
return "[]", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
}
return string(jsonBytes), nil
}
func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
results, err := ScanCueFileForLibraryExtWithCoverCacheKey(
cuePath,
audioDir,
virtualPathPrefix,
fileModTime,
coverCacheKey,
scanTime,
)
if err != nil {
return "[]", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
}
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)
}
return successMethodJSON("native_m4a_freeform")
}
// 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}
s, _ := marshalJSONString(resp)
return s, 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
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
return "", fmt.Errorf("invalid metadata JSON: %w", err)
}
lower := strings.ToLower(filePath)
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)) {
if err := EditM4AReplayGain(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
}
return successMethodJSON("native_m4a_replaygain")
}
if isFlac {
// A .flac name does not guarantee FLAC content: providers sometimes
// deliver an MP4/M4A stream that ends up under the requested name.
// The FLAC writer would fail "fLaC head incorrect" on every attempt,
// so detect the mismatch up front and say what is actually wrong.
if isMP4ContainerFile(filePath) {
return "", fmt.Errorf(
"failed to write FLAC metadata: file is an MP4/M4A stream under a .flac name; rename it to .m4a",
)
}
if err := EditFlacFields(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
}
return successMethodJSON("native")
}
// 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)
}
return successMethodJSON("native_wav")
}
if isAiffFile {
if err := WriteAIFFTags(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
}
return successMethodJSON("native_aiff")
}
if isApeFile {
meta := audioMetadataFromEditFields(fields)
newItems := AudioMetadataToAPEItems(meta)
// If a cover image was provided, embed it as a binary APE item.
// APEv2 cover format: "cover.jpg\0<binary image data>", flagged binary.
if coverPath != "" {
coverData, coverErr := os.ReadFile(coverPath)
if coverErr == nil && len(coverData) > 0 {
// The value is "filename\0" + raw bytes. We store the
// description as the Value field, but since the item is
// flagged binary, the writer serializes it verbatim.
desc := "cover.jpg\x00"
binaryValue := desc + string(coverData)
newItems = append(newItems, APETagItem{
Key: "Cover Art (Front)",
Value: binaryValue,
Flags: apeItemFlagBinary,
})
}
}
// Build the set of APE keys that the edit explicitly controls.
// Even if the value is empty (user cleared the field), the old
// value must be removed during merge.
overrideKeys := apeKeysFromFields(fields)
if coverPath != "" {
overrideKeys["COVER ART (FRONT)"] = struct{}{}
}
// Read existing tags so we can merge rather than replace.
// This preserves cover art and custom items not in the edit set.
existingTag, _ := ReadAPETags(filePath)
var finalItems []APETagItem
if existingTag != nil && len(existingTag.Items) > 0 {
finalItems = MergeAPEItems(existingTag.Items, newItems, overrideKeys)
} else {
finalItems = newItems
}
tag := &APETag{
Version: apeTagVersion2,
Items: finalItems,
}
if err := WriteAPETags(filePath, tag); err != nil {
return "", fmt.Errorf("failed to write APE tags: %w", err)
}
return successMethodJSON("native_ape")
}
// MP3, Ogg/Opus, and M4A have native editors that preserve foreign
// tags and skip the ffmpeg remux. Any failure falls back to the ffmpeg
// response so callers keep the old behavior for exotic files.
isMp3 := strings.HasSuffix(lower, ".mp3")
isOggFile := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
if isMp3 {
if err := EditMP3Fields(filePath, fields); err != nil {
GoLog("[Metadata] Native MP3 edit failed, falling back to ffmpeg: %v\n", err)
} else {
return successMethodJSON("native_mp3")
}
}
if isOggFile {
if err := EditOggFields(filePath, fields); err != nil {
GoLog("[Metadata] Native Ogg edit failed, falling back to ffmpeg: %v\n", err)
} else {
return successMethodJSON("native_ogg")
}
}
if isM4AFile || isMP4ContainerFile(filePath) {
if err := EditM4AFields(filePath, fields); err != nil {
GoLog("[Metadata] Native M4A edit failed, falling back to ffmpeg: %v\n", err)
} else {
return successMethodJSON("native_m4a")
}
}
resp := map[string]any{
"success": true,
"method": "ffmpeg",
"fields": fields,
}
s, _ := marshalJSONString(resp)
return s, nil
}
func isMP4ContainerFile(filePath string) bool {
f, err := os.Open(filePath)
if err != nil {
return false
}
defer f.Close()
header := make([]byte, 12)
n, err := f.Read(header)
if err != nil || n < 8 {
return false
}
return string(header[4:8]) == "ftyp"
}
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
allowed := map[string]struct{}{
"replaygain_track_gain": {},
"replaygain_track_peak": {},
"replaygain_album_gain": {},
"replaygain_album_peak": {},
}
hasReplayGain := false
for key, value := range fields {
if strings.TrimSpace(value) == "" {
continue
}
if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok {
hasReplayGain = true
continue
}
return false
}
return hasReplayGain
}
// RewriteSplitArtistTagsExport rewrites ARTIST and ALBUMARTIST Vorbis
// comments in a FLAC file as multiple separate entries (one per artist).
// Call this after FFmpeg metadata embedding to fix split artist tags,
// since FFmpeg deduplicates -metadata keys and only keeps the last value.
func RewriteSplitArtistTagsExport(filePath, artist, albumArtist string) (string, error) {
err := RewriteSplitArtistTags(filePath, artist, albumArtist)
if err != nil {
return errorResponse("Failed to rewrite artist tags: " + err.Error())
}
resp := map[string]any{
"success": true,
"message": "Split artist tags written successfully",
}
s, _ := marshalJSONString(resp)
return s, nil
}
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
data, err := downloadCoverToMemory(coverURL, maxQuality)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Downloaded cover to: %s (%d KB)\n", outputPath, len(data)/1024)
return nil
}
func ExtractCoverToFile(audioPath string, outputPath string) error {
lower := strings.ToLower(audioPath)
var coverData []byte
var err error
if strings.HasSuffix(lower, ".flac") {
coverData, err = ExtractCoverArt(audioPath)
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
coverData, err = extractCoverFromM4A(audioPath)
} else if strings.HasSuffix(lower, ".mp3") {
coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
coverData, _, err = extractOggCoverArt(audioPath)
} else if strings.HasSuffix(lower, ".wav") ||
strings.HasSuffix(lower, ".aiff") ||
strings.HasSuffix(lower, ".aif") ||
strings.HasSuffix(lower, ".aifc") {
coverData, _, err = extractWAVAIFFCover(audioPath)
} else {
return fmt.Errorf("unsupported audio format for cover extraction")
}
if err != nil {
return fmt.Errorf("failed to extract cover: %w", err)
}
if err := os.WriteFile(outputPath, coverData, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Extracted cover art to: %s (%d KB)\n", outputPath, len(coverData)/1024)
return nil
}
+266
View File
@@ -0,0 +1,266 @@
package gobackend
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
// MusicBrainz lookups run in the per-track finalize stage and album batches
// repeat the same ISRC-adjacent questions; each miss costs up to 3 attempts
// with 2s sleeps. Positive results are stable (6h TTL); misses/errors expire
// quickly (10min) so a manual re-enrich can retry soon.
const (
musicBrainzCachePositiveTTL = 6 * time.Hour
musicBrainzCacheNegativeTTL = 10 * time.Minute
musicBrainzCacheMaxEntries = 256
)
type musicBrainzCacheEntry struct {
value string
err error
expiresAt time.Time
}
var (
musicBrainzCacheMu sync.Mutex
musicBrainzCache = make(map[string]musicBrainzCacheEntry)
)
func musicBrainzCached(key string, fetch func() (string, error)) (string, error) {
musicBrainzCacheMu.Lock()
if entry, ok := musicBrainzCache[key]; ok && time.Now().Before(entry.expiresAt) {
musicBrainzCacheMu.Unlock()
return entry.value, entry.err
}
musicBrainzCacheMu.Unlock()
value, err := fetch()
ttl := musicBrainzCachePositiveTTL
if err != nil || value == "" {
ttl = musicBrainzCacheNegativeTTL
}
musicBrainzCacheMu.Lock()
if len(musicBrainzCache) >= musicBrainzCacheMaxEntries {
now := time.Now()
for k, e := range musicBrainzCache {
if now.After(e.expiresAt) {
delete(musicBrainzCache, k)
}
}
if len(musicBrainzCache) >= musicBrainzCacheMaxEntries {
musicBrainzCache = make(map[string]musicBrainzCacheEntry)
}
}
musicBrainzCache[key] = musicBrainzCacheEntry{
value: value,
err: err,
expiresAt: time.Now().Add(ttl),
}
musicBrainzCacheMu.Unlock()
return value, err
}
type musicBrainzTag struct {
Count int `json:"count"`
Name string `json:"name"`
}
type musicBrainzRecordingResponse struct {
Recordings []struct {
Tags []musicBrainzTag `json:"tags"`
} `json:"recordings"`
}
type musicBrainzArtistCredit struct {
Name string `json:"name"`
JoinPhrase string `json:"joinphrase"`
}
type musicBrainzRelease struct {
Title string `json:"title"`
ArtistCredit []musicBrainzArtistCredit `json:"artist-credit"`
}
type musicBrainzAlbumArtistResponse struct {
Recordings []struct {
Releases []musicBrainzRelease `json:"releases"`
} `json:"recordings"`
}
func formatMusicBrainzGenre(tags []musicBrainzTag) string {
if len(tags) == 0 {
return ""
}
caser := cases.Title(language.English)
seen := make(map[string]struct{}, len(tags))
maxCount := -1
bestTag := ""
for _, tag := range tags {
name := strings.TrimSpace(tag.Name)
if name == "" {
continue
}
key := strings.ToLower(name)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
formatted := caser.String(name)
if tag.Count > maxCount {
maxCount = tag.Count
bestTag = formatted
}
}
return bestTag
}
func formatMusicBrainzArtistCredit(credits []musicBrainzArtistCredit) string {
var builder strings.Builder
for _, credit := range credits {
name := strings.TrimSpace(credit.Name)
if name == "" {
continue
}
builder.WriteString(name)
builder.WriteString(credit.JoinPhrase)
}
return strings.TrimSpace(builder.String())
}
func selectMusicBrainzAlbumArtist(releases []musicBrainzRelease, albumName string) string {
if len(releases) == 0 {
return ""
}
normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName))
if normalizedAlbum != "" {
for _, release := range releases {
if strings.ToLower(strings.TrimSpace(release.Title)) != normalizedAlbum {
continue
}
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
return albumArtist
}
}
}
for _, release := range releases {
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
return albumArtist
}
}
return ""
}
// fetchMusicBrainzRecordingByISRC queries the MusicBrainz recording endpoint
// for the given ISRC with the supplied inc= parameter, retrying up to 3 times,
// and decodes the JSON response into payload. It returns the normalized ISRC.
func fetchMusicBrainzRecordingByISRC(isrc string, inc string, payload any) (string, error) {
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
if normalizedISRC == "" {
return "", fmt.Errorf("no ISRC provided")
}
client := NewMetadataHTTPClient(10 * time.Second)
query := fmt.Sprintf("isrc:%s", normalizedISRC)
reqURL := fmt.Sprintf(
"%s/recording?query=%s&fmt=json&inc=%s",
musicBrainzAPIBase,
url.QueryEscape(query),
inc,
)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", getRandomUserAgent())
var resp *http.Response
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
resp, lastErr = client.Do(req)
if lastErr == nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close()
}
if attempt < 2 {
time.Sleep(2 * time.Second)
}
}
if lastErr != nil {
return "", lastErr
}
if resp == nil {
return "", fmt.Errorf("MusicBrainz request failed without response")
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(payload); err != nil {
return "", err
}
return normalizedISRC, nil
}
func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) {
key := "albumartist\x00" + strings.ToUpper(strings.TrimSpace(isrc)) +
"\x00" + strings.ToLower(strings.TrimSpace(albumName))
return musicBrainzCached(key, func() (string, error) {
var payload musicBrainzAlbumArtistResponse
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "releases+artist-credits", &payload)
if err != nil {
return "", err
}
for _, recording := range payload.Recordings {
if albumArtist := selectMusicBrainzAlbumArtist(recording.Releases, albumName); albumArtist != "" {
return albumArtist, nil
}
}
return "", fmt.Errorf("no MusicBrainz album artist found for ISRC: %s", normalizedISRC)
})
}
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
key := "genre\x00" + strings.ToUpper(strings.TrimSpace(isrc))
return musicBrainzCached(key, func() (string, error) {
var payload musicBrainzRecordingResponse
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "tags", &payload)
if err != nil {
return "", err
}
if len(payload.Recordings) == 0 {
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
}
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
if genre == "" {
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
}
return genre, nil
})
}
+793
View File
@@ -0,0 +1,793 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return GetDeezerClient().GetExtendedMetadataByISRC(ctx, isrc)
}
var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC
var fetchMusicBrainzAlbumArtistByISRC = FetchMusicBrainzAlbumArtistByISRC
type reEnrichRequest struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
LyricsMode string `json:"lyrics_mode,omitempty"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ReleaseDate string `json:"release_date"`
ISRC string `json:"isrc"`
Genre string `json:"genre"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Composer string `json:"composer"`
DurationMs int64 `json:"duration_ms"`
SearchOnline bool `json:"search_online"`
UpdateFields []string `json:"update_fields,omitempty"`
// ReplaceReleaseMetadata lets a deliberate single-file re-enrich action
// repair a stale album identity (for example, a playlist name stored as
// ALBUM). Batch and older callers keep the conservative mismatch guard.
ReplaceReleaseMetadata bool `json:"replace_release_metadata,omitempty"`
}
// shouldUpdateField returns true if the given field group should be updated.
// When UpdateFields is empty/nil, all fields are updated (backward compatible).
func (r *reEnrichRequest) shouldUpdateField(field string) bool {
if len(r.UpdateFields) == 0 {
return true
}
for _, f := range r.UpdateFields {
if f == field {
return true
}
}
return false
}
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
// callers that do not send lyrics_mode are unaffected.
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
}
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
// next to the audio file. Only 'external' and 'both' request a sidecar.
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
return mode == "external" || mode == "both"
}
// reEnrichSameRelease reports whether the candidate track appears to come
// from the same release as the file's existing album. An ISRC identifies a
// recording, not a release: the same song often also resolves to a
// compilation, whose album name, cover, and track positions must not
// replace the original release's.
func reEnrichSameRelease(currentAlbum, candidateAlbum string) bool {
if isPlaceholderReEnrichValue(currentAlbum) ||
strings.TrimSpace(candidateAlbum) == "" {
return true
}
return titlesMatch(currentAlbum, candidateAlbum)
}
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
if req == nil {
return
}
albumMatches := reEnrichSameRelease(req.AlbumName, track.AlbumName)
sameRelease := req.ReplaceReleaseMetadata || albumMatches
if !sameRelease {
GoLog("[ReEnrich] Candidate album %q differs from file album %q; keeping release identity (album, cover, positions, date)\n",
track.AlbumName, req.AlbumName)
} else if req.ReplaceReleaseMetadata && !albumMatches {
GoLog("[ReEnrich] Candidate album %q differs from file album %q; replacing release identity as requested\n",
track.AlbumName, req.AlbumName)
}
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else if track.QobuzID != "" {
req.SpotifyID = "qobuz:" + track.QobuzID
} else if track.TidalID != "" {
req.SpotifyID = "tidal:" + track.TidalID
} else if track.ID != "" {
req.SpotifyID = track.ID
}
if req.shouldUpdateField("basic_tags") {
if track.Name != "" {
req.TrackName = track.Name
}
if track.Artists != "" {
req.ArtistName = track.Artists
}
if sameRelease {
if track.AlbumName != "" {
req.AlbumName = track.AlbumName
}
if track.AlbumArtist != "" {
req.AlbumArtist = track.AlbumArtist
}
}
}
if sameRelease && req.shouldUpdateField("track_info") {
if track.TrackNumber > 0 {
req.TrackNumber = track.TrackNumber
}
if track.TotalTracks > 0 {
req.TotalTracks = track.TotalTracks
}
if track.DiscNumber > 0 {
req.DiscNumber = track.DiscNumber
}
if track.TotalDiscs > 0 {
req.TotalDiscs = track.TotalDiscs
}
}
if req.shouldUpdateField("release_info") {
if sameRelease && track.ReleaseDate != "" {
req.ReleaseDate = track.ReleaseDate
}
if track.ISRC != "" {
req.ISRC = track.ISRC
}
}
if sameRelease && req.shouldUpdateField("cover") {
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
req.CoverURL = coverURL
}
}
if track.DurationMS > 0 {
req.DurationMs = int64(track.DurationMS)
}
if req.shouldUpdateField("extra") {
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
if track.Composer != "" {
req.Composer = track.Composer
}
}
}
func isPlaceholderReEnrichValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "unknown", "unknown artist", "unknown title", "unknown album":
return true
default:
return false
}
}
func buildReEnrichSearchQuery(req reEnrichRequest) string {
parts := make([]string, 0, 2)
if !isPlaceholderReEnrichValue(req.TrackName) {
parts = append(parts, strings.TrimSpace(req.TrackName))
}
if !isPlaceholderReEnrichValue(req.ArtistName) {
parts = append(parts, strings.TrimSpace(req.ArtistName))
}
if len(parts) == 0 && !isPlaceholderReEnrichValue(req.AlbumName) {
parts = append(parts, strings.TrimSpace(req.AlbumName))
}
return strings.TrimSpace(strings.Join(parts, " "))
}
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
return DownloadRequest{
TrackName: req.TrackName,
ArtistName: req.ArtistName,
AlbumName: req.AlbumName,
ReleaseDate: req.ReleaseDate,
ISRC: req.ISRC,
DurationMS: int(req.DurationMs),
ArtistTagMode: req.ArtistTagMode,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
TotalDiscs: req.TotalDiscs,
Composer: req.Composer,
}
}
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
metadata := map[string]string{}
if req.shouldUpdateField("basic_tags") {
if req.TrackName != "" {
metadata["TITLE"] = req.TrackName
}
if req.ArtistName != "" {
metadata["ARTIST"] = req.ArtistName
}
if req.AlbumName != "" {
metadata["ALBUM"] = req.AlbumName
}
if req.AlbumArtist != "" {
metadata["ALBUMARTIST"] = req.AlbumArtist
}
}
if req.shouldUpdateField("release_info") {
if req.ReleaseDate != "" {
metadata["DATE"] = req.ReleaseDate
}
if req.ISRC != "" {
metadata["ISRC"] = req.ISRC
}
}
if req.shouldUpdateField("extra") {
if req.Genre != "" {
metadata["GENRE"] = req.Genre
}
if req.Label != "" {
metadata["ORGANIZATION"] = req.Label
}
if req.Copyright != "" {
metadata["COPYRIGHT"] = req.Copyright
}
if req.Composer != "" {
metadata["COMPOSER"] = req.Composer
}
}
if req.shouldUpdateField("track_info") {
if req.TrackNumber > 0 {
metadata["TRACKNUMBER"] = formatIndexValue(req.TrackNumber, req.TotalTracks)
}
if req.DiscNumber > 0 {
metadata["DISCNUMBER"] = formatIndexValue(req.DiscNumber, req.TotalDiscs)
}
}
if req.shouldUpdateField("lyrics") {
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
metadata["LYRICS"] = lyricsLRC
metadata["UNSYNCEDLYRICS"] = lyricsLRC
}
}
return metadata
}
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
if len(tracks) == 0 {
return nil
}
downloadReq := reEnrichDownloadRequest(req)
currentISRC := strings.TrimSpace(req.ISRC)
currentAlbum := strings.TrimSpace(req.AlbumName)
effectiveTrackName := req.TrackName
if isPlaceholderReEnrichValue(effectiveTrackName) {
effectiveTrackName = ""
}
effectiveArtistName := req.ArtistName
if isPlaceholderReEnrichValue(effectiveArtistName) {
effectiveArtistName = ""
}
var best *ExtTrackMetadata
bestScore := -1 << 30
for i := range tracks {
track := &tracks[i]
score := 0
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
if !exactISRCMatch {
if effectiveTrackName != "" && !titleMatches {
continue
}
if effectiveArtistName != "" && !artistMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
continue
}
}
if verified {
score += 2000
}
if exactISRCMatch {
score += 10000
}
if titleMatches {
score += 400
}
if artistMatches {
score += 320
}
if currentAlbum != "" && track.AlbumName != "" {
switch {
case albumMatches:
score += 120
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
score += 50
}
}
if req.DurationMs > 0 && track.DurationMS > 0 {
diff := int(req.DurationMs/1000) - (track.DurationMS / 1000)
if diff < 0 {
diff = -diff
}
if diff <= 10 {
score += 80
}
}
if track.ReleaseDate != "" {
score += 70
}
if track.TrackNumber > 0 {
score += 20
}
if track.DiscNumber > 0 {
score += 10
}
if track.ISRC != "" {
score += 40
}
if best == nil || score > bestScore {
best = track
bestScore = score
}
}
return best
}
func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrackMetadata {
if track == nil {
return nil
}
deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:"))
return &ExtTrackMetadata{
ID: track.SpotifyID,
Name: track.Name,
Artists: track.Artists,
AlbumName: track.AlbumName,
AlbumArtist: track.AlbumArtist,
DurationMS: track.DurationMS,
CoverURL: track.Images,
Images: track.Images,
ReleaseDate: track.ReleaseDate,
TrackNumber: track.TrackNumber,
TotalTracks: track.TotalTracks,
DiscNumber: track.DiscNumber,
TotalDiscs: track.TotalDiscs,
ISRC: track.ISRC,
ProviderID: providerID,
DeezerID: deezerID,
SpotifyID: track.SpotifyID,
Composer: track.Composer,
Explicit: track.Explicit,
}
}
func normalizeReEnrichSpotifyTrackID(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if extracted := extractSpotifyIDFromURL(trimmed); extracted != "" {
return extracted
}
if len(trimmed) == 22 && !strings.Contains(trimmed, ":") && !strings.Contains(trimmed, "/") {
return trimmed
}
return ""
}
func resolveReEnrichTrackFromIdentifiers(req reEnrichRequest) (*ExtTrackMetadata, error) {
deezerClient := GetDeezerClient()
downloadReq := reEnrichDownloadRequest(req)
if isrc := strings.TrimSpace(req.ISRC); isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
track, err := deezerClient.SearchByISRC(ctx, isrc)
cancel()
if err == nil && track != nil {
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
return extTrackFromTrackMetadata(track, "deezer"), nil
}
}
}
sourceTrackID := strings.TrimSpace(req.SpotifyID)
if sourceTrackID == "" {
return nil, nil
}
deezerID := strings.TrimSpace(strings.TrimPrefix(sourceTrackID, "deezer:"))
if deezerID == sourceTrackID {
deezerID = extractDeezerIDFromURL(sourceTrackID)
}
if deezerID == "" {
spotifyID := normalizeReEnrichSpotifyTrackID(sourceTrackID)
if spotifyID != "" {
resolvedDeezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyID)
if err == nil {
deezerID = strings.TrimSpace(resolvedDeezerID)
}
}
}
if deezerID == "" {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil || trackResp == nil {
return nil, err
}
track := &trackResp.Track
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if !trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
return nil, nil
}
return extTrackFromTrackMetadata(track, "deezer"), nil
}
func preferredReleaseMetadata(
req DownloadRequest,
album string,
releaseDate string,
trackNumber int,
discNumber int,
) (string, string, int, int) {
preferredAlbum := strings.TrimSpace(req.AlbumName)
if preferredAlbum == "" {
preferredAlbum = album
}
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
if preferredReleaseDate == "" {
preferredReleaseDate = releaseDate
}
preferredTrackNumber := req.TrackNumber
if preferredTrackNumber == 0 {
preferredTrackNumber = trackNumber
}
preferredDiscNumber := req.DiscNumber
if preferredDiscNumber == 0 {
preferredDiscNumber = discNumber
}
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
}
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
// complete metadata from the internet before embedding.
func ReEnrichFile(requestJSON string) (string, error) {
var req reEnrichRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", fmt.Errorf("failed to parse request: %w", err)
}
if req.FilePath == "" {
return "", fmt.Errorf("file_path is required")
}
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
if req.SearchOnline {
found := false
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
manager := getExtensionManager()
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
applyReEnrichTrackMetadata(&req, *identifierTrack)
found = true
}
searchQuery := buildReEnrichSearchQuery(req)
if searchQuery != "" {
GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery)
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
if searchErr == nil && len(tracks) > 0 {
track := selectBestReEnrichTrack(req, tracks)
if track != nil {
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
applyReEnrichTrackMetadata(&req, *track)
found = true
}
} else if searchErr != nil {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
}
} else {
GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
}
if req.shouldUpdateField("basic_tags") && req.AlbumArtist == "" && req.ISRC != "" {
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
if err != nil {
GoLog("[ReEnrich] Failed to get album artist from MusicBrainz: %v\n", err)
} else if strings.TrimSpace(albumArtist) != "" {
req.AlbumArtist = strings.TrimSpace(albumArtist)
GoLog("[ReEnrich] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
found = true
}
}
// Try to enrich extra metadata from ISRC if not already set.
if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
}
if !found {
GoLog("[ReEnrich] No online match found, using existing metadata\n")
}
}
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
lower := strings.ToLower(req.FilePath)
isFlac := strings.HasSuffix(lower, ".flac")
var coverTempPath string
var coverDataBytes []byte
if req.CoverURL != "" && req.shouldUpdateField("cover") {
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
if err != nil {
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
} else {
coverDataBytes = coverData
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
// MP3/Opus requires a real image file path for Dart FFmpeg.
// FLAC uses in-memory embed and does not require temp files.
if !isFlac {
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
if err != nil {
fallbackDir := filepath.Dir(req.FilePath)
if fallbackDir == "" || fallbackDir == "." {
GoLog("[ReEnrich] Failed to create cover temp file: %v\n", err)
} else {
tmpFile, err = os.CreateTemp(fallbackDir, "reenrich_cover_*.jpg")
if err != nil {
GoLog("[ReEnrich] Failed to create cover temp file (fallback dir %s): %v\n", fallbackDir, err)
}
}
}
if err == nil && tmpFile != nil {
coverTempPath = tmpFile.Name()
if _, writeErr := tmpFile.Write(coverData); writeErr != nil {
GoLog("[ReEnrich] Failed writing cover temp file: %v\n", writeErr)
tmpFile.Close()
os.Remove(coverTempPath)
coverTempPath = ""
} else if closeErr := tmpFile.Close(); closeErr != nil {
GoLog("[ReEnrich] Failed closing cover temp file: %v\n", closeErr)
os.Remove(coverTempPath)
coverTempPath = ""
}
}
}
}
}
// Only cleanup cover temp for FLAC (native embed).
// For MP3/Opus, Dart needs the file for FFmpeg — Dart handles cleanup.
cleanupCover := true
defer func() {
if cleanupCover && coverTempPath != "" {
os.Remove(coverTempPath)
}
}()
// Preserve existing lyrics when online enrichment does not return a replacement.
var lyricsLRC string
if req.shouldUpdateField("lyrics") {
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
lyricsLRC = existingLyrics
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
}
}
if req.EmbedLyrics && req.shouldUpdateField("lyrics") {
client := NewLyricsClient()
durationSec := float64(req.DurationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec)
if err != nil {
GoLog("[ReEnrich] Lyrics not found: %v\n", err)
} else if !lyrics.Instrumental {
lyricsLRC = convertToLRCWithMetadata(lyrics, req.TrackName, req.ArtistName)
GoLog("[ReEnrich] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
GoLog("[ReEnrich] Track is instrumental\n")
}
}
// Build enrichedMeta map: only include fields from selected update groups
// so that the caller (Dart) does not overwrite non-selected metadata in its
// local library database with potentially stale cached values.
enrichedMeta := map[string]any{
"spotify_id": req.SpotifyID,
"duration_ms": req.DurationMs,
}
if req.shouldUpdateField("basic_tags") {
enrichedMeta["track_name"] = req.TrackName
enrichedMeta["artist_name"] = req.ArtistName
enrichedMeta["album_name"] = req.AlbumName
enrichedMeta["album_artist"] = req.AlbumArtist
}
if req.shouldUpdateField("track_info") {
enrichedMeta["track_number"] = req.TrackNumber
enrichedMeta["total_tracks"] = req.TotalTracks
enrichedMeta["disc_number"] = req.DiscNumber
enrichedMeta["total_discs"] = req.TotalDiscs
}
if req.shouldUpdateField("release_info") {
enrichedMeta["release_date"] = req.ReleaseDate
enrichedMeta["isrc"] = req.ISRC
}
if req.shouldUpdateField("cover") {
enrichedMeta["cover_url"] = req.CoverURL
}
if req.shouldUpdateField("extra") {
enrichedMeta["genre"] = req.Genre
enrichedMeta["label"] = req.Label
enrichedMeta["copyright"] = req.Copyright
enrichedMeta["composer"] = req.Composer
}
if isFlac {
// 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.
metadata := Metadata{
ArtistTagMode: req.ArtistTagMode,
}
if req.shouldUpdateField("basic_tags") {
metadata.Title = req.TrackName
metadata.Artist = req.ArtistName
metadata.Album = req.AlbumName
metadata.AlbumArtist = req.AlbumArtist
}
if req.shouldUpdateField("track_info") {
metadata.TrackNumber = req.TrackNumber
metadata.TotalTracks = req.TotalTracks
metadata.DiscNumber = req.DiscNumber
metadata.TotalDiscs = req.TotalDiscs
}
if req.shouldUpdateField("release_info") {
metadata.Date = req.ReleaseDate
metadata.ISRC = req.ISRC
}
if req.shouldUpdateField("lyrics") {
if req.lyricsEmbedEnabled() {
metadata.Lyrics = lyricsLRC
}
}
if req.shouldUpdateField("extra") {
metadata.Genre = req.Genre
metadata.Label = req.Label
metadata.Copyright = req.Copyright
metadata.Composer = req.Composer
}
if len(coverDataBytes) > 0 {
if err := EmbedMetadataWithCoverData(req.FilePath, metadata, coverDataBytes); err != nil {
return "", fmt.Errorf("failed to embed metadata with cover: %w", err)
}
} else {
if err := EmbedMetadata(req.FilePath, metadata, ""); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
}
if len(coverDataBytes) > 0 {
embeddedCover, err := ExtractCoverArt(req.FilePath)
if err != nil || len(embeddedCover) == 0 {
if err != nil {
return "", fmt.Errorf("metadata embedded but cover verification failed: %w", err)
}
return "", fmt.Errorf("metadata embedded but cover verification failed: empty embedded cover")
}
GoLog("[ReEnrich] Cover verified after embed (%d bytes)\n", len(embeddedCover))
}
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
result := map[string]any{
"method": "native",
"success": true,
"enriched_metadata": enrichedMeta,
"lyrics": lyricsLRC,
"write_external_lrc": req.EmbedLyrics &&
req.shouldUpdateField("lyrics") &&
req.lyricsSidecarEnabled() &&
strings.TrimSpace(lyricsLRC) != "",
}
s, _ := marshalJSONString(result)
return s, nil
}
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false
ffmpegMetadata := buildReEnrichFFmpegMetadata(&req, lyricsLRC)
result := map[string]any{
"method": "ffmpeg",
"cover_path": coverTempPath,
"lyrics": lyricsLRC,
"enriched_metadata": enrichedMeta,
"metadata": ffmpegMetadata,
"write_external_lrc": req.EmbedLyrics &&
req.shouldUpdateField("lyrics") &&
req.lyricsSidecarEnabled() &&
strings.TrimSpace(lyricsLRC) != "",
}
s, _ := marshalJSONString(result)
return s, nil
}
+148
View File
@@ -0,0 +1,148 @@
package gobackend
import (
"fmt"
"net/url"
"path/filepath"
"strings"
)
func InitExtensionRepoJSON(cacheDir string) error {
initExtensionRepo(cacheDir)
return nil
}
func SetRepoRegistryURLJSON(registryURL string) error {
repo := getExtensionRepo()
if repo == nil {
return fmt.Errorf("extension repo not initialized")
}
resolved, err := resolveRegistryURL(registryURL)
if err != nil {
return err
}
if err := requireHTTPSURL(resolved, "registry"); err != nil {
return err
}
repo.setRegistryURL(resolved)
return nil
}
func ClearRepoRegistryURLJSON() error {
repo := getExtensionRepo()
if repo == nil {
return fmt.Errorf("extension repo not initialized")
}
repo.setRegistryURL("")
repo.clearCache()
return nil
}
func GetRepoRegistryURLJSON() (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
return repo.getRegistryURL(), nil
}
func GetRepoExtensionsJSON(forceRefresh bool) (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
extensions, err := repo.getExtensionsWithStatus(forceRefresh)
if err != nil {
return "", err
}
return marshalJSONString(extensions)
}
func SearchRepoExtensionsJSON(query, category string) (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
extensions, err := repo.searchExtensions(query, category)
if err != nil {
return "", err
}
return marshalJSONString(extensions)
}
func GetRepoCategoriesJSON() (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
categories := repo.getCategories()
return marshalJSONString(categories)
}
func repoExtensionPackageSuffix(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 buildRepoExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) {
if strings.TrimSpace(extensionID) == "" {
return "", fmt.Errorf("invalid extension id")
}
safeExtensionID := sanitizeFilename(extensionID)
return filepath.Join(destDir, safeExtensionID+repoExtensionPackageSuffix(downloadURL)), nil
}
func DownloadRepoExtensionJSON(extensionID, destDir string) (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
ext, err := repo.findExtension(extensionID)
if err != nil {
return "", err
}
destPath, err := buildRepoExtensionDestPath(destDir, extensionID, ext.getDownloadURL())
if err != nil {
return "", err
}
err = repo.downloadExtension(extensionID, destPath)
if err != nil {
return "", err
}
return destPath, nil
}
func ClearRepoCacheJSON() error {
repo := getExtensionRepo()
if repo == nil {
return fmt.Errorf("extension repo not initialized")
}
repo.clearCache()
return nil
}
@@ -1,7 +1,6 @@
package gobackend
import (
"context"
"io"
"net/http"
"os"
@@ -21,9 +20,6 @@ func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
t.Fatal(err)
}
if jsonText, err := FetchLyrics("spotify-1", "Song Instrumental", "Artist", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
t.Fatalf("FetchLyrics instrumental = %q/%v", jsonText, err)
}
if lrc, err := GetLyricsLRC("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || lrc != "[instrumental:true]" {
t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err)
}
@@ -52,16 +48,42 @@ func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
}
}
func TestLyricsExportWrappersRejectMetadataOnlySidecar(t *testing.T) {
dir := t.TempDir()
audioPath := filepath.Join(dir, "metadata-only.mp3")
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
metadataOnly := "[ti:Title]\n[ar:Artist]\n[al:Album]\n[by:SpotiFLAC Mobile]"
if err := os.WriteFile(filepath.Join(dir, "metadata-only.lrc"), []byte(metadataOnly), 0600); err != nil {
t.Fatal(err)
}
if rawLyricsHasUsableContent(metadataOnly) {
t.Fatal("metadata-only LRC must not be considered usable")
}
if !rawLyricsHasUsableContent("[00:01.00]Actual lyric") {
t.Fatal("timed lyric must be considered usable")
}
if !rawLyricsHasUsableContent("[instrumental:true]") {
t.Fatal("instrumental marker must be considered usable")
}
if lrc, err := GetLyricsLRC("", "", "", audioPath, 0); err != nil || lrc != "" {
t.Fatalf("GetLyricsLRC metadata-only sidecar = %q/%v", lrc, err)
}
if jsonText, err := GetLyricsLRCWithSource("", "", "", audioPath, 0); err != nil ||
!strings.Contains(jsonText, `"lyrics":""`) || strings.Contains(jsonText, `"source":"Embedded"`) {
t.Fatalf("GetLyricsLRCWithSource metadata-only sidecar = %q/%v", jsonText, err)
}
}
func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
origClient := globalSongLinkClient
origRetryConfig := songLinkRetryConfig
origSearchByISRC := songLinkSearchByISRC
origCheckFromDeezer := songLinkCheckAvailabilityFromDeezer
defer func() {
globalSongLinkClient = origClient
songLinkRetryConfig = origRetryConfig
songLinkSearchByISRC = origSearchByISRC
songLinkCheckAvailabilityFromDeezer = origCheckFromDeezer
SetSongLinkNetworkOptions(false, false)
}()
songLinkRetryConfig = func() RetryConfig {
@@ -69,11 +91,12 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
}
globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
var body string
if req.URL.Host == "api.zarz.moe" {
switch req.URL.Host {
case "api.zarz.moe":
body = `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/spotify-1","Deezer":"https://www.deezer.com/track/101","Tidal":"https://listen.tidal.com/track/202","YouTube":"https://youtu.be/yt1","AmazonMusic":"https://music.amazon.com/tracks/amz1","Qobuz":"https://open.qobuz.com/track/303"}}`
} else if req.URL.Host == "api.song.link" {
case "api.song.link":
body = `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}`
} else {
default:
t.Fatalf("unexpected SongLink request: %s", req.URL.String())
}
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
@@ -81,15 +104,6 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
songLinkClientOnce.Do(func() {})
SetSongLinkNetworkOptions(true, true)
if availabilityJSON, err := CheckAvailability("spotify-1", ""); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
t.Fatalf("CheckAvailability = %q/%v", availabilityJSON, err)
}
if availabilityJSON, err := CheckAvailabilityFromDeezerID("101"); err != nil || !strings.Contains(availabilityJSON, `"spotify_id":"spotify-1"`) {
t.Fatalf("CheckAvailabilityFromDeezerID = %q/%v", availabilityJSON, err)
}
if availabilityJSON, err := CheckAvailabilityByPlatformID("deezer", "song", "101"); err != nil || !strings.Contains(availabilityJSON, `"tidal_url"`) {
t.Fatalf("CheckAvailabilityByPlatformID = %q/%v", availabilityJSON, err)
}
if spotifyID, err := GetSpotifyIDFromDeezerTrack("101"); err != nil || spotifyID != "spotify-1" {
t.Fatalf("GetSpotifyIDFromDeezerTrack = %q/%v", spotifyID, err)
}
@@ -121,19 +135,23 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
t.Fatalf("CheckAvailabilityFromURL = %#v/%v", availability, err)
}
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
return &TrackMetadata{SpotifyID: "deezer:101", ExternalURL: "https://www.deezer.com/track/101"}, nil
}
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
return &TrackAvailability{SpotifyID: "spotify-1", Deezer: true, DeezerID: deezerTrackID}, nil
}
if availabilityJSON, err := CheckAvailability("", "USRC17607839"); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
t.Fatalf("CheckAvailability by ISRC = %q/%v", availabilityJSON, err)
}
if songLinkExtractDeezerTrackID(nil) != "" || songLinkExtractDeezerTrackID(&TrackMetadata{ExternalURL: "https://www.deezer.com/track/202"}) != "202" {
t.Fatal("songLinkExtractDeezerTrackID mismatch")
}
if linksJSON, err := GetTrackPlatformLinksJSON("spotify-1", ""); err != nil ||
!strings.Contains(linksJSON, `"tidal":"https://listen.tidal.com/track/202"`) ||
!strings.Contains(linksJSON, `"spotify":`) {
t.Fatalf("GetTrackPlatformLinksJSON = %q/%v", linksJSON, err)
}
// Second call must come from the links cache, not a new request.
if cached, hit, cachedErr := trackPlatformLinksCacheLookup(GetSongLinkRegion() + "|spotify:spotify-1"); !hit || cachedErr || cached["tidal"] == "" {
t.Fatalf("trackPlatformLinksCacheLookup = %#v hit=%v err=%v", cached, hit, cachedErr)
}
if _, err := GetTrackPlatformLinksJSON("", ""); err == nil {
t.Fatal("GetTrackPlatformLinksJSON with empty IDs should error")
}
deezerClient = &DeezerClient{
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
+165 -70
View File
@@ -33,9 +33,8 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
cases := []string{
"HTTP 401 for /tickets",
"HTTP status 428: precondition required",
"Verification required",
"verification_required: canonical gateway challenge",
"signed session expired",
}
for _, tc := range cases {
if got := classifyDownloadErrorType(tc); got != "verification_required" {
@@ -44,6 +43,76 @@ func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
}
}
func TestDownloadErrorClassificationDoesNotInferVerificationFromHTTPStatus(t *testing.T) {
cases := []string{
"HTTP 401 for /tickets",
"HTTP 403 forbidden",
"HTTP status 428: precondition required",
"Provider returned unauthorized",
"VERIFY_REQUIRED without canonical origin and action",
"Verification required without a typed contract",
}
for _, tc := range cases {
if got := classifyDownloadErrorType(tc); got == "verification_required" {
t.Fatalf("classifyDownloadErrorType(%q) inferred verification from an ambiguous status", tc)
}
}
}
func TestDownloadErrorClassificationPreservesProviderContracts(t *testing.T) {
tests := map[string]string{
"PROVIDER_AUTH_FAILED": "provider_auth_failed",
"PROVIDER_UNAVAILABLE": "provider_unavailable",
"REQUEST_AUTH_INVALID": "request_auth_invalid",
"BYOA_PROVIDER_REAUTH_REQUIRED action": "provider_reauth_required",
}
for message, want := range tests {
if got := classifyDownloadErrorType(message); got != want {
t.Fatalf("classifyDownloadErrorType(%q) = %q, want %q", message, got, want)
}
}
}
func TestOutputStorageWriteFailureDetection(t *testing.T) {
cases := []struct {
name string
errorType string
message string
want bool
}{
{
name: "typed permission failure",
errorType: "permission",
message: "backend omitted details",
want: true,
},
{
name: "android operation not permitted",
message: "failed to create file: open /storage/song.partial: operation not permitted",
want: true,
},
{
name: "read only destination",
message: "open /music/song.flac: read-only file system",
want: true,
},
{
name: "provider API error",
errorType: "api_error",
message: "HTTP 404 for /download",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isOutputStorageWriteFailure(tc.errorType, tc.message); got != tc.want {
t.Fatalf("isOutputStorageWriteFailure(%q, %q) = %v, want %v", tc.errorType, tc.message, got, tc.want)
}
})
}
}
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
dir := t.TempDir()
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
@@ -69,6 +138,75 @@ func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
}
}
func TestExtensionTrackExportsPreserveExplicitFlag(t *testing.T) {
dir := t.TempDir()
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
manager := getExtensionManager()
manager.mu.Lock()
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
manager.mu.Unlock()
defer CleanupExtensions()
assertExplicit := func(name, jsonText string, err error) {
t.Helper()
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if !strings.Contains(jsonText, `"explicit":true`) {
t.Fatalf("%s dropped explicit flag: %s", name, jsonText)
}
}
jsonText, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`)
assertExplicit("custom search", jsonText, err)
for _, resourceType := range []string{"track", "album", "playlist", "artist"} {
jsonText, err = GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1")
assertExplicit("provider metadata "+resourceType, jsonText, err)
}
jsonText, err = HandleURLWithExtensionJSON("https://example.test/track/1")
assertExplicit("URL handler", jsonText, err)
}
func TestSearchTracksWithMetadataProviderUsesOnlySelectedExtension(t *testing.T) {
dir := t.TempDir()
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
selected := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
selected.ID = "selected-metadata"
selected.Manifest.Name = selected.ID
other := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
other.ID = "other-metadata"
other.Manifest.Name = other.ID
manager := getExtensionManager()
manager.mu.Lock()
manager.extensions = map[string]*loadedExtension{
selected.ID: selected,
other.ID: other,
}
manager.mu.Unlock()
defer CleanupExtensions()
jsonText, err := SearchTracksWithMetadataProviderJSON(selected.ID, "needle", 5)
if err != nil {
t.Fatalf("SearchTracksWithMetadataProviderJSON: %v", err)
}
if !strings.Contains(jsonText, `"provider_id":"selected-metadata"`) {
t.Fatalf("expected selected provider attribution, got %s", jsonText)
}
if strings.Contains(jsonText, `"provider_id":"other-metadata"`) {
t.Fatalf("unexpected fallback to another provider: %s", jsonText)
}
}
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
@@ -91,24 +229,17 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
manager.mu.Unlock()
}()
if response, err := DownloadTrack(`{}`); err != nil || !strings.Contains(response, "retired") {
t.Fatalf("DownloadTrack = %q/%v", response, err)
}
if response, err := DownloadByStrategy(`not-json`); err != nil || !strings.Contains(response, "Invalid request") {
t.Fatalf("DownloadByStrategy invalid = %q/%v", response, err)
}
if response, err := DownloadByStrategy(`{"use_extensions":false}`); err != nil || !strings.Contains(response, "disabled") {
t.Fatalf("DownloadByStrategy disabled = %q/%v", response, err)
}
if response, err := DownloadWithFallback(`{}`); err != nil || !strings.Contains(response, "retired") {
t.Fatalf("DownloadWithFallback = %q/%v", response, err)
}
InitItemProgress("item-1")
FinishItemProgress("item-1")
ClearItemProgress("item-1")
CancelDownload("item-1")
if GetDownloadProgress() == "" || GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
if GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
t.Fatal("expected progress JSON")
}
CleanupConnections()
@@ -165,9 +296,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if err := SetDownloadDirectory(dir); err != nil {
t.Fatalf("SetDownloadDirectory: %v", err)
}
if duplicateJSON, err := CheckDuplicate(dir, ""); err != nil || !strings.Contains(duplicateJSON, "exists") {
t.Fatalf("CheckDuplicate = %q/%v", duplicateJSON, err)
}
if batchJSON, err := CheckDuplicatesBatch(dir, `[{"isrc":"","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(batchJSON, "Song") {
t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err)
}
@@ -183,12 +311,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
t.Fatalf("SanitizeFilename = %q", got)
}
if response, err := PreWarmTrackCacheJSON(`not-json`); err != nil || !strings.Contains(response, "Invalid JSON") {
t.Fatalf("PreWarmTrackCacheJSON invalid = %q/%v", response, err)
}
if response, err := PreWarmTrackCacheJSON(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(response, "success") {
t.Fatalf("PreWarmTrackCacheJSON = %q/%v", response, err)
}
if GetTrackCacheSize() != 0 {
t.Fatal("expected empty track cache")
}
@@ -219,9 +341,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if err := SetExtensionFallbackProviderIDsJSON(`["coverage-ext"]`); err != nil {
t.Fatalf("SetExtensionFallbackProviderIDsJSON: %v", err)
}
if jsonText, err := GetExtensionFallbackProviderIDsJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
t.Fatalf("GetExtensionFallbackProviderIDsJSON = %q/%v", jsonText, err)
}
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
t.Fatalf("reset extension fallback IDs: %v", err)
}
@@ -242,9 +361,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
t.Fatal("expected settings JSON error")
}
if jsonText, err := SearchTracksWithExtensionsJSON("song", 5); err != nil || !strings.Contains(jsonText, "search-1") {
t.Fatalf("SearchTracksWithExtensionsJSON = %q/%v", jsonText, err)
}
if jsonText, err := SearchTracksWithMetadataProvidersJSON("song", 5, true); err != nil || !strings.Contains(jsonText, "search-1") {
t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err)
}
@@ -342,9 +458,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if _, err := GetDeezerMetadata("bad", "1"); err == nil {
t.Fatal("expected unsupported Deezer metadata type")
}
if jsonText, err := GetDeezerRelatedArtists("301", 2); err != nil || !strings.Contains(jsonText, "Related") {
t.Fatalf("GetDeezerRelatedArtists = %q/%v", jsonText, err)
}
if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") {
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
}
@@ -365,48 +478,33 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if customJSON, err := CustomSearchWithExtensionJSONWithRequestID(ext.ID, "needle", `not-json`, "req-custom"); err != nil || !strings.Contains(customJSON, "custom-1") {
t.Fatalf("CustomSearchWithExtensionJSONWithRequestID = %q/%v", customJSON, err)
}
if providersJSON, err := GetSearchProvidersJSON(); err != nil || !strings.Contains(providersJSON, "coverage-ext") {
t.Fatalf("GetSearchProvidersJSON = %q/%v", providersJSON, err)
}
if found := FindURLHandlerJSON("https://example.test/track/1"); found != ext.ID {
t.Fatalf("FindURLHandlerJSON = %q", found)
}
if handlersJSON, err := GetURLHandlersJSON(); err != nil || !strings.Contains(handlersJSON, "coverage-ext") {
t.Fatalf("GetURLHandlersJSON = %q/%v", handlersJSON, err)
}
if handledJSON, err := HandleURLWithExtensionJSON("https://example.test/track/1"); err != nil || !strings.Contains(handledJSON, "url-track") {
t.Fatalf("HandleURLWithExtensionJSON = %q/%v", handledJSON, err)
}
if postJSON, err := RunPostProcessingJSON(filepath.Join(dir, "song.flac"), `{"title":"Song"}`); err != nil || !strings.Contains(postJSON, "success") {
t.Fatalf("RunPostProcessingJSON = %q/%v", postJSON, err)
}
v2Input := `{"path":"` + escapeJSONPath(filepath.Join(dir, "song.flac")) + `","uri":"content://song","name":"song.flac","mime_type":"audio/flac","size":10}`
if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") {
t.Fatalf("RunPostProcessingV2JSON = %q/%v", postJSON, err)
}
if postProviders, err := GetPostProcessingProvidersJSON(); err != nil || !strings.Contains(postProviders, "hook") {
t.Fatalf("GetPostProcessingProvidersJSON = %q/%v", postProviders, err)
}
if feedJSON, err := GetExtensionHomeFeedJSON(ext.ID); err != nil || !strings.Contains(feedJSON, "home-1") {
t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err)
}
if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") {
t.Fatalf("GetExtensionHomeFeedJSONWithRequestID = %q/%v", feedJSON, err)
}
if categoriesJSON, err := GetExtensionBrowseCategoriesJSON(ext.ID); err != nil || !strings.Contains(categoriesJSON, "cat-1") {
t.Fatalf("GetExtensionBrowseCategoriesJSON = %q/%v", categoriesJSON, err)
}
CancelExtensionRequestJSON("req-home")
storeDir := filepath.Join(dir, "store")
if err := InitExtensionStoreJSON(storeDir); err != nil {
t.Fatalf("InitExtensionStoreJSON: %v", err)
if err := InitExtensionRepoJSON(storeDir); err != nil {
t.Fatalf("InitExtensionRepoJSON: %v", err)
}
if err := SetStoreRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
t.Fatalf("SetStoreRegistryURLJSON: %v", err)
if err := SetRepoRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
t.Fatalf("SetRepoRegistryURLJSON: %v", err)
}
store := getExtensionStore()
store.cache = &storeRegistry{Extensions: []storeExtension{{
store := getExtensionRepo()
store.cache = &repoRegistry{Extensions: []repoExtension{{
ID: "coverage-ext",
Name: "coverage-ext",
Version: "1.0.0",
@@ -416,44 +514,44 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
DownloadURL: "https://registry.example.com/coverage.spotiflac-ext",
}}}
store.cacheTime = time.Now()
if registryURL, err := GetStoreRegistryURLJSON(); err != nil || registryURL == "" {
t.Fatalf("GetStoreRegistryURLJSON = %q/%v", registryURL, err)
if registryURL, err := GetRepoRegistryURLJSON(); err != nil || registryURL == "" {
t.Fatalf("GetRepoRegistryURLJSON = %q/%v", registryURL, err)
}
if storeJSON, err := GetStoreExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("GetStoreExtensionsJSON = %q/%v", storeJSON, err)
if storeJSON, err := GetRepoExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("GetRepoExtensionsJSON = %q/%v", storeJSON, err)
}
if storeJSON, err := SearchStoreExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("SearchStoreExtensionsJSON = %q/%v", storeJSON, err)
if storeJSON, err := SearchRepoExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("SearchRepoExtensionsJSON = %q/%v", storeJSON, err)
}
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
if catsJSON, err := GetRepoCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
t.Fatalf("GetRepoCategoriesJSON = %q/%v", catsJSON, err)
}
if dest, err := buildStoreExtensionDestPath(
if dest, err := buildRepoExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.spotiflac-ext",
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
t.Fatalf("buildRepoExtensionDestPath = %q/%v", dest, err)
}
if dest, err := buildStoreExtensionDestPath(
if dest, err := buildRepoExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.sflx",
); err != nil || !strings.HasSuffix(dest, ".sflx") {
t.Fatalf("buildStoreExtensionDestPath sflx = %q/%v", dest, err)
t.Fatalf("buildRepoExtensionDestPath sflx = %q/%v", dest, err)
}
if _, err := buildStoreExtensionDestPath(
if _, err := buildRepoExtensionDestPath(
dir,
" ",
"https://registry.example.com/coverage.sflx",
); err == nil {
t.Fatal("expected invalid extension id")
}
if err := ClearStoreCacheJSON(); err != nil {
t.Fatalf("ClearStoreCacheJSON: %v", err)
if err := ClearRepoCacheJSON(); err != nil {
t.Fatalf("ClearRepoCacheJSON: %v", err)
}
if err := ClearStoreRegistryURLJSON(); err != nil {
t.Fatalf("ClearStoreRegistryURLJSON: %v", err)
if err := ClearRepoRegistryURLJSON(); err != nil {
t.Fatalf("ClearRepoRegistryURLJSON: %v", err)
}
SetLibraryCoverCacheDirJSON(filepath.Join(dir, "covers"))
@@ -484,9 +582,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if metadataJSON, err := ReadAudioMetadataJSON(filepath.Join(libraryDir, "missing.mp3")); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataJSON = %q/%v", metadataJSON, err)
}
if metadataJSON, err := ReadAudioMetadataWithHintJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing"); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataWithHintJSON = %q/%v", metadataJSON, err)
}
if metadataJSON, err := ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing", "key"); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataWithHintAndCoverCacheKeyJSON = %q/%v", metadataJSON, err)
}
+77 -2
View File
@@ -352,7 +352,7 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
AlbumName: "Resolved Album",
AlbumName: "Original Album (Deluxe)",
ReleaseDate: "",
ISRC: "",
})
@@ -360,7 +360,7 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
if req.ReleaseDate != "2024-01-01" {
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
}
if req.AlbumName != "Resolved Album" {
if req.AlbumName != "Original Album (Deluxe)" {
t.Fatalf("album = %q, want updated album", req.AlbumName)
}
if req.ISRC != "REQ123" {
@@ -368,6 +368,81 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
}
}
func TestApplyReEnrichTrackMetadataKeepsReleaseIdentityOnAlbumMismatch(t *testing.T) {
req := reEnrichRequest{
TrackName: "Afsana",
ArtistName: "Artist Name",
AlbumName: "Original Soundtrack",
CoverURL: "https://covers/original.jpg",
TrackNumber: 3,
ReleaseDate: "2005-01-01",
}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
Name: "Afsana",
Artists: "Artist Name",
AlbumName: "The Hit Machine",
CoverURL: "https://covers/compilation.jpg",
TrackNumber: 17,
ReleaseDate: "2010-01-01",
ISRC: "NEW123",
})
if req.AlbumName != "Original Soundtrack" {
t.Fatalf("album = %q, want original release kept", req.AlbumName)
}
if req.CoverURL != "https://covers/original.jpg" {
t.Fatalf("cover = %q, want original release cover kept", req.CoverURL)
}
if req.TrackNumber != 3 {
t.Fatalf("track number = %d, want original position kept", req.TrackNumber)
}
if req.ReleaseDate != "2005-01-01" {
t.Fatalf("release date = %q, want original date kept", req.ReleaseDate)
}
if req.ISRC != "NEW123" {
t.Fatalf("isrc = %q, want recording-level fields still enriched", req.ISRC)
}
}
func TestApplyReEnrichTrackMetadataReplacesStalePlaylistAlbumWhenRequested(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song",
ArtistName: "Artist",
AlbumName: "Road Trip Playlist",
CoverURL: "https://covers/playlist.jpg",
TrackNumber: 42,
ReleaseDate: "",
ReplaceReleaseMetadata: true,
}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
Name: "Song",
Artists: "Artist",
AlbumName: "Actual Album",
AlbumArtist: "Artist",
CoverURL: "https://covers/album.jpg",
TrackNumber: 3,
ReleaseDate: "2024-01-01",
})
if req.AlbumName != "Actual Album" {
t.Fatalf("album = %q, want actual album", req.AlbumName)
}
if req.AlbumArtist != "Artist" {
t.Fatalf("album artist = %q", req.AlbumArtist)
}
if req.CoverURL != "https://covers/album.jpg" {
t.Fatalf("cover = %q", req.CoverURL)
}
if req.TrackNumber != 3 {
t.Fatalf("track number = %d", req.TrackNumber)
}
if req.ReleaseDate != "2024-01-01" {
t.Fatalf("release date = %q", req.ReleaseDate)
}
}
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
+57
View File
@@ -0,0 +1,57 @@
package gobackend
import (
"net/http"
"net/url"
"testing"
"time"
)
func TestSimpleCookieJarReplacesAndExpiresCookies(t *testing.T) {
jar, err := newSimpleCookieJar()
if err != nil {
t.Fatalf("newSimpleCookieJar: %v", err)
}
u, _ := url.Parse("https://api.example.com/path")
jar.SetCookies(u, []*http.Cookie{{Name: "session", Value: "old", Path: "/"}})
jar.SetCookies(u, []*http.Cookie{{Name: "session", Value: "new", Path: "/"}})
cookies := jar.Cookies(u)
if len(cookies) != 1 || cookies[0].Value != "new" {
t.Fatalf("replacement cookies = %#v", cookies)
}
jar.SetCookies(u, []*http.Cookie{{
Name: "session",
Value: "expired",
Path: "/",
Expires: time.Now().Add(-time.Hour),
MaxAge: -1,
}})
if cookies := jar.Cookies(u); len(cookies) != 0 {
t.Fatalf("expired cookies = %#v", cookies)
}
}
func TestSimpleCookieJarClearAndScope(t *testing.T) {
jar, err := newSimpleCookieJar()
if err != nil {
t.Fatalf("newSimpleCookieJar: %v", err)
}
apiURL, _ := url.Parse("https://api.example.com/private/resource")
otherURL, _ := url.Parse("https://other.example.com/private/resource")
publicURL, _ := url.Parse("https://api.example.com/public")
jar.SetCookies(apiURL, []*http.Cookie{{Name: "session", Value: "value", Path: "/private"}})
if len(jar.Cookies(apiURL)) != 1 {
t.Fatal("expected cookie for matching host and path")
}
if len(jar.Cookies(otherURL)) != 0 || len(jar.Cookies(publicURL)) != 0 {
t.Fatal("cookie escaped its host or path scope")
}
jar.Clear()
if len(jar.Cookies(apiURL)) != 0 {
t.Fatal("Clear retained cookies")
}
}
+748
View File
@@ -0,0 +1,748 @@
package gobackend
import (
"errors"
"fmt"
"strings"
"time"
)
// attemptExtensionDownload runs a single provider.Download attempt: builds the
// output path, reports progress, and on success assembles the full
// DownloadResponse (overlay, request fallbacks, optional title/artist/composer
// fallback, metadata embed, ISRC index). On failure it writes into
// lastErr/lastErrType/lastRetryAfterSeconds exactly as the inline code did
// (leaving them untouched when neither branch applies) so callers can keep
// their own verification_required/stop-fallback handling and error messages.
// cancelledOuter true means the caller must return (nil, ErrDownloadCancelled).
func attemptExtensionDownload(
req DownloadRequest,
ext *loadedExtension,
provider *extensionProviderWrapper,
trackID, quality, providerLabel string,
applyTitleFallback bool,
lastErr *error,
lastErrType *string,
lastRetryAfterSeconds *int,
) (resp *DownloadResponse, cancelledOuter bool) {
outputPath := buildOutputPathForExtension(req, ext)
if shouldReuseExistingOutput(req, outputPath) {
result := DownloadResult{FilePath: outputPath}
enrichResultQualityFromFile(&result)
built := buildDownloadSuccessResponse(
req,
result,
providerLabel,
"File already exists",
outputPath,
true,
)
if req.ItemID != "" {
CompleteItemProgress(req.ItemID)
}
GoLog("[DownloadWithExtensionFallback] Keeping existing output instead of replacing it: %s\n", outputPath)
return &built, false
}
if req.ItemID != "" {
SetItemPreparingStage(req.ItemID, "resolving_stream")
}
result, err := provider.Download(trackID, quality, outputPath, req.ItemID, func(percent int) {
if req.ItemID != "" {
normalized := float64(percent) / 100.0
if normalized < 0 {
normalized = 0
}
if normalized > 1 {
normalized = 1
}
SetItemProgress(req.ItemID, normalized, 0, 0)
}
})
downloadSucceeded := err == nil && result != nil && result.Success
if downloadSucceeded {
resolved := resolvedTrackInfo{
Title: result.Title,
ArtistName: result.Artist,
AlbumName: result.Album,
ISRC: result.ISRC,
Duration: result.DurationMS / 1000,
SkipNameVerification: strings.EqualFold(strings.TrimSpace(req.Source), strings.TrimSpace(providerLabel)) ||
ext.Manifest.HasCustomMatching(),
}
if !trackMatchesRequest(req, resolved, "Extension "+providerLabel) {
discardRejectedExtensionOutput(result, outputPath)
*lastErr = fmt.Errorf("provider %s returned a different track", providerLabel)
*lastErrType = "not_found"
return nil, false
}
}
if req.ItemID != "" && downloadSucceeded {
SetItemFinalizing(req.ItemID)
}
if shouldAbortCancelledFallback(req.ItemID, err) {
return nil, true
}
if downloadSucceeded {
metadataStartedAt := time.Now()
enrichRequestExtendedMetadata(&req)
LogDebug(
"DownloadPipeline",
"item=%s provider=%s post-transfer metadataMs=%.1f",
req.ItemID,
providerLabel,
extensionDurationMs(time.Since(metadataStartedAt)),
)
normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result)
message := "Downloaded from " + providerLabel
if alreadyExists {
message = "File already exists"
}
built := buildDownloadSuccessResponse(
req,
normalizedResult,
providerLabel,
message,
normalizedResult.FilePath,
alreadyExists,
)
overlayExtensionDownloadMetadata(&built, result)
if ext.Manifest.SkipMetadataEnrichment {
built.SkipMetadataEnrichment = true
}
applyExtensionRequestFallbacks(&built, req)
if applyTitleFallback {
if req.TrackName != "" && built.Title == "" {
built.Title = req.TrackName
}
if req.ArtistName != "" && built.Artist == "" {
built.Artist = req.ArtistName
}
if req.Composer != "" && built.Composer == "" {
built.Composer = req.Composer
}
}
embedExtensionDownloadMetadata(built, req, alreadyExists)
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
indexISRC := strings.TrimSpace(built.ISRC)
if indexISRC == "" {
indexISRC = strings.TrimSpace(req.ISRC)
}
if indexISRC != "" && strings.TrimSpace(built.FilePath) != "" {
AddToISRCIndex(req.OutputDir, indexISRC, built.FilePath)
}
}
if req.ItemID != "" {
CompleteItemProgress(req.ItemID)
}
return &built, false
}
if err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return &DownloadResponse{
Success: false,
Error: "Download cancelled",
ErrorType: "cancelled",
Service: providerLabel,
}, false
}
*lastErr = err
*lastErrType = ""
} else if result != nil && result.ErrorMessage != "" {
*lastErr = fmt.Errorf("%s", result.ErrorMessage)
*lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage)
*lastRetryAfterSeconds = result.RetryAfterSeconds
} else if result == nil {
*lastErr = fmt.Errorf("extension returned no download result")
*lastErrType = "extension_error"
}
return nil, false
}
// attemptVerifiedResumeBeforeMetadata gives a just-verified request one direct
// chance against its selected provider before optional metadata providers run.
// Download-provider sources keep their normal precedence because they may
// dynamically lock fallback from checkAvailability.
func attemptVerifiedResumeBeforeMetadata(
req DownloadRequest,
selectedProvider string,
extManager *extensionManager,
) (*DownloadResponse, bool) {
selectedProvider = strings.TrimSpace(selectedProvider)
if selectedProvider == "" || extManager == nil {
return nil, false
}
sourceProvider := strings.TrimSpace(req.Source)
if sourceProvider != "" && !strings.EqualFold(sourceProvider, selectedProvider) {
if sourceExt, err := extManager.GetExtension(sourceProvider); err == nil &&
sourceExt != nil && sourceExt.Enabled && sourceExt.Error == "" &&
sourceExt.Manifest != nil && sourceExt.Manifest.IsDownloadProvider() {
return nil, false
}
}
ext, err := extManager.GetExtension(selectedProvider)
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" ||
ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
return nil, false
}
provider := newExtensionProviderWrapper(ext)
var availability *ExtAvailabilityResult
trackID := ""
if strings.EqualFold(sourceProvider, selectedProvider) {
trackID = resolvePreferredTrackIDForExtension(ext, req, "")
} else {
availability, err = provider.CheckAvailabilityForItemID(
req.ISRC,
req.TrackName,
req.ArtistName,
req.SpotifyID,
req.DeezerID,
req.TidalID,
req.QobuzID,
req.DurationMS,
req.ItemID,
)
if shouldAbortCancelledFallback(req.ItemID, err) {
return nil, true
}
if err != nil {
if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") {
return &DownloadResponse{
Success: false,
Error: "Download failed: " + err.Error(),
ErrorType: "verification_required",
Service: selectedProvider,
}, false
}
return nil, false
}
if availability == nil || !availability.Available {
if shouldStopProviderFallback(availability) {
return buildExtensionFallbackStoppedResponse(selectedProvider, availability, nil), false
}
return nil, false
}
trackID = resolvePreferredTrackIDForExtension(ext, req, availability.TrackID)
}
var lastErr error
var lastErrType string
var lastRetryAfterSeconds int
resp, cancelled := attemptExtensionDownload(
req,
ext,
provider,
trackID,
req.Quality,
selectedProvider,
strings.EqualFold(sourceProvider, selectedProvider),
&lastErr,
&lastErrType,
&lastRetryAfterSeconds,
)
if cancelled || resp != nil {
return resp, cancelled
}
errorType := lastErrType
if errorType == "" && lastErr != nil {
errorType = classifyDownloadErrorType(lastErr.Error())
}
if strings.EqualFold(errorType, "verification_required") {
errorMessage := "Verification required"
if lastErr != nil {
errorMessage = "Download failed: " + lastErr.Error()
}
return &DownloadResponse{
Success: false,
Error: errorMessage,
ErrorType: "verification_required",
RetryAfterSeconds: lastRetryAfterSeconds,
Service: selectedProvider,
}, false
}
if lastErr != nil && isOutputStorageWriteFailure(errorType, lastErr.Error()) {
return buildOutputStorageFailureResponse(
selectedProvider,
lastErr,
lastRetryAfterSeconds,
), false
}
// A failed fast attempt may still succeed after source enrichment resolves a
// better provider-native ID. The normal path below retains strict/stop-
// fallback semantics for that prepared retry.
return nil, false
}
func buildOutputStorageFailureResponse(
providerID string,
err error,
retryAfterSeconds int,
) *DownloadResponse {
message := "Output storage is not writable"
if err != nil {
message = err.Error()
}
return &DownloadResponse{
Success: false,
Error: "Download failed: " + message,
ErrorType: "permission",
RetryAfterSeconds: retryAfterSeconds,
Service: providerID,
}
}
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
pipelineStartedAt := time.Now()
defer func() {
LogDebug(
"DownloadPipeline",
"item=%s service=%s totalMs=%.1f",
req.ItemID,
req.Service,
extensionDurationMs(time.Since(pipelineStartedAt)),
)
}()
preparationKey := downloadPreparationKey(req)
metadataPrepared := false
resumedAfterVerification := false
if prepared, preparedMetadata, ok := takePreparedDownloadRequest(preparationKey, req); ok {
req = prepared
metadataPrepared = preparedMetadata
resumedAfterVerification = true
GoLog("[DownloadWithExtensionFallback] Resuming item %s after verification (metadata prepared: %v)\n", req.ItemID, metadataPrepared)
}
if req.ItemID != "" {
StartItemProgress(req.ItemID)
SetItemPreparingStage(req.ItemID, "resolving_metadata")
}
priority := GetProviderPriority()
extManager := getExtensionManager()
strictMode := !req.UseFallback
selectedProvider := strings.TrimSpace(req.Service)
if isDownloadCancelled(req.ItemID) {
return nil, ErrDownloadCancelled
}
if strictMode {
if selectedProvider == "" {
selectedProvider = strings.TrimSpace(req.Source)
}
if selectedProvider != "" {
priority = []string{selectedProvider}
GoLog("[DownloadWithExtensionFallback] Strict mode enabled, provider locked to: %s\n", selectedProvider)
}
}
if !strictMode && req.Service != "" {
found := false
for _, p := range priority {
if strings.EqualFold(p, req.Service) {
found = true
break
}
}
newPriority := []string{req.Service}
for _, p := range priority {
if !strings.EqualFold(p, req.Service) {
newPriority = append(newPriority, p)
}
}
priority = newPriority
if !found {
GoLog("[DownloadWithExtensionFallback] Extension service '%s' added to priority front\n", req.Service)
} else {
GoLog("[DownloadWithExtensionFallback] Extension service '%s' moved to priority front\n", req.Service)
}
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
}
var lastErr error
var lastErrType string
var lastRetryAfterSeconds int
var stopProviderFallback bool
var sourceExtensionLocked bool
var sourceExtensionAvailability *ExtAvailabilityResult
var sourceExtensionTrackID string
if resumedAfterVerification && !metadataPrepared {
GoLog("[DownloadWithExtensionFallback] Trying verified provider %s before optional metadata enrichment\n", selectedProvider)
resp, cancelled := attemptVerifiedResumeBeforeMetadata(req, selectedProvider, extManager)
if cancelled {
return nil, ErrDownloadCancelled
}
if resp != nil {
return resp, nil
}
if req.ItemID != "" {
SetItemPreparingStage(req.ItemID, "resolving_metadata")
}
}
if req.Source != "" && selectedProvider != req.Source {
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
provider := newExtensionProviderWrapper(ext)
availability, availErr := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.DurationMS, req.ItemID)
if shouldAbortCancelledFallback(req.ItemID, availErr) {
return nil, ErrDownloadCancelled
}
if availErr != nil {
GoLog("[DownloadWithExtensionFallback] Source extension %s preflight failed (non-fatal): %v\n", req.Source, availErr)
} else if shouldStopProviderFallback(availability) {
sourceExtensionLocked = true
sourceExtensionAvailability = availability
sourceExtensionTrackID = strings.TrimSpace(availability.TrackID)
selectedProvider = req.Source
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback (available=%v), locking download to source extension\n", req.Source, availability.Available)
}
}
}
if !metadataPrepared {
if req.Source != "" {
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
provider := newExtensionProviderWrapper(ext)
trackMeta := &ExtTrackMetadata{
ID: req.SpotifyID,
Name: req.TrackName,
Artists: req.ArtistName,
AlbumName: req.AlbumName,
DurationMS: req.DurationMS,
ISRC: req.ISRC,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
TotalDiscs: req.TotalDiscs,
ProviderID: req.Source,
Composer: req.Composer,
}
enrichedTrack, err := provider.EnrichTrackForItemID(trackMeta, req.ItemID)
if shouldAbortCancelledFallback(req.ItemID, err) {
return nil, ErrDownloadCancelled
}
if err == nil && enrichedTrack != nil {
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
req.ISRC = enrichedTrack.ISRC
}
if enrichedTrack.TidalID != "" {
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
req.TidalID = enrichedTrack.TidalID
}
if enrichedTrack.QobuzID != "" {
GoLog("[DownloadWithExtensionFallback] Qobuz ID from Odesli: %s\n", enrichedTrack.QobuzID)
req.QobuzID = enrichedTrack.QobuzID
}
if enrichedTrack.DeezerID != "" {
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
req.DeezerID = enrichedTrack.DeezerID
}
if enrichedTrack.Name != "" {
req.TrackName = enrichedTrack.Name
}
if enrichedTrack.Artists != "" {
req.ArtistName = enrichedTrack.Artists
}
overlayStr(&req.AlbumName, enrichedTrack.AlbumName, "AlbumName")
overlayStr(&req.AlbumArtist, enrichedTrack.AlbumArtist, "")
overlayInt(&req.DurationMS, enrichedTrack.DurationMS, "DurationMS")
overlayStr(&req.CoverURL, enrichedTrack.CoverURL, "")
overlayStr(&req.SpotifyID, enrichedTrack.ID, "Track ID")
overlayStr(&req.Label, enrichedTrack.Label, "Label")
overlayStr(&req.Copyright, enrichedTrack.Copyright, "Copyright")
overlayStr(&req.Genre, enrichedTrack.Genre, "Genre")
overlayStr(&req.ReleaseDate, enrichedTrack.ReleaseDate, "ReleaseDate")
overlayInt(&req.TrackNumber, enrichedTrack.TrackNumber, "TrackNumber")
overlayInt(&req.TotalTracks, enrichedTrack.TotalTracks, "TotalTracks")
overlayInt(&req.DiscNumber, enrichedTrack.DiscNumber, "DiscNumber")
overlayInt(&req.TotalDiscs, enrichedTrack.TotalDiscs, "TotalDiscs")
overlayStr(&req.Composer, enrichedTrack.Composer, "Composer")
}
}
}
if req.Source != "" &&
req.TrackName != "" && req.ArtistName != "" &&
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
searchQuery := req.TrackName + " " + req.ArtistName
GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
// Only the first match is consumed below. Asking for five made the manager
// continue through additional providers even after it already had a usable
// match, multiplying the per-provider timeout on slow networks.
tracks, searchErr := extManager.SearchTracksWithMetadataProvidersForItemID(searchQuery, 1, true, req.ItemID)
if shouldAbortCancelledFallback(req.ItemID, searchErr) {
return nil, ErrDownloadCancelled
}
if searchErr == nil && len(tracks) > 0 {
track := tracks[0]
GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n",
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC)
overlayStr(&req.AlbumName, track.AlbumName, "")
overlayStr(&req.AlbumArtist, track.AlbumArtist, "")
overlayStr(&req.ReleaseDate, track.ReleaseDate, "")
overlayStr(&req.ISRC, track.ISRC, "")
overlayInt(&req.TrackNumber, track.TrackNumber, "")
overlayInt(&req.TotalTracks, track.TotalTracks, "")
overlayInt(&req.DiscNumber, track.DiscNumber, "")
overlayInt(&req.TotalDiscs, track.TotalDiscs, "")
overlayStr(&req.Composer, track.Composer, "")
overlayStr(&req.CoverURL, track.CoverURL, "")
overlayStr(&req.Genre, track.Genre, "")
overlayStr(&req.Label, track.Label, "")
overlayStr(&req.Copyright, track.Copyright, "")
} else if searchErr != nil {
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
}
}
}
if req.Source != "" && selectedProvider == req.Source {
if isDownloadCancelled(req.ItemID) {
return nil, ErrDownloadCancelled
}
if sourceExtensionLocked && (sourceExtensionAvailability == nil || !sourceExtensionAvailability.Available) {
GoLog("[DownloadWithExtensionFallback] Source extension %s stopped fallback before download (reason: %s)\n", req.Source, resolveExtensionAvailabilityReason(sourceExtensionAvailability, nil))
return buildExtensionFallbackStoppedResponse(req.Source, sourceExtensionAvailability, nil), nil
}
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s' matching selected provider, trying it first\n", req.Source)
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
stopProviderFallback = ext.Manifest.StopsProviderFallback()
provider := newExtensionProviderWrapper(ext)
trackID := resolvePreferredTrackIDForExtension(ext, req, sourceExtensionTrackID)
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (stopProviderFallback: %v)\n", trackID, stopProviderFallback)
resp, cancelledOuter := attemptExtensionDownload(req, ext, provider, trackID, req.Quality, req.Source, true, &lastErr, &lastErrType, &lastRetryAfterSeconds)
if cancelledOuter {
return nil, ErrDownloadCancelled
}
if resp != nil {
return resp, nil
}
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
sourceErrType := lastErrType
if sourceErrType == "" && lastErr != nil {
sourceErrType = classifyDownloadErrorType(lastErr.Error())
}
if lastErr != nil && isOutputStorageWriteFailure(sourceErrType, lastErr.Error()) {
GoLog("[DownloadWithExtensionFallback] Source extension %s hit an unwritable output path; stopping provider fallback\n", req.Source)
return buildOutputStorageFailureResponse(
req.Source,
lastErr,
lastRetryAfterSeconds,
), nil
}
if strings.EqualFold(sourceErrType, "verification_required") {
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
cachePreparedDownloadRequest(preparationKey, req)
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)
return buildExtensionFallbackStoppedResponse(req.Source, sourceExtensionAvailability, lastErr), nil
}
GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n")
return &DownloadResponse{
Success: false,
Error: "Download failed: " + lastErr.Error(),
ErrorType: firstNonEmptyTrimmed(lastErrType, "extension_error"),
RetryAfterSeconds: lastRetryAfterSeconds,
Service: req.Source,
}, nil
}
} else {
GoLog("[DownloadWithExtensionFallback] Source extension %s not available or not a download provider\n", req.Source)
}
}
healthProtectedProvider := selectedProvider
if strings.TrimSpace(healthProtectedProvider) == "" {
healthProtectedProvider = req.Source
}
priority = prioritizeFallbackProvidersByHealth(priority, extManager, healthProtectedProvider)
priority = moveProviderToFront(priority, selectedProvider)
for _, providerID := range priority {
if isDownloadCancelled(req.ItemID) {
return nil, ErrDownloadCancelled
}
providerID = strings.TrimSpace(providerID)
if providerID == "" {
continue
}
// 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 providerID != selectedProvider && !isExtensionFallbackAllowed(providerID) {
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
continue
}
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
{
ext, err := extManager.GetExtension(providerID)
if err != nil || !ext.Enabled || ext.Error != "" {
GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID)
continue
}
if !ext.Manifest.IsDownloadProvider() {
continue
}
provider := newExtensionProviderWrapper(ext)
availability, err := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.DurationMS, req.ItemID)
if shouldAbortCancelledFallback(req.ItemID, err) {
return nil, ErrDownloadCancelled
}
terminalAvailability := shouldStopProviderFallback(availability)
if err != nil || !availability.Available {
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)
cachePreparedDownloadRequest(preparationKey, req)
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)
return buildExtensionFallbackStoppedResponse(providerID, availability, err), nil
}
continue
}
req.OutputExt = ""
// 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
}
}
}
resp, cancelledOuter := attemptExtensionDownload(req, ext, provider, availability.TrackID, fallbackQuality, providerID, false, &lastErr, &lastErrType, &lastRetryAfterSeconds)
if cancelledOuter {
return nil, ErrDownloadCancelled
}
if resp != nil {
return resp, nil
}
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr)
if lastErr != nil {
effType := lastErrType
if effType == "" {
effType = classifyDownloadErrorType(lastErr.Error())
}
if isOutputStorageWriteFailure(effType, lastErr.Error()) {
GoLog("[DownloadWithExtensionFallback] %s hit an unwritable output path; stopping provider fallback\n", providerID)
return buildOutputStorageFailureResponse(
providerID,
lastErr,
lastRetryAfterSeconds,
), nil
}
if strings.EqualFold(effType, "verification_required") {
GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID)
cachePreparedDownloadRequest(preparationKey, req)
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
}
}
}
if lastErr != nil {
errorType := firstNonEmptyTrimmed(lastErrType, classifyDownloadErrorType(lastErr.Error()))
if errorType == "unknown" {
errorType = "not_found"
}
return &DownloadResponse{
Success: false,
Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: errorType,
RetryAfterSeconds: lastRetryAfterSeconds,
}, nil
}
return &DownloadResponse{
Success: false,
Error: "No extension download providers available",
ErrorType: "not_found",
}, nil
}
+432
View File
@@ -0,0 +1,432 @@
package gobackend
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
func manifestCapabilityStringList(manifest *ExtensionManifest, key string) []string {
if manifest == nil || manifest.Capabilities == nil {
return nil
}
raw, ok := manifest.Capabilities[key]
if !ok {
return nil
}
values, ok := raw.([]any)
if !ok {
return nil
}
result := make([]string, 0, len(values))
for _, value := range values {
str, ok := value.(string)
if !ok {
continue
}
trimmed := strings.ToLower(strings.TrimSpace(str))
if trimmed == "" {
continue
}
result = append(result, trimmed)
}
return result
}
func extensionReplacesBuiltInProvider(ext *loadedExtension, providerID string) bool {
if ext == nil {
return false
}
normalized := strings.ToLower(strings.TrimSpace(providerID))
if normalized == "" {
return false
}
for _, replaced := range manifestCapabilityStringList(ext.Manifest, "replacesBuiltInProviders") {
if replaced == normalized {
return true
}
}
return false
}
func trimKnownProviderPrefix(trackID, providerID string) string {
trimmedID := strings.TrimSpace(trackID)
normalizedProvider := strings.ToLower(strings.TrimSpace(providerID))
if trimmedID == "" || normalizedProvider == "" {
return trimmedID
}
prefix := normalizedProvider + ":"
if strings.HasPrefix(strings.ToLower(trimmedID), prefix) {
return trimmedID[len(prefix):]
}
return trimmedID
}
func resolvePreferredTrackIDForExtension(ext *loadedExtension, req DownloadRequest, explicitTrackID string) string {
candidates := make([]string, 0, 8)
appendCandidate := func(value string) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return
}
for _, existing := range candidates {
if existing == trimmed {
return
}
}
candidates = append(candidates, trimmed)
}
appendCandidate(explicitTrackID)
if extensionReplacesBuiltInProvider(ext, "tidal") {
appendCandidate(req.TidalID)
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "tidal"))
}
if extensionReplacesBuiltInProvider(ext, "qobuz") {
appendCandidate(req.QobuzID)
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "qobuz"))
}
if extensionReplacesBuiltInProvider(ext, "deezer") {
appendCandidate(req.DeezerID)
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "deezer"))
}
if extensionReplacesBuiltInProvider(ext, "spotify") {
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "spotify"))
appendCandidate(req.SpotifyID)
}
appendCandidate(req.SpotifyID)
appendCandidate(req.TidalID)
appendCandidate(req.QobuzID)
appendCandidate(req.DeezerID)
if len(candidates) == 0 {
return ""
}
return candidates[0]
}
func normalizeDownloadResultExtension(candidates ...string) string {
for _, candidate := range candidates {
ext := strings.TrimSpace(strings.ToLower(candidate))
if ext == "" {
continue
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
if ext == ".mp4" {
return ".m4a"
}
return ext
}
return ""
}
// discardRejectedExtensionOutput removes only a newly downloaded file inside
// the host-selected output directory. Existing-library hits are never removed,
// nor are paths outside that narrow directory.
func discardRejectedExtensionOutput(result *ExtDownloadResult, requestedOutputPath string) {
if result == nil || result.AlreadyExists {
return
}
resultPath := strings.TrimSpace(result.FilePath)
requestedPath := strings.TrimSpace(requestedOutputPath)
if resultPath == "" || requestedPath == "" ||
strings.HasPrefix(resultPath, "content://") ||
strings.HasPrefix(resultPath, "/proc/self/fd/") {
return
}
resultAbs, resultErr := filepath.Abs(resultPath)
outputDirAbs, outputErr := filepath.Abs(filepath.Dir(requestedPath))
if resultErr != nil || outputErr != nil {
return
}
relative, err := filepath.Rel(outputDirAbs, resultAbs)
if err != nil || relative == "." || relative == ".." ||
strings.HasPrefix(relative, ".."+string(filepath.Separator)) ||
filepath.IsAbs(relative) {
return
}
if err := os.Remove(resultAbs); err != nil && !os.IsNotExist(err) {
GoLog("[DownloadWithExtensionFallback] Warning: failed to remove rejected provider output %q: %v\n", resultAbs, err)
}
}
func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) {
if result == nil {
return DownloadResult{}, false
}
downloadResult := DownloadResult{
FilePath: strings.TrimSpace(result.FilePath),
BitDepth: result.BitDepth,
SampleRate: result.SampleRate,
AudioCodec: strings.TrimSpace(result.AudioCodec),
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
TotalTracks: result.TotalTracks,
DiscNumber: result.DiscNumber,
TotalDiscs: result.TotalDiscs,
ISRC: result.ISRC,
CoverURL: result.CoverURL,
Genre: result.Genre,
Label: result.Label,
Copyright: result.Copyright,
Composer: result.Composer,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
ActualExtension: normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension),
ActualContainer: strings.TrimSpace(result.ActualContainer),
RequiresContainerConversion: result.RequiresContainerConversion,
}
alreadyExists := result.AlreadyExists
if strings.HasPrefix(downloadResult.FilePath, "EXISTS:") {
alreadyExists = true
downloadResult.FilePath = strings.TrimPrefix(downloadResult.FilePath, "EXISTS:")
}
enrichResultQualityFromFile(&downloadResult)
return downloadResult, alreadyExists
}
// overlayStr sets *dst = src when dst is empty and src is not. If field is
// non-empty it logs "<field> from enrichment: <src>" on overlay.
func overlayStr(dst *string, src, field string) {
if src == "" || *dst != "" {
return
}
*dst = src
if field != "" {
GoLog("[DownloadWithExtensionFallback] %s from enrichment: %s\n", field, src)
}
}
// overlayStrTrim is overlayStr but treats a whitespace-only dst as empty too.
func overlayStrTrim(dst *string, src string) {
if src == "" || strings.TrimSpace(*dst) != "" {
return
}
*dst = src
}
// overlayInt sets *dst = src when dst is zero and src is positive. If field is
// non-empty it logs "<field> from enrichment: <src>" on overlay.
func overlayInt(dst *int, src int, field string) {
if src <= 0 || *dst != 0 {
return
}
*dst = src
if field != "" {
GoLog("[DownloadWithExtensionFallback] %s from enrichment: %d\n", field, src)
}
}
func overlayExtensionDownloadMetadata(resp *DownloadResponse, result *ExtDownloadResult) {
if resp == nil || result == nil {
return
}
overlayStrTrim(&resp.Title, result.Title)
overlayStrTrim(&resp.Artist, result.Artist)
overlayStrTrim(&resp.Album, result.Album)
overlayStrTrim(&resp.AlbumArtist, result.AlbumArtist)
overlayInt(&resp.TrackNumber, result.TrackNumber, "")
overlayInt(&resp.DiscNumber, result.DiscNumber, "")
overlayInt(&resp.TotalTracks, result.TotalTracks, "")
overlayInt(&resp.TotalDiscs, result.TotalDiscs, "")
overlayStrTrim(&resp.ReleaseDate, result.ReleaseDate)
overlayStrTrim(&resp.CoverURL, result.CoverURL)
overlayStrTrim(&resp.ISRC, result.ISRC)
overlayStrTrim(&resp.Genre, result.Genre)
overlayStrTrim(&resp.Label, result.Label)
overlayStrTrim(&resp.Copyright, result.Copyright)
overlayStrTrim(&resp.Composer, result.Composer)
if result.LyricsLRC != "" {
resp.LyricsLRC = result.LyricsLRC
}
if result.DecryptionKey != "" {
resp.DecryptionKey = result.DecryptionKey
}
if normalized := normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey); normalized != nil {
resp.Decryption = normalized
}
if ext := normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension); ext != "" {
resp.ActualExtension = ext
}
if container := strings.TrimSpace(result.ActualContainer); container != "" {
resp.ActualContainer = container
}
if result.RequiresContainerConversion {
resp.RequiresContainerConversion = true
}
}
func applyExtensionRequestFallbacks(resp *DownloadResponse, req DownloadRequest) {
if resp == nil {
return
}
overlayStr(&resp.Album, req.AlbumName, "")
overlayStr(&resp.AlbumArtist, req.AlbumArtist, "")
overlayStr(&resp.ReleaseDate, req.ReleaseDate, "")
overlayStr(&resp.ISRC, req.ISRC, "")
overlayInt(&resp.TrackNumber, req.TrackNumber, "")
overlayInt(&resp.TotalTracks, req.TotalTracks, "")
overlayInt(&resp.DiscNumber, req.DiscNumber, "")
overlayInt(&resp.TotalDiscs, req.TotalDiscs, "")
overlayStr(&resp.CoverURL, req.CoverURL, "")
}
func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool {
return availability != nil && availability.SkipFallback
}
func fallbackRuntimeHealthStatus(ext *loadedExtension) string {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return "unknown"
}
status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status))
switch status {
case "online", "degraded", "offline":
return status
default:
return "unknown"
}
}
func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, protectedProvider string) []string {
if len(priority) == 0 || extManager == nil {
return priority
}
online := make([]string, 0, len(priority))
degraded := make([]string, 0, len(priority))
unknown := make([]string, 0, len(priority))
for _, rawProviderID := range priority {
providerID := strings.TrimSpace(rawProviderID)
if providerID == "" {
continue
}
if strings.EqualFold(providerID, protectedProvider) || !isExtensionFallbackAllowed(providerID) {
unknown = append(unknown, providerID)
continue
}
ext, err := extManager.GetExtension(providerID)
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
unknown = append(unknown, providerID)
continue
}
switch fallbackRuntimeHealthStatus(ext) {
case "online":
online = append(online, providerID)
case "degraded":
degraded = append(degraded, providerID)
case "offline":
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID)
default:
unknown = append(unknown, providerID)
}
}
result := make([]string, 0, len(online)+len(degraded)+len(unknown))
result = append(result, online...)
result = append(result, degraded...)
result = append(result, unknown...)
return result
}
// moveProviderToFront preserves the user's explicit provider selection after
// health-based fallback sorting. Health may order the remaining fallback
// candidates, but it must never silently replace the provider the user picked.
func moveProviderToFront(priority []string, providerID string) []string {
providerID = strings.TrimSpace(providerID)
if providerID == "" || len(priority) < 2 {
return priority
}
selectedIndex := -1
for i, candidate := range priority {
if strings.EqualFold(strings.TrimSpace(candidate), providerID) {
selectedIndex = i
break
}
}
if selectedIndex <= 0 {
return priority
}
reordered := make([]string, 0, len(priority))
reordered = append(reordered, priority[selectedIndex])
reordered = append(reordered, priority[:selectedIndex]...)
reordered = append(reordered, priority[selectedIndex+1:]...)
return reordered
}
func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string {
if availability != nil {
if reason := strings.TrimSpace(availability.Reason); reason != "" {
return reason
}
}
if err != nil {
return err.Error()
}
return "extension requested no further fallback"
}
func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse {
reason := resolveExtensionAvailabilityReason(availability, err)
errorType := classifyDownloadErrorType(reason)
if errorType == "unknown" {
errorType = "extension_error"
}
return &DownloadResponse{
Success: false,
Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason),
ErrorType: errorType,
Service: providerID,
}
}
func shouldAbortCancelledFallback(itemID string, err error) bool {
if errors.Is(err, ErrDownloadCancelled) {
return true
}
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
}
+183
View File
@@ -0,0 +1,183 @@
package gobackend
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// buildDownloadFilename renders the sanitized "<name><ext>" filename for req
// from its template/metadata, defaulting to "<artist> - <title>.flac".
func buildDownloadFilename(req DownloadRequest) string {
metadata := map[string]any{
"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,
"quality": req.Quality,
"quality_variant": req.QualityVariant,
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
if strings.TrimSpace(filename) == "" {
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
}
filename = sanitizeFilenamePreservingToken(filename, req.QualityVariant)
ext := strings.TrimSpace(req.OutputExt)
if ext == "" {
ext = ".flac"
} else if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return filename + ext
}
func buildOutputPath(req DownloadRequest) string {
if strings.TrimSpace(req.OutputPath) != "" {
return strings.TrimSpace(req.OutputPath)
}
outputDir := req.OutputDir
if strings.TrimSpace(outputDir) == "" {
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
}
os.MkdirAll(outputDir, 0755)
AddAllowedDownloadDir(outputDir)
return filepath.Join(outputDir, buildDownloadFilename(req))
}
func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string {
if strings.TrimSpace(req.OutputPath) != "" {
outputPath := strings.TrimSpace(req.OutputPath)
AddAllowedDownloadDir(filepath.Dir(outputPath))
return outputPath
}
// SAF downloads hand extensions a detached output FD owned by the host.
// Extensions still need a real local temp file so Android can copy it into
// the target document after provider-specific post-processing completes.
if !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
return buildOutputPath(req)
}
tempDir := filepath.Join(ext.DataDir, "downloads")
os.MkdirAll(tempDir, 0755)
AddAllowedDownloadDir(tempDir)
return filepath.Join(tempDir, buildDownloadFilename(req))
}
func shouldReuseExistingOutput(req DownloadRequest, outputPath string) bool {
if req.AllowQualityVariant || isFDOutput(req.OutputFD) {
return false
}
path := strings.TrimSpace(outputPath)
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
return false
}
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular() && info.Size() > 0
}
func canEmbedGenreLabel(filePath string) bool {
path := strings.TrimSpace(filePath)
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
return false
}
if strings.ToLower(filepath.Ext(path)) != ".flac" {
return false
}
if !filepath.IsAbs(path) {
return false
}
info, err := os.Stat(path)
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
}
@@ -0,0 +1,35 @@
package gobackend
import (
"os"
"path/filepath"
"testing"
)
func TestShouldReuseExistingOutputProtectsCompletedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "Artist - Track.flac")
if err := os.WriteFile(path, []byte("existing audio"), 0o644); err != nil {
t.Fatalf("write existing output: %v", err)
}
if !shouldReuseExistingOutput(DownloadRequest{}, path) {
t.Fatal("expected completed output to be reused when variants are disabled")
}
if shouldReuseExistingOutput(
DownloadRequest{AllowQualityVariant: true},
path,
) {
t.Fatal("quality-variant staging output must remain independently writable")
}
}
func TestShouldReuseExistingOutputIgnoresEmptyStagingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "native_saf_work.flac")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatalf("write staging output: %v", err)
}
if shouldReuseExistingOutput(DownloadRequest{}, path) {
t.Fatal("empty staging output must not be treated as a completed download")
}
}
@@ -0,0 +1,122 @@
package gobackend
import (
"path/filepath"
"testing"
)
func TestVerifiedDownloadResumeTriesSelectedProviderBeforeMetadata(t *testing.T) {
resetPreparedDownloadRequestCacheForTest()
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
metadataExt := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
metadataExt.ID = "resume-metadata"
metadataExt.Manifest.Name = metadataExt.ID
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
downloadExt.ID = "resume-download"
downloadExt.Manifest.Name = downloadExt.ID
manager := getExtensionManager()
manager.mu.Lock()
previousExtensions := manager.extensions
manager.extensions = map[string]*loadedExtension{
metadataExt.ID: metadataExt,
downloadExt.ID: downloadExt,
}
manager.mu.Unlock()
t.Cleanup(func() {
teardownExtension(metadataExt)
teardownExtension(downloadExt)
manager.mu.Lock()
manager.extensions = previousExtensions
manager.mu.Unlock()
})
req := DownloadRequest{
ItemID: "resume-item",
Service: downloadExt.ID,
Source: metadataExt.ID,
SpotifyID: "spotify:track:1",
TrackName: "Original Song",
ArtistName: "Artist",
AlbumName: "Album",
ReleaseDate: "2026-05-04",
OutputDir: t.TempDir(),
OutputExt: ".flac",
FilenameFormat: "{title}",
Quality: "LOSSLESS",
UseFallback: false,
}
key := downloadPreparationKey(req)
cacheUnpreparedDownloadRequest(key, req)
resp, err := DownloadWithExtensionFallback(req)
if err != nil {
t.Fatalf("DownloadWithExtensionFallback: %v", err)
}
if resp == nil || !resp.Success {
t.Fatalf("resume response = %#v", resp)
}
if got := filepath.Base(resp.FilePath); got != "Original Song.flac" {
t.Fatalf("resume ran metadata enrichment before download: file = %q", got)
}
}
func TestVerifiedDownloadResumeReusesPreparedMetadata(t *testing.T) {
resetPreparedDownloadRequestCacheForTest()
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
metadataExt := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
metadataExt.ID = "prepared-metadata"
metadataExt.Manifest.Name = metadataExt.ID
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
downloadExt.ID = "prepared-download"
downloadExt.Manifest.Name = downloadExt.ID
manager := getExtensionManager()
manager.mu.Lock()
previousExtensions := manager.extensions
manager.extensions = map[string]*loadedExtension{
metadataExt.ID: metadataExt,
downloadExt.ID: downloadExt,
}
manager.mu.Unlock()
t.Cleanup(func() {
teardownExtension(metadataExt)
teardownExtension(downloadExt)
manager.mu.Lock()
manager.extensions = previousExtensions
manager.mu.Unlock()
})
req := DownloadRequest{
ItemID: "prepared-item",
Service: downloadExt.ID,
Source: metadataExt.ID,
SpotifyID: "spotify:track:2",
TrackName: "Original Song",
ArtistName: "Artist",
AlbumName: "Album",
ReleaseDate: "2026-05-04",
OutputDir: t.TempDir(),
OutputExt: ".flac",
FilenameFormat: "{title}",
Quality: "LOSSLESS",
UseFallback: false,
}
key := downloadPreparationKey(req)
prepared := req
prepared.TrackName = "Prepared Song"
cachePreparedDownloadRequest(key, prepared)
resp, err := DownloadWithExtensionFallback(req)
if err != nil {
t.Fatalf("DownloadWithExtensionFallback: %v", err)
}
if resp == nil || !resp.Success {
t.Fatalf("prepared response = %#v", resp)
}
if got := filepath.Base(resp.FilePath); got != "Prepared Song.flac" {
t.Fatalf("prepared metadata was not reused: file = %q", got)
}
}
+648
View File
@@ -0,0 +1,648 @@
package gobackend
import (
"fmt"
"strconv"
"strings"
"github.com/dop251/goja"
)
func gojaValueIsEmpty(value goja.Value) bool {
return value == nil || goja.IsUndefined(value) || goja.IsNull(value)
}
func gojaObjectString(obj *goja.Object, keys ...string) string {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
if str, ok := value.Export().(string); ok {
return str
}
}
return ""
}
func gojaObjectValue(obj *goja.Object, keys ...string) goja.Value {
for _, key := range keys {
value := obj.Get(key)
if !gojaValueIsEmpty(value) {
return value
}
}
return nil
}
func gojaObjectInt(obj *goja.Object, keys ...string) int {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
return int(value.ToInteger())
}
return 0
}
func gojaObjectInt64(obj *goja.Object, keys ...string) int64 {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
return value.ToInteger()
}
return 0
}
func gojaObjectBool(obj *goja.Object, keys ...string) bool {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
return value.ToBoolean()
}
return false
}
func gojaObjectInterfaceMap(obj *goja.Object, keys ...string) map[string]any {
value := gojaObjectValue(obj, keys...)
if gojaValueIsEmpty(value) {
return nil
}
exported, ok := value.Export().(map[string]any)
if !ok || len(exported) == 0 {
return nil
}
return exported
}
func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map[string]string {
value := gojaObjectValue(obj, keys...)
if gojaValueIsEmpty(value) {
return nil
}
valueObj := value.ToObject(vm)
objectKeys := valueObj.Keys()
if len(objectKeys) == 0 {
return nil
}
result := make(map[string]string, len(objectKeys))
for _, childKey := range objectKeys {
childValue := valueObj.Get(childKey)
if gojaValueIsEmpty(childValue) {
continue
}
result[childKey] = childValue.String()
}
if len(result) == 0 {
return nil
}
return result
}
func gojaObjectStringSlice(obj *goja.Object, keys ...string) []string {
value := gojaObjectValue(obj, keys...)
if gojaValueIsEmpty(value) {
return nil
}
exported, ok := value.Export().([]any)
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
}
lengthValue := value.ToObject(vm).Get("length")
if gojaValueIsEmpty(lengthValue) {
return 0, fmt.Errorf("value is not an array")
}
length := lengthValue.ToInteger()
if length <= 0 {
return 0, nil
}
return int(length), nil
}
func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetadata {
obj := value.ToObject(vm)
return ExtTrackMetadata{
ID: gojaObjectString(obj, "id"),
Name: gojaObjectString(obj, "name"),
Artists: gojaObjectString(obj, "artists"),
AlbumName: gojaObjectString(obj, "album_name", "albumName"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
AlbumID: gojaObjectString(obj, "album_id", "albumId"),
AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"),
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"),
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"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
ISRC: gojaObjectString(obj, "isrc"),
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
ItemType: gojaObjectString(obj, "item_type", "itemType"),
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
Explicit: gojaObjectBool(obj, "explicit", "is_explicit", "isExplicit"),
TidalID: gojaObjectString(obj, "tidal_id", "tidalId"),
QobuzID: gojaObjectString(obj, "qobuz_id", "qobuzId"),
DeezerID: gojaObjectString(obj, "deezer_id", "deezerId"),
SpotifyID: gojaObjectString(obj, "spotify_id", "spotifyId"),
ExternalLinks: gojaObjectStringMap(vm, obj, "external_links", "externalLinks"),
Label: gojaObjectString(obj, "label"),
Copyright: gojaObjectString(obj, "copyright"),
Genre: gojaObjectString(obj, "genre"),
Composer: gojaObjectString(obj, "composer"),
AudioQuality: gojaObjectString(obj, "audio_quality", "audioQuality"),
AudioModes: gojaObjectString(obj, "audio_modes", "audioModes"),
}
}
func parseExtensionTrackArray(vm *goja.Runtime, value goja.Value) ([]ExtTrackMetadata, error) {
length, err := gojaArrayLength(value, vm)
if err != nil {
return nil, err
}
if length == 0 {
return []ExtTrackMetadata{}, nil
}
arrayObj := value.ToObject(vm)
tracks := make([]ExtTrackMetadata, 0, length)
for i := 0; i < length; i++ {
trackValue := arrayObj.Get(strconv.Itoa(i))
if gojaValueIsEmpty(trackValue) {
continue
}
tracks = append(tracks, parseExtensionTrackValue(vm, trackValue))
}
return tracks, nil
}
func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetadata, error) {
if gojaValueIsEmpty(value) {
return ExtAlbumMetadata{}, nil
}
obj := value.ToObject(vm)
tracks := []ExtTrackMetadata{}
if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) {
parsedTracks, err := parseExtensionTrackArray(vm, tracksValue)
if err != nil {
return ExtAlbumMetadata{}, err
}
tracks = parsedTracks
}
return ExtAlbumMetadata{
ID: gojaObjectString(obj, "id"),
Name: gojaObjectString(obj, "name"),
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"),
}.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) {
length, err := gojaArrayLength(value, vm)
if err != nil {
return nil, err
}
if length == 0 {
return []ExtAlbumMetadata{}, nil
}
arrayObj := value.ToObject(vm)
albums := make([]ExtAlbumMetadata, 0, length)
for i := 0; i < length; i++ {
albumValue := arrayObj.Get(strconv.Itoa(i))
if gojaValueIsEmpty(albumValue) {
continue
}
album, err := parseExtensionAlbumValue(vm, albumValue)
if err != nil {
return nil, err
}
albums = append(albums, album)
}
return albums, nil
}
func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMetadata, error) {
if gojaValueIsEmpty(value) {
return ExtArtistMetadata{}, nil
}
obj := value.ToObject(vm)
albums := []ExtAlbumMetadata{}
if albumsValue := gojaObjectValue(obj, "albums"); !gojaValueIsEmpty(albumsValue) {
parsedAlbums, err := parseExtensionAlbumArray(vm, albumsValue)
if err != nil {
return ExtArtistMetadata{}, err
}
albums = parsedAlbums
}
releases := []ExtAlbumMetadata{}
if releasesValue := gojaObjectValue(obj, "releases"); !gojaValueIsEmpty(releasesValue) {
parsedReleases, err := parseExtensionAlbumArray(vm, releasesValue)
if err != nil {
return ExtArtistMetadata{}, err
}
releases = parsedReleases
}
topTracks := []ExtTrackMetadata{}
if topTracksValue := gojaObjectValue(obj, "top_tracks", "topTracks"); !gojaValueIsEmpty(topTracksValue) {
parsedTopTracks, err := parseExtensionTrackArray(vm, topTracksValue)
if err != nil {
return ExtArtistMetadata{}, err
}
topTracks = parsedTopTracks
}
return ExtArtistMetadata{
ID: gojaObjectString(obj, "id"),
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,
TopTracks: topTracks,
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
}, nil
}
func parseExtensionAvailabilityValue(vm *goja.Runtime, value goja.Value) ExtAvailabilityResult {
obj := value.ToObject(vm)
return ExtAvailabilityResult{
Available: gojaObjectBool(obj, "available"),
Reason: gojaObjectString(obj, "reason"),
TrackID: gojaObjectString(obj, "track_id", "trackId"),
SkipFallback: gojaObjectBool(obj, "skip_fallback", "skipFallback"),
}
}
func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *DownloadDecryptionInfo {
if gojaValueIsEmpty(value) {
return nil
}
obj := value.ToObject(vm)
info := &DownloadDecryptionInfo{
Strategy: gojaObjectString(obj, "strategy"),
Key: gojaObjectString(obj, "key"),
IV: gojaObjectString(obj, "iv"),
InputFormat: gojaObjectString(obj, "input_format", "inputFormat"),
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
Options: gojaObjectInterfaceMap(obj, "options"),
}
if info.Strategy == "" && info.Key == "" && info.IV == "" && info.InputFormat == "" && info.OutputExtension == "" && len(info.Options) == 0 {
return nil
}
return info
}
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"),
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
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",
"requiresContainerConversion",
),
}
}
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"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
}
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
track := parseExtensionTrackValue(vm, trackValue)
handleResult.Track = &track
}
if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) {
tracks, err := parseExtensionTrackArray(vm, tracksValue)
if err != nil {
return ExtURLHandleResult{}, err
}
handleResult.Tracks = tracks
}
if albumValue := gojaObjectValue(obj, "album"); !gojaValueIsEmpty(albumValue) {
album, err := parseExtensionAlbumValue(vm, albumValue)
if err != nil {
return ExtURLHandleResult{}, err
}
handleResult.Album = &album
}
if artistValue := gojaObjectValue(obj, "artist"); !gojaValueIsEmpty(artistValue) {
artist, err := parseExtensionArtistValue(vm, artistValue)
if err != nil {
return ExtURLHandleResult{}, err
}
handleResult.Artist = &artist
}
return handleResult, nil
}
func parseExtensionPostProcessValue(vm *goja.Runtime, value goja.Value) PostProcessResult {
obj := value.ToObject(vm)
return PostProcessResult{
Success: gojaObjectBool(obj, "success"),
NewFilePath: gojaObjectString(obj, "new_file_path", "newFilePath"),
NewFileURI: gojaObjectString(obj, "new_file_uri", "newFileUri"),
Error: gojaObjectString(obj, "error"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
}
}
func parseExtensionLyricsLineArray(vm *goja.Runtime, value goja.Value) ([]ExtLyricsLine, error) {
length, err := gojaArrayLength(value, vm)
if err != nil {
return nil, err
}
if length == 0 {
return []ExtLyricsLine{}, nil
}
arrayObj := value.ToObject(vm)
lines := make([]ExtLyricsLine, 0, length)
for i := 0; i < length; i++ {
lineValue := arrayObj.Get(strconv.Itoa(i))
if gojaValueIsEmpty(lineValue) {
continue
}
lineObj := lineValue.ToObject(vm)
lines = append(lines, ExtLyricsLine{
StartTimeMs: gojaObjectInt64(lineObj, "startTimeMs", "start_time_ms"),
Words: gojaObjectString(lineObj, "words"),
EndTimeMs: gojaObjectInt64(lineObj, "endTimeMs", "end_time_ms"),
})
}
return lines, nil
}
func parseExtensionLyricsValue(vm *goja.Runtime, value goja.Value) (ExtLyricsResult, error) {
obj := value.ToObject(vm)
lines := []ExtLyricsLine{}
if linesValue := gojaObjectValue(obj, "lines"); !gojaValueIsEmpty(linesValue) {
parsedLines, err := parseExtensionLyricsLineArray(vm, linesValue)
if err != nil {
return ExtLyricsResult{}, err
}
lines = parsedLines
}
return ExtLyricsResult{
Lines: lines,
SyncType: gojaObjectString(obj, "syncType", "sync_type"),
Instrumental: gojaObjectBool(obj, "instrumental"),
PlainLyrics: gojaObjectString(obj, "plainLyrics", "plain_lyrics"),
Provider: gojaObjectString(obj, "provider"),
}, nil
}
func parseExtensionSearchResult(vm *goja.Runtime, value goja.Value) (ExtSearchResult, error) {
if gojaValueIsEmpty(value) {
return ExtSearchResult{}, nil
}
resultObj := value.ToObject(vm)
tracksValue := resultObj.Get("tracks")
if gojaValueIsEmpty(tracksValue) {
tracks, err := parseExtensionTrackArray(vm, value)
if err != nil {
return ExtSearchResult{}, err
}
return ExtSearchResult{
Tracks: tracks,
Total: len(tracks),
}, nil
}
tracks, err := parseExtensionTrackArray(vm, tracksValue)
if err != nil {
return ExtSearchResult{}, err
}
total := gojaObjectInt(resultObj, "total")
if total == 0 {
total = len(tracks)
}
return ExtSearchResult{
Tracks: tracks,
Total: total,
}, nil
}
+11 -5
View File
@@ -52,6 +52,12 @@ var (
extensionHealthCache = map[string]cachedExtensionHealthResult{}
)
func clearExtensionHealthCache() {
extensionHealthCacheMu.Lock()
extensionHealthCache = map[string]cachedExtensionHealthResult{}
extensionHealthCacheMu.Unlock()
}
func CheckExtensionHealthJSON(extensionID string) (string, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -296,7 +302,7 @@ func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string
return "online", ""
}
var payload map[string]interface{}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return "online", ""
}
@@ -325,12 +331,12 @@ func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string
}
}
func classifyExtensionHealthService(payload map[string]interface{}, serviceKey string) (string, string, bool) {
func classifyExtensionHealthService(payload map[string]any, serviceKey string) (string, string, bool) {
rawServices, ok := payload["services"]
if !ok {
return "", "", false
}
services, ok := rawServices.(map[string]interface{})
services, ok := rawServices.(map[string]any)
if !ok {
return "", "", false
}
@@ -338,7 +344,7 @@ func classifyExtensionHealthService(payload map[string]interface{}, serviceKey s
if !ok {
return "unknown", fmt.Sprintf("service '%s' not found", serviceKey), true
}
service, ok := rawService.(map[string]interface{})
service, ok := rawService.(map[string]any)
if !ok {
return "unknown", fmt.Sprintf("service '%s' has invalid health payload", serviceKey), true
}
@@ -444,7 +450,7 @@ func isTransientHealthStatusCode(code int) bool {
}
}
func healthNumber(value interface{}) (int, bool) {
func healthNumber(value any) (int, bool) {
switch v := value.(type) {
case float64:
return int(v), true

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