Compare commits

...
205 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
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
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
Zarz Eleutherius d6726f976a New translations app_en.arb (Korean)
[ci skip]
2026-07-11 20:36:39 +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
Zarz Eleutherius d1ede223cf New translations app_en.arb (Korean)
[ci skip]
2026-07-10 14:04:22 +07:00
353 changed files with 52434 additions and 94832 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
+4
View File
@@ -1,5 +1,9 @@
* text=auto eol=lf * text=auto eol=lf
# Scripts executed on Unix runners must keep LF line endings.
*.sh text eol=lf
gradlew text eol=lf
# Windows scripts # Windows scripts
*.bat text eol=crlf *.bat text eol=crlf
*.cmd text eol=crlf *.cmd text eol=crlf
+61 -54
View File
@@ -1,30 +1,29 @@
name: Bug Report name: Bug Report
description: Report a bug or unexpected behavior description: Report a reproducible bug or unexpected behavior
title: "[Bug]: " title: "[Bug]: "
labels: ["bug"] labels: ["bug"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | 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 - type: checkboxes
id: checklist id: checklist
attributes: attributes:
label: Checklist label: Checklist
description: Please confirm the following before submitting
options: 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 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 required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
label: Bug Description label: Description
description: A clear and concise description of what the bug is description: What happened, and what did you expect instead?
placeholder: Describe the bug... placeholder: Include the visible error and any relevant context.
validations: validations:
required: true required: true
@@ -32,29 +31,26 @@ body:
id: steps id: steps
attributes: attributes:
label: Steps to Reproduce label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: | placeholder: |
1. Go to '...' 1. Open ...
2. Click on '...' 2. Enable ...
3. See error 3. Tap ...
4. Observe ...
validations: validations:
required: true required: true
- type: textarea - type: dropdown
id: expected id: trigger
attributes: attributes:
label: Expected Behavior label: Area
description: What did you expect to happen? options:
placeholder: Describe what you expected... - App startup
validations: - Search or navigation
required: true - Download or verification
- Library or metadata
- type: textarea - Playback or background service
id: actual - Audio analysis
attributes: - Other
label: Actual Behavior
description: What actually happened?
placeholder: Describe what actually happened...
validations: validations:
required: true required: true
@@ -62,62 +58,73 @@ body:
id: version id: version
attributes: attributes:
label: App Version label: App Version
description: Which version of SpotiFLAC are you using? (Check in Settings > About) description: Find this in Settings > About.
placeholder: "e.g., v2.2.0" placeholder: "e.g. 4.8.1 (139)"
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
id: platform id: channel
attributes: attributes:
label: Platform label: Release Channel
description: Which platform are you using?
options: options:
- Android - Stable
- iOS - Beta
- Local or development build
validations: validations:
required: true required: true
- type: input - type: input
id: device id: device
attributes: attributes:
label: Device & OS Version label: Device and OS
description: What device and OS version are you using? placeholder: "e.g. Samsung Galaxy S24, Android 16"
placeholder: "e.g., Samsung Galaxy S24, Android 14"
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
id: download-service id: storage
attributes: attributes:
label: Download Service label: Download Storage
description: Which download service were you using when the bug occurred?
options: options:
- Tidal - SAF folder
- Qobuz - App-specific folder
- Amazon Music
- Deezer (search only)
- Not applicable - Not applicable
- Not sure
validations: validations:
required: true 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 - type: textarea
id: logs id: logs
attributes: attributes:
label: Logs / Screenshots label: Logs and Media
description: | description: |
If applicable, add logs or screenshots to help explain your problem. 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.
**To get logs:** validations:
1. Go to Settings > Options > Detailed Logging (turn ON) required: true
2. Reproduce the bug
3. Go to Settings > Logs
4. Tap Share button to export logs
placeholder: Paste logs or drag & drop screenshots here...
- type: textarea - type: textarea
id: additional id: additional
attributes: attributes:
label: Additional Context label: Additional Context
description: Any other context about the problem placeholder: Settings, frequency, regression version, or anything else that may help.
placeholder: Add any other context...
+2 -2
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: README - 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 about: Check the README for setup instructions and FAQ
- name: Extension Development Guide - name: Extension Development Guide
url: https://spotiflac.zarz.moe/docs 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 name: Download Issue
description: Report issues with downloading specific tracks or albums description: Report a wrong, missing, failed, or low-quality download
title: "[Download]: " title: "[Download]: "
labels: ["download-issue"] labels: ["download-issue"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | 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 - type: checkboxes
id: checklist id: checklist
attributes: attributes:
label: Checklist label: Checklist
description: Please confirm the following before submitting
options: 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 required: true
- label: I am using the latest version of SpotiFLAC (Stable Version) - label: I updated the affected extension and retried once.
required: true required: true
- type: dropdown - type: dropdown
id: issue-type id: issue-type
attributes: attributes:
label: Issue Type label: Issue Type
description: What kind of download issue are you experiencing?
options: options:
- Track not found on service - Track or album not found
- Wrong track downloaded - Wrong track downloaded
- Download fails/errors - Download fails or stalls
- Metadata incorrect - Metadata or cover is incorrect
- Audio quality issue - Audio format or quality is incorrect
- Duplicate or replacement behavior
- Other - Other
validations: validations:
required: true required: true
- type: input - type: input
id: spotify-url id: source-url
attributes: attributes:
label: Spotify URL label: Source URL
description: The Spotify URL of the track/album you're trying to download description: URL pasted or opened in SpotiFLAC Mobile, if available.
placeholder: "https://open.spotify.com/track/..." placeholder: "https://..."
validations:
required: true
- type: input - type: input
id: track-info id: track-info
attributes: attributes:
label: Track Info label: Track or Album
description: Artist name and track title placeholder: "Artist - Track or album title"
placeholder: "Artist - Track 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: validations:
required: true required: true
- type: dropdown - type: dropdown
id: download-service id: storage
attributes: attributes:
label: Download Service label: Download Storage
description: Which service did you try to download from?
options: options:
- Tidal - SAF folder
- Qobuz - App-specific folder
- Amazon Music - Not sure
- All services
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
id: search-service id: worker
attributes: attributes:
label: Search Service label: Download Worker
description: Which search service are you using?
options: options:
- Spotify - Native worker
- Deezer - Dart worker
- Not sure
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
label: Description label: What Happened?
description: Describe the issue in detail description: For a wrong match, identify both the expected and downloaded recording.
placeholder: | placeholder: Describe the result and the expected result.
What happened? What did you expect?
If wrong track was downloaded, what track was downloaded instead?
validations: validations:
required: true required: true
- type: input - type: input
id: version id: environment
attributes: attributes:
label: App Version label: App Version, Device, and OS
description: Which version of SpotiFLAC are you using? placeholder: "4.8.1 (139), device model, Android/iOS version"
placeholder: "e.g., v2.2.0"
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: screenshots id: logs
attributes: attributes:
label: Screenshots / Logs label: Logs and Screenshots
description: | description: Enable detailed logging, retry once, and export Settings > Logs. Remove credentials and personal data.
If applicable, add screenshots or logs. placeholder: Paste logs or drag files here.
validations:
**To get logs:** required: true
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...
@@ -6,7 +6,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | 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. This form is for extension developers who need new features or capabilities that don't exist yet.
- type: checkboxes - type: checkboxes
@@ -15,7 +15,7 @@ body:
label: Checklist label: Checklist
description: Please confirm the following before submitting description: Please confirm the following before submitting
options: 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 required: true
- label: I have searched existing issues and this API feature hasn't been requested yet - label: I have searched existing issues and this API feature hasn't been requested yet
required: true required: true
+1 -1
View File
@@ -50,7 +50,7 @@ body:
options: options:
- UI/UX Improvement - UI/UX Improvement
- Download Feature - Download Feature
- New Service Integration - Extension System/API
- Metadata/Tagging - Metadata/Tagging
- Performance - Performance
- Settings/Configuration - Settings/Configuration
+30 -15
View File
@@ -1,27 +1,42 @@
## Description ## Summary
<!-- What does this PR do? Why is it needed? --> <!-- What changed, why is it needed, and which user-visible behavior is affected? -->
## Related Issues ## Related Issues
<!-- Link related issues, e.g. "Fixes #123" --> <!-- Use "Fixes #123" when this PR should close an issue. -->
## Type of Change ## Type of Change
- [ ] Bug fix - [ ] Bug fix
- [ ] New feature - [ ] Feature
- [ ] Refactor (no functional change) - [ ] Performance improvement
- [ ] Documentation - [ ] Refactor with no intended behavior change
- [ ] Other (describe below) - [ ] Tests, build, or repository maintenance
- [ ] Documentation or localization
## Checklist ## Validation
- [ ] Code follows project conventions <!-- Check only commands relevant to this change. Explain skipped checks below. -->
- [ ] `flutter analyze` and `flutter test` pass
- [ ] `go vet ./...` and `go test ./...` pass in `go_backend/` (if Go code changed)
- [ ] Documentation updated (if needed)
- [ ] Commit messages follow the [conventional commits](https://www.conventionalcommits.org) format
## Screenshots - [ ] `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
<!-- For UI changes, add before/after screenshots. Delete this section otherwise. --> 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. -->
+11 -12
View File
@@ -37,6 +37,7 @@ jobs:
- 'pubspec.lock' - 'pubspec.lock'
- 'analysis_options.yaml' - 'analysis_options.yaml'
- 'l10n.yaml' - 'l10n.yaml'
- '.fvmrc'
- '.github/workflows/ci.yml' - '.github/workflows/ci.yml'
go: go:
- 'go_backend/**' - 'go_backend/**'
@@ -51,6 +52,7 @@ jobs:
- 'lib/services/platform_bridge.dart' - 'lib/services/platform_bridge.dart'
- 'pubspec.yaml' - 'pubspec.yaml'
- 'pubspec.lock' - 'pubspec.lock'
- '.fvmrc'
- '.github/workflows/ci.yml' - '.github/workflows/ci.yml'
flutter: flutter:
@@ -67,6 +69,7 @@ jobs:
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: "stable" channel: "stable"
flutter-version-file: .fvmrc
cache: true cache: true
- name: Cache pub dependencies - name: Cache pub dependencies
@@ -102,8 +105,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
# Keep in sync with release.yml go-version-file: go_backend/go.mod
go-version: "1.26.5"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
- name: Check formatting - name: Check formatting
@@ -134,30 +136,27 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: "1.26.5" go-version-file: go_backend/go.mod
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
- name: Setup Flutter - name: Setup Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: "stable" channel: "stable"
flutter-version-file: .fvmrc
cache: true cache: true
- name: Cache Gradle - name: Setup Gradle
uses: actions/cache@v5 uses: gradle/actions/setup-gradle@v5
with: with:
path: | gradle-version: "9.6.1"
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Install Android SDK & NDK - name: Install Android SDK & NDK
run: | run: |
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \ $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
"ndk;29.0.14206865" \ "ndk;29.0.14206865" \
"platforms;android-37" \ "platforms;android-37.0" \
"build-tools;37.0.0" "build-tools;37.0.0"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
@@ -182,4 +181,4 @@ jobs:
run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties
- name: Compile Kotlin and run native unit tests - name: Compile Kotlin and run native unit tests
run: ./android/gradlew -p android :app:compileDebugKotlin :app:testDebugUnitTest run: gradle -p android :app:compileDebugKotlin :app:testDebugUnitTest
+32 -15
View File
@@ -70,7 +70,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: "1.26.5" go-version-file: go_backend/go.mod
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds # Cache Gradle for faster builds
@@ -100,7 +100,7 @@ jobs:
# Install NDK r29 (supports 16KB page size for Android 15+) # Install NDK r29 (supports 16KB page size for Android 15+)
# Keep the installed platform aligned with compileSdk/targetSdk. # Keep the installed platform aligned with compileSdk/targetSdk.
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37" "build-tools;37.0.0" $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37.0" "build-tools;37.0.0"
# Set NDK path # Set NDK path
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
@@ -129,6 +129,7 @@ jobs:
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: "stable" channel: "stable"
flutter-version-file: .fvmrc
cache: true cache: true
- name: Cache pub dependencies - name: Cache pub dependencies
@@ -146,13 +147,11 @@ jobs:
- name: Build APK (Release - unsigned) - name: Build APK (Release - unsigned)
run: | run: |
flutter build apk --release --split-per-abi || true flutter build apk --release --split-per-abi \
# Verify APKs were created --target-platform android-arm,android-arm64
ls -la build/app/outputs/flutter-apk/ ls -la build/app/outputs/flutter-apk/
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then test -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
echo "ERROR: APK not found!" test -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
exit 1
fi
- name: Sign APKs - name: Sign APKs
uses: r0adkll/sign-android-release@v1 uses: r0adkll/sign-android-release@v1
@@ -168,12 +167,29 @@ jobs:
- name: Rename APKs - name: Rename APKs
run: | run: |
VERSION=${{ needs.get-version.outputs.version }} VERSION="${{ needs.get-version.outputs.version }}"
cd build/app/outputs/flutter-apk 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 rename_required_apk() {
mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true unsigned="$1"
mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true 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 ls -la
- name: Upload APK artifact - name: Upload APK artifact
@@ -198,7 +214,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: "1.26.5" go-version-file: go_backend/go.mod
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache CocoaPods # Cache CocoaPods
@@ -274,6 +290,7 @@ jobs:
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: "stable" channel: "stable"
flutter-version-file: .fvmrc
cache: true cache: true
- name: Cache pub dependencies - name: Cache pub dependencies
@@ -474,7 +491,7 @@ jobs:
jq --arg ver "$VERSION_NUM" \ jq --arg ver "$VERSION_NUM" \
--arg date "$DATE" \ --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" \ --argjson size "$IPA_SIZE" \
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $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 apps.json > apps.json.tmp && mv apps.json.tmp apps.json
+70 -63
View File
@@ -1,91 +1,98 @@
# OS files # Operating-system files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
Desktop.ini
# IDE # Editors and local assistants
.idea/ .idea/
.vscode/ .vscode/
*.iml *.iml
.cursorignore .cursorignore
.cursorrules .cursorrules
# Kiro specs (development only)
.kiro/ .kiro/
.playwright-mcp/
.rtk/
.claude/settings.local.json
CLAUDE.md
AGENTS.md
# Design assets (banners, mockups) # Flutter and Dart
design/
# Reference folder (development only)
referensi/
# Documentation (development only, published separately)
docs/
# Old spotiflac_android folder (moved to root)
spotiflac_android/
# Flutter/Dart
.dart_tool/ .dart_tool/
.fvm/
.packages .packages
build/
*.lock
!pubspec.lock
.flutter-plugins .flutter-plugins
.flutter-plugins-dependencies .flutter-plugins-dependencies
.metadata .metadata
/build/
/coverage/
*.apk *.apk
*.aab
*.ipa
# Go backend build artifacts # Local JavaScript tooling (the app itself has no Node dependency)
go_backend/*.aar /node_modules/
go_backend/*.jar /bun.lock
go_backend/*.exe /package-lock.json
go_backend/*.xcframework/
# Android # Go backend build outputs
android/.gradle/ /go_backend/*.aar
android/app/libs/gobackend.aar /go_backend/*.jar
android/app/libs/gobackend-sources.jar /go_backend/*.exe
android/local.properties /go_backend/*.xcframework/
android/*.iml
android/key.properties
android/*.jks
android/*.keystore
android/app/*.jks
# iOS # Android build state and signing material
ios/Frameworks/ /android/.gradle/
ios/Pods/ /android/.kotlin/
ios/.symlinks/ /android/app/build/
ios/Flutter/Flutter.framework/ /android/app/libs/*.aar
ios/Flutter/Flutter.podspec /android/app/libs/*-sources.jar
/android/local.properties
/android/*.iml
/android/key.properties
/android/*.jks
/android/*.keystore
/android/app/*.jks
# Extension folder # iOS generated state
extension/* /ios/Frameworks/
extension/v2/ /ios/Pods/
extension/v2/** /ios/.symlinks/
/ios/Flutter/ephemeral/
/ios/Flutter/Flutter.framework/
/ios/Flutter/Flutter.podspec
*.xcarchive
DerivedData/
# Agent instructions # Credentials and machine-local configuration
AGENTS.md .env
.env.*
!.env.example
*.mobileprovision
*.p12
*.pem
# Temp/misc # Local research and design material
.tmp/ /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
NUL NUL
network_requests.txt /network_requests.txt
*.bak
/AndroidManifest.xml /AndroidManifest.xml
*.bak
# Log files *.orig
*.swp
*.tmp
*.log *.log
hs_err_*.log hs_err_*.log
flutter_*.log flutter_*.log
# Development tools
tool/
.claude/settings.local.json
.playwright-mcp/
.rtk/
CLAUDE.md
# FVM Version Cache
.fvm/
+521 -13
View File
@@ -1,5 +1,513 @@
# Changelog # 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 ## [3.7.2] - 2026-03-07
### Changed ### Changed
@@ -745,8 +1253,8 @@ Thank you for your understanding and continued support. This decision was made t
### Highlights ### 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) - **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/zarzet/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist - **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 - **Unified Library Tab**: History renamed to Library, shows Downloaded + Local Library tracks with source badges
### Added ### Added
@@ -814,7 +1322,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
### Added ### 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) - **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) - **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists) - **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 ### 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 - **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)) - **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/zarzet/SpotiFLAC-Mobile/issues/110)) - **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)) - Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro)) - Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot)) - 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 - 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 - Shows default provider (Deezer based on metadata source setting) at the top
- Lists all enabled extensions with custom search capability - 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 - Search hint text updates immediately when switching providers
- Re-triggers search automatically if there's existing text in the search bar - Re-triggers search automatically if there's existing text in the search bar
- Eliminates need to navigate to Settings > Extensions > Search Provider - 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 - Extensions can define `button` type in manifest settings
- Triggers JavaScript function when tapped (e.g., start OAuth flow) - Triggers JavaScript function when tapped (e.g., start OAuth flow)
- Useful for authentication, manual sync, or any custom action - 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 - Fetches genre and label from Deezer album API for each track
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files - Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
- Works automatically when Deezer track ID is available (via ISRC matching) - Works automatically when Deezer track ID is available (via ISRC matching)
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads - 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 - New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options - When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
- Available in both the quality picker dialog and default quality settings - 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 - **Artist Name in Album Screen**: Album info card now displays artist name below album title
- Extracted from first track's artist metadata - Extracted from first track's artist metadata
- Styled with `onSurfaceVariant` color for visual hierarchy - 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. - Visual disc separator header showing "Disc 1", "Disc 2", etc.
- Tracks sorted by disc number first, then by track number - Tracks sorted by disc number first, then by track number
- Single-disc albums display normally without separators - Single-disc albums display normally without separators
- Fixes confusion when albums have duplicate track numbers across discs - 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 - Prevents flooding the recents list when downloading full albums
- Groups tracks by album name and artist - Groups tracks by album name and artist
- Tapping navigates directly to the downloaded album screen - 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)
+117 -239
View File
@@ -1,289 +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) - Search the [existing issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
- [How Can I Contribute?](#how-can-i-contribute) before opening a new one.
- [Reporting Bugs](#reporting-bugs) - Use the issue template that best matches the problem.
- [Suggesting Features](#suggesting-features) - Keep pull requests focused. Separate unrelated fixes into separate PRs.
- [Code Contributions](#code-contributions) - Never commit credentials, signing files, downloaded media, or generated build
- [Translations](#translations) artifacts.
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Guidelines](#coding-guidelines)
- [Commit Guidelines](#commit-guidelines)
- [Pull Request Process](#pull-request-process)
## 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. [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
When creating a bug report, please use the bug report template and include: `flutter` (and `fvm dart` with `dart`) in the commands below.
- **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 `main`
2. **Make your changes** following our coding guidelines
3. **Test your changes** thoroughly
4. **Submit a pull request** to the `main` 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/`.
## Development Setup ## 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 ```bash
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
cd SpotiFLAC-Mobile cd SpotiFLAC-Mobile
```
2. **Add upstream remote**
```bash
git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
``` ```
3. **Use FVM (Flutter Version: 3.41.5)** 2. Install the pinned Flutter SDK and Dart dependencies:
```bash ```bash
fvm use fvm install
fvm flutter pub get
``` ```
4. **Install dependencies** 3. Build the Go backend for Android. `ANDROID_NDK_HOME` must point to the NDK
```bash version used by CI and `CGO_ENABLED` must be enabled.
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.9)**
Building the Go backend for Android requires the **Android NDK** (r29 is what CI uses). Make sure `ANDROID_NDK_HOME` points to it and `CGO_ENABLED=1`.
```bash ```bash
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
cd go_backend cd go_backend
go mod download
go install golang.org/x/mobile/cmd/gomobile
gomobile init
mkdir -p ../android/app/libs mkdir -p ../android/app/libs
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar . gomobile bind \
-target=android/arm,android/arm64 \
-androidapi 24 \
-o ../android/app/libs/gobackend.aar \
.
cd .. 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 ```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 ```bash
# Debug build fvm dart format --output=none --set-exit-if-changed lib test
flutter build apk --debug fvm flutter analyze
fvm flutter test
# Release build
flutter build apk --release
``` ```
## Project Structure Go backend:
```
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
```bash ```bash
dart format . cd go_backend
gofmt -w .
go vet ./...
go test ./...
``` ```
### Linting Android native code, after building `gobackend.aar`:
Ensure your code passes all lints:
```bash ```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, with hand-written `Notifier`s ## Code and Commit Style
(no `riverpod_annotation` code generation). Follow this pattern:
```dart - Follow `analysis_options.yaml`, `.editorconfig`, and existing module patterns.
class MyNotifier extends Notifier<MyState> { - Keep user-facing strings in the localization files.
@override - Prefer small functions and explicit error handling at platform boundaries.
MyState build() => MyState(); - Use [Conventional Commits](https://www.conventionalcommits.org/), for example:
// Methods to update state ```text
} feat(download): add batch selection
fix(storage): handle revoked folder access
docs(contributing): refresh Android setup
```
final myProvider = NotifierProvider<MyNotifier, MyState>(MyNotifier.new); ## Pull Requests
```
### Localization 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.
All user-facing strings should be localized: When reporting a crash, include the SpotiFLAC Mobile version, release channel,
device/OS, exact reproduction steps, storage mode, and exported app logs. For a
```dart cold-start Android crash, `adb logcat -b crash -d` is especially useful.
// Good
Text(AppLocalizations.of(context)!.downloadComplete)
// 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/main
```
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 `main` 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
CI runs `flutter analyze`, `flutter test`, `go vet`, and `go test` on every pull request — make sure they pass locally before pushing.
## Questions?
If you have questions, feel free to:
- Open a [Discussion](https://github.com/spotiflacapp/SpotiFLAC-Mobile/discussions)
- Check existing [Issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
Thank you for contributing! 💚
+31 -11
View File
@@ -16,8 +16,7 @@
<div align="center"> <div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub Release](https://img.shields.io/github/v/release/spotiflacapp/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/spotiflacapp/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)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) [![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
@@ -52,7 +51,22 @@ Extensions let the community add new music sources and features without waiting
### Developing Extensions ### Developing Extensions
> [!NOTE] > [!NOTE]
> Want to build your own extension? The [Extension Development Guide](https://spotiflac.zarz.moe/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> <summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
<br> <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> </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> <summary><b>Why do I need to grant storage permission?</b></summary>
<br> <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> </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> <summary><b>Is this app safe?</b></summary>
<br> <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> </details>
@@ -125,13 +145,13 @@ Some countries have restricted access to certain streaming service APIs. If down
</details> </details>
<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> <br>
Yes! Add the official source to receive updates directly within the app. Copy this link: 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. 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> </details>
> [!NOTE] > [!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) > [![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! Thanks to everyone who has contributed to SpotiFLAC Mobile!
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors"> <a href="https://github.com/spotiflacapp/SpotiFLAC-Mobile/graphs/contributors">
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" /> <img src="https://contrib.rocks/image?repo=spotiflacapp/SpotiFLAC-Mobile" />
</a> </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. 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 type_annotate_public_apis: true
cancel_subscriptions: true cancel_subscriptions: true
close_sinks: 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 # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options
+11
View File
@@ -118,6 +118,17 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> 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 <service
android:name="com.ryanheise.audioservice.AudioService" android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback" 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)
}
}
}
}
@@ -67,13 +67,13 @@ class DownloadService : Service() {
const val EXTRA_SETTINGS_JSON = "settings_json" const val EXTRA_SETTINGS_JSON = "settings_json"
const val EXTRA_REQUESTS_PATH = "requests_path" const val EXTRA_REQUESTS_PATH = "requests_path"
const val EXTRA_SETTINGS_PATH = "settings_path" const val EXTRA_SETTINGS_PATH = "settings_path"
private const val NATIVE_WORKER_STATE_FILE = "native_download_worker_state.json" internal const val NATIVE_WORKER_STATE_FILE = "native_download_worker_state.json"
private const val NATIVE_WORKER_PROGRESS_FILE = "native_download_worker_progress.json" internal const val NATIVE_WORKER_PROGRESS_FILE = "native_download_worker_progress.json"
private const val NATIVE_REPLAYGAIN_JOURNAL_FILE = "native_replaygain_journal.json" internal const val NATIVE_REPLAYGAIN_JOURNAL_FILE = "native_replaygain_journal.json"
private const val NATIVE_WORKER_CONTRACT_VERSION = NativeDownloadFinalizer.NATIVE_WORKER_CONTRACT_VERSION internal const val NATIVE_WORKER_CONTRACT_VERSION = NativeDownloadFinalizer.NATIVE_WORKER_CONTRACT_VERSION
private const val NOTIFICATION_PERCENT_TOTAL = 10_000L internal const val NOTIFICATION_PERCENT_TOTAL = 10_000L
private val NATIVE_WORKER_STATE_FILE_LOCK = Any() internal val NATIVE_WORKER_STATE_FILE_LOCK = Any()
private val NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK = Any() internal val NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK = Any()
private var isRunning = false private var isRunning = false
@@ -165,8 +165,8 @@ class DownloadService : Service() {
// re-parsing the full state file — which grows with every completed // re-parsing the full state file — which grows with every completed
// item (results embed history rows and lyrics) — once the caller has // item (results embed history rows and lyrics) — once the caller has
// already consumed that items payload. // already consumed that items payload.
@Volatile private var lastStateHeaderJson: String? = null @Volatile internal var lastStateHeaderJson: String? = null
@Volatile private var lastStateHeaderSerial = 0L @Volatile internal var lastStateHeaderSerial = 0L
fun getNativeWorkerSnapshot(context: Context, sinceStateSerial: Long = 0L): String { fun getNativeWorkerSnapshot(context: Context, sinceStateSerial: Long = 0L): String {
synchronized(NATIVE_WORKER_STATE_FILE_LOCK) { synchronized(NATIVE_WORKER_STATE_FILE_LOCK) {
@@ -253,7 +253,7 @@ class DownloadService : Service() {
} }
} }
private data class NativeDownloadRequest( internal data class NativeDownloadRequest(
val itemId: String, val itemId: String,
val requestJson: String, val requestJson: String,
val trackName: String, val trackName: String,
@@ -261,7 +261,7 @@ class DownloadService : Service() {
val itemJson: String val itemJson: String
) )
private data class NativeWorkerItem( internal data class NativeWorkerItem(
val itemId: String, val itemId: String,
val trackName: String, val trackName: String,
val artistName: String, val artistName: String,
@@ -274,38 +274,41 @@ class DownloadService : Service() {
var resultJson: JSONObject? = null var resultJson: JSONObject? = null
) )
private data class NativeWorkerCounts( internal data class NativeWorkerCounts(
val total: Int, val total: Int,
val completed: Int, val completed: Int,
val failed: Int, val failed: Int,
val skipped: Int val skipped: Int
) )
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) internal val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var nativeWorkerJob: Job? = null internal var nativeWorkerJob: Job? = null
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var currentTrackName = "" private var currentTrackName = ""
private var currentArtistName = "" private var currentArtistName = ""
private var currentStatus = "preparing" internal var currentStatus = "preparing"
private var queueCount = 0 private var queueCount = 0
private var lastProgress = 0L // Signature of the last home-screen widget push; keeps widget updates
private var lastTotal = 0L // event-driven (track/status/queue changes, 25% steps), never per byte.
private var nativeWorkerRunId = "" private var widgetSignature = ""
internal var lastProgress = 0L
internal var lastTotal = 0L
internal var nativeWorkerRunId = ""
@Volatile private var nativeWorkerCurrentItemId = "" @Volatile private var nativeWorkerCurrentItemId = ""
private val nativeWorkerItems = mutableListOf<NativeWorkerItem>() internal val nativeWorkerItems = mutableListOf<NativeWorkerItem>()
private val nativeReplayGainEntries = mutableListOf<JSONObject>() internal val nativeReplayGainEntries = mutableListOf<JSONObject>()
private val nativeReplayGainRequestAlbumKeys = mutableMapOf<String, String>() internal val nativeReplayGainRequestAlbumKeys = mutableMapOf<String, String>()
private val snapshotWriteLock = Any() internal val snapshotWriteLock = Any()
private val snapshotWriteSerial = AtomicLong(0L) internal val snapshotWriteSerial = AtomicLong(0L)
private var latestCommittedStateSnapshotSerial = 0L internal var latestCommittedStateSnapshotSerial = 0L
private var latestCommittedProgressSnapshotSerial = 0L internal var latestCommittedProgressSnapshotSerial = 0L
@Volatile private var nativeWorkerPaused = false @Volatile private var nativeWorkerPaused = false
@Volatile private var nativeWorkerNetworkPaused = false @Volatile internal var nativeWorkerNetworkPaused = false
@Volatile private var nativeWorkerVerificationPaused = false @Volatile internal var nativeWorkerVerificationPaused = false
@Volatile private var nativeWorkerCancelRequested = false @Volatile private var nativeWorkerCancelRequested = false
private var nativeWorkerDownloadNetworkMode = "any" internal var nativeWorkerDownloadNetworkMode = "any"
private var nativeWorkerNetworkCallback: ConnectivityManager.NetworkCallback? = null internal var nativeWorkerNetworkCallback: ConnectivityManager.NetworkCallback? = null
private val nativeWorkerWifiNetworks = mutableSetOf<Network>() internal val nativeWorkerWifiNetworks = mutableSetOf<Network>()
// Bumped every time a new native queue replaces the current one. A worker // Bumped every time a new native queue replaces the current one. A worker
// coroutine that observes a different generation than its own must stop // coroutine that observes a different generation than its own must stop
// without touching the snapshot or the service lifecycle: cancel() alone // without touching the snapshot or the service lifecycle: cancel() alone
@@ -507,6 +510,7 @@ class DownloadService : Service() {
} else { } else {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
} }
pushWidgetState(0, 0)
} }
private fun startNativeWorker(requestsJson: String, settingsJson: String) { private fun startNativeWorker(requestsJson: String, settingsJson: String) {
@@ -618,18 +622,18 @@ class DownloadService : Service() {
} }
} }
private fun isNativeWorkerPaused(): Boolean = internal fun isNativeWorkerPaused(): Boolean =
nativeWorkerPaused || nativeWorkerPaused ||
nativeWorkerNetworkPaused || nativeWorkerNetworkPaused ||
nativeWorkerVerificationPaused nativeWorkerVerificationPaused
private fun nativeWorkerPauseMessage(): String = when { internal fun nativeWorkerPauseMessage(): String = when {
nativeWorkerVerificationPaused -> "Verification required" nativeWorkerVerificationPaused -> "Verification required"
nativeWorkerNetworkPaused -> "Waiting for Wi-Fi" nativeWorkerNetworkPaused -> "Waiting for Wi-Fi"
else -> "Paused" else -> "Paused"
} }
private fun cancelActiveNativeItemForPause() { internal fun cancelActiveNativeItemForPause() {
var itemIdToCancel = "" var itemIdToCancel = ""
synchronized(nativeWorkerItems) { synchronized(nativeWorkerItems) {
val activeItem = nativeWorkerItems.firstOrNull { val activeItem = nativeWorkerItems.firstOrNull {
@@ -655,146 +659,6 @@ class DownloadService : Service() {
NativeDownloadFinalizer.cancelActiveWork() NativeDownloadFinalizer.cancelActiveWork()
} }
private fun 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}",
)
}
}
private fun 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
}
}
private fun 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,
)
}
private fun 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) {
}
}
private fun parseNativeDownloadRequests(requestsJson: String): List<NativeDownloadRequest> { private fun parseNativeDownloadRequests(requestsJson: String): List<NativeDownloadRequest> {
val array = JSONArray(requestsJson) val array = JSONArray(requestsJson)
val requests = ArrayList<NativeDownloadRequest>(array.length()) val requests = ArrayList<NativeDownloadRequest>(array.length())
@@ -1085,7 +949,11 @@ class DownloadService : Service() {
settingsJson = settingsJson, settingsJson = settingsJson,
includeItems = true, includeItems = true,
) )
scheduleNativeVerificationNotification(generation) // Publish immediately. If Flutter is alive it will
// replace this same notification ID while owning
// the interactive challenge; if Flutter is
// suspended, the native alert remains visible.
showNativeVerificationRequired()
updateNotification(0L, 0L) updateNotification(0L, 0L)
retryCurrentRequest = true retryCurrentRequest = true
} else { } else {
@@ -1178,489 +1046,6 @@ class DownloadService : Service() {
} }
} }
private fun 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)
}
private fun buildEligibleNativeAlbumReplayGain(
entries: List<JSONObject>,
statuses: Map<String, String>,
requestKeys: Map<String, String>
): JSONArray {
val blockedKeys = mutableSetOf<String>()
val expectedCompletedByKey = mutableMapOf<String, Int>()
for ((itemId, key) in requestKeys) {
when (statuses[itemId]) {
"completed" -> expectedCompletedByKey[key] = (expectedCompletedByKey[key] ?: 0) + 1
"failed", "skipped", "queued", "downloading", "finalizing" -> blockedKeys.add(key)
}
}
val entriesByKey = entries.groupBy { it.optString("album_key", "") }
val eligible = JSONArray()
for ((key, group) in entriesByKey) {
if (key.isBlank() || blockedKeys.contains(key) || group.size <= 1) continue
val expected = expectedCompletedByKey[key] ?: continue
if (group.size != expected) continue
for (entry in group) eligible.put(entry)
}
return eligible
}
private fun 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
}
}
private fun hasPendingNativeAlbumReplayGainWork(statuses: Map<String, String>): Boolean {
return statuses.values.any {
it == "queued" || it == "downloading" || it == "finalizing"
}
}
private fun 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(NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
val file = AtomicFile(File(filesDir, 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)
}
}
}
}
private fun 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
}
}
private fun 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)
}
}
private fun 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
}
private fun clearNativeReplayGainJournal() {
synchronized(NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
try {
AtomicFile(File(filesDir, NATIVE_REPLAYGAIN_JOURNAL_FILE)).delete()
} catch (_: Exception) {
}
}
}
private fun flushNativeAlbumReplayGainJournalIfComplete() {
val root = synchronized(NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
try {
val file = File(filesDir, 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()
}
}
private fun 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", 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(NATIVE_WORKER_STATE_FILE_LOCK) {
val targetFileName = if (includeItems) {
NATIVE_WORKER_STATE_FILE
} else {
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) {
lastStateHeaderJson = headerCandidate
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}")
}
}
private fun 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
)
}
}
private fun readNativeWorkerRunIdFromSnapshotFile(): String {
return try {
synchronized(NATIVE_WORKER_STATE_FILE_LOCK) {
val file = File(filesDir, 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) {
""
}
}
private fun updateNativeWorkerItem(itemId: String, updater: (NativeWorkerItem) -> Unit) {
synchronized(nativeWorkerItems) {
nativeWorkerItems.firstOrNull { it.itemId == itemId }?.let(updater)
}
}
private fun 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 * NOTIFICATION_PERCENT_TOTAL).toLong()
.coerceIn(0L, NOTIFICATION_PERCENT_TOTAL)
lastProgress = percentProgress
lastTotal = NOTIFICATION_PERCENT_TOTAL
updateNotification(percentProgress, NOTIFICATION_PERCENT_TOTAL)
} else {
lastProgress = 0L
lastTotal = 0L
updateNotification(0L, 0L)
}
} catch (_: Exception) {
}
}
private fun nativeWorkerCounts(): 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 NativeWorkerCounts(
total = total,
completed = completed,
failed = failed,
skipped = skipped
)
}
private fun 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)
}
}
private fun nativeWorkerItemIds(): JSONArray {
val array = JSONArray()
synchronized(nativeWorkerItems) {
for (item in nativeWorkerItems) {
array.put(item.itemId)
}
}
return array
}
private fun nativeWorkerItemsSnapshot(includeStatic: Boolean): JSONArray {
val array = JSONArray()
synchronized(nativeWorkerItems) {
for (item in nativeWorkerItems) {
array.put(nativeWorkerItemSnapshotLocked(item, includeStatic))
}
}
return array
}
private fun nativeWorkerItemSnapshotLocked(item: 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
}
@Synchronized
private fun ensureWakeLock() { private fun ensureWakeLock() {
val existingWakeLock = wakeLock val existingWakeLock = wakeLock
if (existingWakeLock?.isHeld == true) { if (existingWakeLock?.isHeld == true) {
@@ -1719,6 +1104,12 @@ class DownloadService : Service() {
nativeWorkerNetworkPaused = false nativeWorkerNetworkPaused = false
nativeWorkerJob = null nativeWorkerJob = null
isRunning = false isRunning = false
widgetSignature = ""
try {
DownloadQueueWidgetProvider.push(this, running = false)
} catch (e: Exception) {
android.util.Log.w("DownloadService", "Widget clear failed: ${e.message}")
}
releaseWakeLock() releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
@@ -1731,13 +1122,51 @@ class DownloadService : Service() {
} }
} }
private fun updateNotification(progress: Long, total: Long) { internal fun updateNotification(progress: Long, total: Long) {
if (!isRunning) return if (!isRunning) return
ensureWakeLock() ensureWakeLock()
val notification = buildNotification(progress, total) val notification = buildNotification(progress, total)
val manager = getSystemService(NotificationManager::class.java) val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, notification) manager.notify(NOTIFICATION_ID, notification)
pushWidgetState(progress, total)
}
private fun pushWidgetState(progress: Long, total: Long) {
val percent = if (total > 0) {
((progress * 100) / total).toInt().coerceIn(0, 100)
} else {
-1
}
val bucket = if (percent < 0) -1 else percent / 25
val signature = "$currentTrackName|$currentStatus|$queueCount|$bucket"
if (signature == widgetSignature) return
widgetSignature = signature
val subtitle = when (currentStatus) {
"verification_required" -> "Verification required"
"rate_limited" -> "Rate limited, retrying..."
"waiting_wifi" -> "Waiting for Wi-Fi..."
"finalizing" -> "Finalizing..."
else -> buildString {
append(currentArtistName)
if (queueCount > 1) {
if (isNotEmpty()) append("")
append("$queueCount in queue")
}
}
}
try {
DownloadQueueWidgetProvider.push(
this,
running = true,
title = currentTrackName.ifEmpty { "Downloading..." },
subtitle = subtitle,
percent = percent,
)
} catch (e: Exception) {
android.util.Log.w("DownloadService", "Widget update failed: ${e.message}")
}
} }
private fun buildNotification(progress: Long, total: Long): Notification { private fun buildNotification(progress: Long, total: Long): Notification {
@@ -1842,21 +1271,6 @@ class DownloadService : Service() {
} }
} }
private fun scheduleNativeVerificationNotification(generation: Long) {
serviceScope.launch {
// Give an active Flutter poller time to take ownership of the
// verification flow. If Flutter is suspended, the service remains
// paused and publishes the alert itself.
delay(2_000L)
if (generation == nativeWorkerGeneration &&
nativeWorkerVerificationPaused &&
!nativeWorkerCancelRequested
) {
showNativeVerificationRequired()
}
}
}
private fun showNativeVerificationRequired() { private fun showNativeVerificationRequired() {
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
this, this,
@@ -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 }
}
@@ -27,6 +27,11 @@ object SafDownloadHandler {
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>() private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
data class UniqueWriteResult(val uri: String, val fileName: String) 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( private fun <T> withSafNameLock(
treeUriStr: String, treeUriStr: String,
@@ -352,6 +357,79 @@ object SafDownloadHandler {
} }
} }
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 { private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
val sanitized = sanitizeFilename(fileName) val sanitized = sanitizeFilename(fileName)
val trimmedSuffix = suffix.trim() val trimmedSuffix = suffix.trim()
@@ -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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#000000</color> <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> </resources>
@@ -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,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
+6 -6
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC Mobile", "name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac", "bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet", "developerName": "zarzet",
"version": "4.7.1", "version": "4.8.0",
"versionDate": "2026-07-01", "versionDate": "2026-07-25",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa", "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. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.", "localizedDescription": "SpotiFLAC Mobile is written in Flutter and uses installable extensions for metadata and audio downloads.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png", "iconURL": "https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 37455821 "size": 37939793
} }
] ]
} }
Binary file not shown.
+2 -2
View File
@@ -5,7 +5,7 @@
# Template for the changelog body # Template for the changelog body
body = """ body = """
{%- macro remote_url() -%} {%- macro remote_url() -%}
https://github.com/zarzet/SpotiFLAC-Mobile https://github.com/spotiflacapp/SpotiFLAC-Mobile
{%- endmacro -%} {%- endmacro -%}
{% if version %}\ {% if version %}\
@@ -99,5 +99,5 @@ tag_pattern = "v[0-9].*"
sort_commits = "newest" sort_commits = "newest"
[remote.github] [remote.github]
owner = "zarzet" owner = "spotiflacapp"
repo = "SpotiFLAC-Mobile" repo = "SpotiFLAC-Mobile"
+3 -5
View File
@@ -6,18 +6,16 @@ files:
# Keys MUST be the project's Crowdin language ids; values are the # Keys MUST be the project's Crowdin language ids; values are the
# %locale% suffix used in app_%locale%.arb (underscores so Flutter # %locale% suffix used in app_%locale%.arb (underscores so Flutter
# gen-l10n parses them — hyphenated filenames break gen-l10n). # 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 de: de
es-ES: es_ES es-ES: es_ES
fr: fr fr: fr
hi: hi
id: id id: id
ja: ja ja: ja
ko: ko ko: ko
nl: nl
pt-PT: pt_PT pt-PT: pt_PT
ru: ru ru: ru
tr: tr tr: tr
uk: uk 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.
-827
View File
@@ -2,13 +2,10 @@ package gobackend
import ( import (
"bytes" "bytes"
"encoding/base64"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"io" "io"
"math"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
) )
@@ -775,440 +772,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
return quality, nil 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
// 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
}
var id3v1Genres = []string{ var id3v1Genres = []string{
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
@@ -1238,393 +801,3 @@ var id3v1Genres = []string{
"Contemporary Christian", "Christian Rock", "Merengue", "Salsa", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
"Thrash Metal", "Anime", "J-Pop", "Synthpop", "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 {
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)
}
+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
}
+95 -135
View File
@@ -20,23 +20,25 @@ type cancelEntry struct {
refs int refs int
} }
var ( type cancelRegistry struct {
cancelMu sync.Mutex mu sync.Mutex
cancelMap = make(map[string]*cancelEntry) entries map[string]*cancelEntry
}
extensionRequestCancelMu sync.Mutex var (
extensionRequestCancelMap = make(map[string]*cancelEntry) downloadCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)}
extensionRequestCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)}
) )
func initDownloadCancel(itemID string) context.Context { func (r *cancelRegistry) init(id string) context.Context {
if itemID == "" { if id == "" {
return context.Background() return context.Background()
} }
cancelMu.Lock() r.mu.Lock()
defer cancelMu.Unlock() defer r.mu.Unlock()
if entry, ok := cancelMap[itemID]; ok { if entry, ok := r.entries[id]; ok {
if entry.ctx == nil { if entry.ctx == nil {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
entry.ctx = ctx entry.ctx = ctx
@@ -50,7 +52,7 @@ func initDownloadCancel(itemID string) context.Context {
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{ r.entries[id] = &cancelEntry{
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
canceled: false, canceled: false,
@@ -59,164 +61,122 @@ func initDownloadCancel(itemID string) context.Context {
return ctx return ctx
} }
func downloadCancelContext(itemID string) context.Context { func (r *cancelRegistry) context(id string) context.Context {
if itemID == "" { if id == "" {
return context.Background() return context.Background()
} }
cancelMu.Lock() r.mu.Lock()
defer cancelMu.Unlock() defer r.mu.Unlock()
if entry, ok := cancelMap[itemID]; ok && entry.ctx != nil { if entry, ok := r.entries[id]; ok && entry.ctx != nil {
return entry.ctx return entry.ctx
} }
return context.Background() 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) { func cancelDownload(itemID string) {
if itemID == "" { if itemID == "" {
return return
} }
downloadCancels.requestCancel(itemID)
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()
RemoveItemProgress(itemID) RemoveItemProgress(itemID)
} }
func isDownloadCancelled(itemID string) bool { func isDownloadCancelled(itemID string) bool {
if itemID == "" { return downloadCancels.isCancelled(itemID)
return false
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
canceled := ok && entry.canceled
cancelMu.Unlock()
return canceled
} }
// resetDownloadCancel removes a cancellation entry that has no active
// download 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 resetDownloadCancel(itemID string) { func resetDownloadCancel(itemID string) {
if itemID == "" { downloadCancels.resetIfIdle(itemID)
return
}
cancelMu.Lock()
if entry, ok := cancelMap[itemID]; ok && entry.refs <= 0 {
delete(cancelMap, itemID)
}
cancelMu.Unlock()
} }
func clearDownloadCancel(itemID string) { func clearDownloadCancel(itemID string) {
if itemID == "" { downloadCancels.release(itemID)
return
}
cancelMu.Lock()
if entry, ok := cancelMap[itemID]; ok {
entry.refs--
if entry.refs <= 0 {
delete(cancelMap, itemID)
}
}
cancelMu.Unlock()
} }
func initExtensionRequestCancel(requestID string) context.Context { func initExtensionRequestCancel(requestID string) context.Context {
if requestID == "" { return extensionRequestCancels.init(requestID)
return context.Background()
}
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 { func extensionRequestCancelContext(requestID string) context.Context {
if requestID == "" { return extensionRequestCancels.context(requestID)
return context.Background()
}
extensionRequestCancelMu.Lock()
defer extensionRequestCancelMu.Unlock()
if entry, ok := extensionRequestCancelMap[requestID]; ok && entry.ctx != nil {
return entry.ctx
}
return context.Background()
} }
func cancelExtensionRequest(requestID string) { func cancelExtensionRequest(requestID string) {
if requestID == "" { extensionRequestCancels.requestCancel(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()
} }
func isExtensionRequestCancelled(requestID string) bool { func isExtensionRequestCancelled(requestID string) bool {
if requestID == "" { return extensionRequestCancels.isCancelled(requestID)
return false
}
extensionRequestCancelMu.Lock()
entry, ok := extensionRequestCancelMap[requestID]
canceled := ok && entry.canceled
extensionRequestCancelMu.Unlock()
return canceled
} }
func clearExtensionRequestCancel(requestID string) { func clearExtensionRequestCancel(requestID string) {
if requestID == "" { extensionRequestCancels.release(requestID)
return
}
extensionRequestCancelMu.Lock()
if entry, ok := extensionRequestCancelMap[requestID]; ok {
entry.refs--
if entry.refs <= 0 {
delete(extensionRequestCancelMap, requestID)
}
}
extensionRequestCancelMu.Unlock()
} }
+138 -29
View File
@@ -1,7 +1,11 @@
package gobackend package gobackend
import ( import (
"bytes"
"fmt" "fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
@@ -16,8 +20,11 @@ const (
spotifySizeMax = "ab67616d000082c1" spotifySizeMax = "ab67616d000082c1"
) )
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800 // Square CDN covers using this path shape may return an image whose decoded
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`) // 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$`) var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
@@ -42,27 +49,137 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
GoLog("[Cover] Upgraded 300x300 → 640x640") GoLog("[Cover] Upgraded 300x300 → 640x640")
} }
if maxQuality { if !maxQuality {
maxURL := upgradeToMaxQuality(downloadURL) GoLog("[Cover] Final URL: %s", downloadURL)
if maxURL != downloadURL { data, err := fetchCoverCached(downloadURL)
downloadURL = maxURL if err != nil {
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") { return nil, err
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
} }
return append([]byte(nil), data...), nil
} }
GoLog("[Cover] Final URL: %s", downloadURL) candidates := maxQualityCoverCandidateURLs(downloadURL)
data, selectedURL, width, height, err := fetchBestCoverCandidate(candidates)
data, err := fetchCoverCached(downloadURL)
if err != nil { if err != nil {
return nil, err // 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; // Cached bytes are shared across goroutines and must never be mutated;
// hand callers their own copy. // hand callers their own copy.
return append([]byte(nil), data...), nil 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 ( const (
coverCacheMaxBytes = 24 * 1024 * 1024 coverCacheMaxBytes = 24 * 1024 * 1024
coverCacheTTL = 15 * time.Minute coverCacheTTL = 15 * time.Minute
@@ -185,16 +302,8 @@ func fetchCoverBytes(downloadURL string) ([]byte, error) {
return nil, fmt.Errorf("failed to read cover data: %w", err) return nil, fmt.Errorf("failed to read cover data: %w", err)
} }
sizeKB := len(data) / 1024 width, height := coverDimensions(data)
var resolution string GoLog("[Cover] Downloaded %d KB (%dx%d)", len(data)/1024, width, height)
if sizeKB > 200 {
resolution = "~2000x2000 (hi-res)"
} else if sizeKB > 50 {
resolution = "~640x640"
} else {
resolution = "~300x300"
}
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
return data, nil return data, nil
} }
@@ -204,8 +313,8 @@ func upgradeToMaxQuality(coverURL string) string {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
} }
if strings.Contains(coverURL, "cdn-images.dzcdn.net") { if squareCoverSizeRegex.MatchString(coverURL) {
return upgradeDeezerCover(coverURL) return upgradeSquareCover(coverURL)
} }
if strings.Contains(coverURL, "resources.tidal.com") { if strings.Contains(coverURL, "resources.tidal.com") {
@@ -219,14 +328,14 @@ func upgradeToMaxQuality(coverURL string) string {
return coverURL return coverURL
} }
func upgradeDeezerCover(coverURL string) string { func upgradeSquareCover(coverURL string) string {
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") { if !squareCoverSizeRegex.MatchString(coverURL) {
return 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 { if upgraded != coverURL {
GoLog("[Cover] Deezer: upgraded to 1800x1800") GoLog("[Cover] Square CDN: probing 1900x1900 and 1500x1500")
} }
return upgraded return upgraded
} }
+91
View File
@@ -1,12 +1,33 @@
package gobackend package gobackend
import ( import (
"bytes"
"errors"
"image"
"image/color"
"image/png"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "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() { func resetCoverCache() {
coverMu.Lock() coverMu.Lock()
coverCache = map[string]*coverCacheEntry{} coverCache = map[string]*coverCacheEntry{}
@@ -94,3 +115,73 @@ func TestFetchCoverCachedTTLExpiry(t *testing.T) {
t.Fatalf("expected refetch after TTL expiry, got %d fetches", got) 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 -12
View File
@@ -333,7 +333,7 @@ func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *Ext
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" { if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
return 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 url
} }
return "" return ""
@@ -350,7 +350,7 @@ func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *Ext
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" { if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
return 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 url
} }
return "" return ""
@@ -430,13 +430,3 @@ func stripProviderPrefix(id string) string {
} }
return id return id
} }
func firstNonEmptyString(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
@@ -159,6 +159,40 @@ func writeTestFlacWithISRC(t *testing.T, path, isrc string) {
} }
} }
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) { func TestISRCIndexIncrementalRebuild(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
trackA := filepath.Join(dir, "a.flac") trackA := filepath.Join(dir, "a.flac")
+6 -727
View File
@@ -14,13 +14,12 @@ import (
) )
const ( const (
deezerBaseURL = "https://api.deezer.com/2.0" deezerBaseURL = "https://api.deezer.com/2.0"
deezerSearchURL = deezerBaseURL + "/search" deezerSearchURL = deezerBaseURL + "/search"
deezerTrackURL = deezerBaseURL + "/track/%s" deezerTrackURL = deezerBaseURL + "/track/%s"
deezerAlbumURL = deezerBaseURL + "/album/%s" deezerAlbumURL = deezerBaseURL + "/album/%s"
deezerArtistURL = deezerBaseURL + "/artist/%s" deezerArtistURL = deezerBaseURL + "/artist/%s"
deezerArtistRelatedURL = deezerBaseURL + "/artist/%s/related" deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerCacheTTL = 10 * time.Minute deezerCacheTTL = 10 * time.Minute
@@ -68,250 +67,6 @@ func GetDeezerClient() *DeezerClient {
return deezerClient return deezerClient
} }
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
cache map[string]*cacheEntry,
now time.Time,
) {
for key, entry := range cache {
if entry == nil || now.After(entry.expiresAt) {
delete(cache, key)
}
}
}
func (c *DeezerClient) trimCacheEntriesLocked(
cache map[string]*cacheEntry,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
for len(cache) > maxEntries {
var oldestKey string
var oldestExpiry time.Time
first := true
for key, entry := range cache {
expiry := time.Time{}
if entry != nil {
expiry = entry.expiresAt
}
if first || expiry.Before(oldestExpiry) {
first = false
oldestKey = key
oldestExpiry = expiry
}
}
if oldestKey == "" {
return
}
delete(cache, oldestKey)
}
}
func (c *DeezerClient) trimStringCacheEntriesLocked(
cache map[string]string,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
toRemove := len(cache) - maxEntries
for key := range cache {
delete(cache, key)
toRemove--
if toRemove <= 0 {
return
}
}
}
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
(c.lastCacheCleanup.IsZero() ||
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
if periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
c.lastCacheCleanup = now
}
if len(c.searchCache) > deezerMaxSearchCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
}
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
}
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
}
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
}
if len(c.artistCache) > deezerMaxArtistCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
}
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
}
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
}
}
type deezerTrack struct {
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) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) { 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) GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
@@ -563,443 +318,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
}, nil }, 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,
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) 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),
Explicit: deezerTrackIsExplicit(track),
})
}
return &PlaylistResponsePayload{
PlaylistInfo: info,
TrackList: tracks,
}, nil
}
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) { func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc) directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
@@ -1132,45 +450,6 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
return fullTrack.ISRC, nil 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 { type AlbumExtendedMetadata struct {
Genre string Genre string
Label string Label string
+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) 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") playlist, err := client.GetPlaylist(ctx, "401")
if err != nil { if err != nil {
t.Fatalf("GetPlaylist: %v", err) t.Fatalf("GetPlaylist: %v", err)
+34 -2
View File
@@ -108,7 +108,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
} }
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))
if ext != ".flac" { if !isrcIndexExts[ext] {
return nil return nil
} }
@@ -141,7 +141,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
go func() { go func() {
defer wg.Done() defer wg.Done()
for i := range tasks { for i := range tasks {
isrcs[i] = strings.ToUpper(readFlacISRC(toParse[i].path)) isrcs[i] = strings.ToUpper(readFileISRC(toParse[i].path))
} }
}() }()
} }
@@ -170,6 +170,38 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return idx 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 // readFlacISRC extracts the ISRC Vorbis comment from a FLAC file by walking
// the metadata block headers and reading only the VORBIS_COMMENT payload; // the metadata block headers and reading only the VORBIS_COMMENT payload;
// picture and padding blocks are seeked past, never loaded. Returns "" when // picture and padding blocks are seeked past, never loaded. Returns "" when
+10 -11
View File
@@ -63,6 +63,16 @@ func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS) SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
} }
// GetTrackPlatformLinksJSON returns {"platforms": {platformID: url}} for a
// track, resolved via song.link (memory-cached; either ID may be empty).
func GetTrackPlatformLinksJSON(spotifyTrackID string, isrc string) (string, error) {
links, err := NewSongLinkClient().GetTrackPlatformLinks(spotifyTrackID, isrc)
if err != nil {
return "", err
}
return marshalJSONString(map[string]any{"platforms": links})
}
func SetDownloadDirectory(path string) error { func SetDownloadDirectory(path string) error {
return setDownloadDir(path) return setDownloadDir(path)
} }
@@ -74,17 +84,6 @@ func AllowDownloadDir(path string) {
AddAllowedDownloadDir(path) AddAllowedDownloadDir(path)
} }
func CheckDuplicate(outputDir, isrc string) (string, error) {
existingFile, exists := CheckISRCExists(outputDir, isrc)
result := map[string]any{
"exists": exists,
"filepath": existingFile,
}
return marshalJSONString(result)
}
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) { func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
return CheckFilesExistParallel(outputDir, tracksJSON) return CheckFilesExistParallel(outputDir, tracksJSON)
} }
-16
View File
@@ -17,22 +17,6 @@ func GetTrackCacheSize() int {
func ClearTrackIDCache() { func ClearTrackIDCache() {
} }
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
client := GetDeezerClient()
artists, err := client.GetRelatedArtists(ctx, artistID, limit)
if err != nil {
return "", err
}
resp := map[string]any{
"artists": artists,
}
return marshalJSONString(resp)
}
func GetDeezerMetadata(resourceType, resourceID string) (string, error) { func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
+31 -19
View File
@@ -383,12 +383,6 @@ func DownloadByStrategy(requestJSON string) (string, error) {
return errorResponse("Extension providers are disabled; built-in download providers have been retired") return errorResponse("Extension providers are disabled; built-in download providers have been retired")
} }
func GetDownloadProgress() string {
progress := getProgress()
jsonBytes, _ := json.Marshal(progress)
return string(jsonBytes)
}
func GetAllDownloadProgress() string { func GetAllDownloadProgress() string {
return GetMultiProgress() return GetMultiProgress()
} }
@@ -401,10 +395,6 @@ func InitItemProgress(itemID string) {
StartItemProgress(itemID) StartItemProgress(itemID)
} }
func FinishItemProgress(itemID string) {
CompleteItemProgress(itemID)
}
func ClearItemProgress(itemID string) { func ClearItemProgress(itemID string) {
RemoveItemProgress(itemID) RemoveItemProgress(itemID)
} }
@@ -445,18 +435,20 @@ func classifyDownloadErrorType(msg string) string {
return "isp_blocked" return "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") { } else if strings.Contains(lowerMsg, "cancel") {
return "cancelled" return "cancelled"
} else if strings.Contains(lowerMsg, "verify_required") || } else if strings.Contains(lowerMsg, "verification_required") ||
strings.Contains(lowerMsg, "verification_required") ||
strings.Contains(lowerMsg, "verification required") ||
strings.Contains(lowerMsg, "needs verification") ||
strings.Contains(lowerMsg, "session is not authenticated") || strings.Contains(lowerMsg, "session is not authenticated") ||
strings.Contains(lowerMsg, "signed session is not authenticated") || strings.Contains(lowerMsg, "signed session is not authenticated") ||
strings.Contains(lowerMsg, "signed session expired") || strings.Contains(lowerMsg, "signed session expired") {
strings.Contains(lowerMsg, "unauthorized") ||
strings.Contains(lowerMsg, "precondition required") ||
messageHasHTTPStatusCode(lowerMsg, "401") ||
messageHasHTTPStatusCode(lowerMsg, "428") {
return "verification_required" 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") || } else if strings.Contains(lowerMsg, "rate limit") ||
messageHasHTTPStatusCode(lowerMsg, "429") || messageHasHTTPStatusCode(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") { strings.Contains(lowerMsg, "too many requests") {
@@ -483,6 +475,26 @@ func classifyDownloadErrorType(msg string) string {
return "unknown" 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 { func messageHasHTTPStatusCode(lowerMsg, code string) bool {
return strings.Contains(lowerMsg, "http "+code) || return strings.Contains(lowerMsg, "http "+code) ||
strings.Contains(lowerMsg, "http status "+code) || strings.Contains(lowerMsg, "http status "+code) ||
+38 -187
View File
@@ -432,9 +432,9 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
return manager.InitializeExtension(extensionID, settings) return manager.InitializeExtension(extensionID, settings)
} }
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) { func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
manager := getExtensionManager() manager := getExtensionManager()
tracks, err := manager.SearchTracksWithExtensions(query, limit) tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -442,9 +442,9 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
return marshalJSONString(tracks) return marshalJSONString(tracks)
} }
func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) { func SearchTracksWithMetadataProviderJSON(providerID, query string, limit int) (string, error) {
manager := getExtensionManager() manager := getExtensionManager()
tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions) tracks, err := manager.SearchTracksWithMetadataProvider(providerID, query, limit)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -512,7 +512,17 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
preflightStartedAt := time.Now() preflightStartedAt := time.Now()
verificationRequired, preflightErr := preflightExtensionDownloadSession(sessionProvider) verificationRequired, preflightErr := preflightExtensionDownloadSession(sessionProvider)
if preflightErr != nil { if preflightErr != nil {
GoLog("[DownloadWithExtensions] Signed-session preflight for %s was inconclusive after %s: %v\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond), preflightErr) message := fmt.Sprintf("Could not start verification for %s: %v", sessionProvider, preflightErr)
GoLog("[DownloadWithExtensions] Signed-session preflight for %s failed after %s: %v\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond), preflightErr)
if req.ItemID != "" {
RemoveItemProgress(req.ItemID)
}
return marshalJSONString(&DownloadResponse{
Success: false,
Error: message,
ErrorType: classifyDownloadErrorType(message),
Service: sessionProvider,
})
} else if verificationRequired { } else if verificationRequired {
GoLog("[DownloadWithExtensions] Signed-session verification required for %s after %s; skipping metadata preparation\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond)) GoLog("[DownloadWithExtensions] Signed-session verification required for %s after %s; skipping metadata preparation\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond))
cacheUnpreparedDownloadRequest(downloadPreparationKey(req), req) cacheUnpreparedDownloadRequest(downloadPreparationKey(req), req)
@@ -566,7 +576,10 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
} }
func GetExtensionPendingAuthJSON(extensionID string) (string, error) { func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
req := ensureExtensionPendingAuthRequest(extensionID) req, err := ensureExtensionPendingAuthRequest(extensionID)
if err != nil {
return "", err
}
if req == nil { if req == nil {
return "", nil return "", nil
} }
@@ -580,15 +593,15 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
return marshalJSONString(result) return marshalJSONString(result)
} }
func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest { func ensureExtensionPendingAuthRequest(extensionID string) (*PendingAuthRequest, error) {
extensionID = strings.TrimSpace(extensionID) extensionID = strings.TrimSpace(extensionID)
if extensionID == "" { if extensionID == "" {
return nil return nil, nil
} }
if req := GetPendingAuthRequest(extensionID); req != nil { if req := GetPendingAuthRequest(extensionID); req != nil {
if time.Since(req.CreatedAt) < pendingAuthRequestTTL { if time.Since(req.CreatedAt) < pendingAuthRequestTTL {
return req return req, nil
} }
// The cached challenge is stale (e.g. verification was requested // The cached challenge is stale (e.g. verification was requested
// while the app was backgrounded and never completed); serving it // while the app was backgrounded and never completed); serving it
@@ -599,25 +612,24 @@ func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
manager := getExtensionManager() manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID) ext, err := manager.GetExtension(extensionID)
if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil { if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil {
return nil return nil, nil
} }
if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil { if err := ext.ensureRuntimeReady(); err != nil {
return nil return nil, err
}
if ext.runtime == nil {
return nil, fmt.Errorf("extension '%s' runtime is unavailable", extensionID)
} }
config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession) verificationRequired, err := ext.runtime.preflightSignedSession()
if config.Namespace == "" || config.BaseURL == "" { if err != nil {
return nil return nil, err
} }
if record, err := ext.runtime.loadSignedSession(config); err == nil { if !verificationRequired {
record.SessionID = "" return nil, nil
record.SessionSecret = ""
record.ExpiresAt = ""
_ = ext.runtime.saveSignedSession(config, record)
} }
ext.runtime.startSignedSessionVerification(config, "pending-auth-request") return GetPendingAuthRequest(extensionID), nil
return GetPendingAuthRequest(extensionID)
} }
func SetExtensionAuthCodeByID(extensionID, authCode string) { func SetExtensionAuthCodeByID(extensionID, authCode string) {
@@ -771,47 +783,7 @@ func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optio
result := make([]map[string]any, len(tracks)) result := make([]map[string]any, len(tracks))
for i, track := range tracks { for i, track := range tracks {
result[i] = map[string]any{ result[i] = normalizeExtensionTrackMetadataMap(track, "", 0)
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
"disc_number": track.DiscNumber,
"total_discs": track.TotalDiscs,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
"composer": track.Composer,
"audio_quality": track.AudioQuality,
"audio_modes": track.AudioModes,
"explicit": track.Explicit,
}
}
return marshalJSONString(result)
}
func GetSearchProvidersJSON() (string, error) {
manager := getExtensionManager()
providers := manager.GetSearchProviders()
result := make([]map[string]any, 0, len(providers))
for _, p := range providers {
result = append(result, map[string]any{
"id": p.extension.ID,
"display_name": p.extension.Manifest.DisplayName,
"placeholder": p.extension.Manifest.SearchBehavior.Placeholder,
"primary": p.extension.Manifest.SearchBehavior.Primary,
"icon": p.extension.Manifest.SearchBehavior.Icon,
})
} }
return marshalJSONString(result) return marshalJSONString(result)
@@ -841,51 +813,13 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
} }
if result.Track != nil { if result.Track != nil {
response["track"] = map[string]any{ response["track"] = normalizeExtensionTrackMetadataMap(*result.Track, "", 0)
"id": result.Track.ID,
"name": result.Track.Name,
"artists": result.Track.Artists,
"album_name": result.Track.AlbumName,
"album_artist": result.Track.AlbumArtist,
"duration_ms": result.Track.DurationMS,
"images": result.Track.ResolvedCoverURL(),
"preview_url": result.Track.PreviewURL,
"release_date": result.Track.ReleaseDate,
"track_number": result.Track.TrackNumber,
"total_tracks": result.Track.TotalTracks,
"disc_number": result.Track.DiscNumber,
"total_discs": result.Track.TotalDiscs,
"isrc": result.Track.ISRC,
"provider_id": result.Track.ProviderID,
"composer": result.Track.Composer,
"explicit": result.Track.Explicit,
}
} }
if len(result.Tracks) > 0 { if len(result.Tracks) > 0 {
tracks := make([]map[string]any, len(result.Tracks)) tracks := make([]map[string]any, len(result.Tracks))
for i, track := range result.Tracks { for i, track := range result.Tracks {
tracks[i] = map[string]any{ tracks[i] = normalizeExtensionTrackMetadataMap(track, "", 0)
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
"disc_number": track.DiscNumber,
"total_discs": track.TotalDiscs,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
"composer": track.Composer,
"explicit": track.Explicit,
}
} }
response["tracks"] = tracks response["tracks"] = tracks
} }
@@ -964,26 +898,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
if len(result.Artist.TopTracks) > 0 { if len(result.Artist.TopTracks) > 0 {
topTracks := make([]map[string]any, len(result.Artist.TopTracks)) topTracks := make([]map[string]any, len(result.Artist.TopTracks))
for i, track := range result.Artist.TopTracks { for i, track := range result.Artist.TopTracks {
topTracks[i] = map[string]any{ topTracks[i] = normalizeExtensionTrackMetadataMap(track, "", 0)
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
"disc_number": track.DiscNumber,
"total_discs": track.TotalDiscs,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"spotify_id": track.SpotifyID,
"composer": track.Composer,
"explicit": track.Explicit,
}
} }
artistResponse["top_tracks"] = topTracks artistResponse["top_tracks"] = topTracks
} }
@@ -1003,39 +918,6 @@ func FindURLHandlerJSON(url string) string {
return handler.extension.ID return handler.extension.ID
} }
func GetURLHandlersJSON() (string, error) {
manager := getExtensionManager()
handlers := manager.GetURLHandlers()
result := make([]map[string]any, 0, len(handlers))
for _, h := range handlers {
result = append(result, map[string]any{
"id": h.extension.ID,
"display_name": h.extension.Manifest.DisplayName,
"patterns": h.extension.Manifest.URLHandler.Patterns,
})
}
return marshalJSONString(result)
}
func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
var metadata map[string]any
if metadataJSON != "" {
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
metadata = make(map[string]any)
}
}
manager := getExtensionManager()
result, err := manager.RunPostProcessing(filePath, metadata)
if err != nil {
return "", err
}
return marshalJSONString(result)
}
func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) { func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
var metadata map[string]any var metadata map[string]any
if metadataJSON != "" { if metadataJSON != "" {
@@ -1060,33 +942,6 @@ func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
return marshalJSONString(result) return marshalJSONString(result)
} }
func GetPostProcessingProvidersJSON() (string, error) {
manager := getExtensionManager()
providers := manager.GetPostProcessingProviders()
result := make([]map[string]any, 0, len(providers))
for _, p := range providers {
hooks := make([]map[string]any, 0)
for _, h := range p.extension.Manifest.GetPostProcessingHooks() {
hooks = append(hooks, map[string]any{
"id": h.ID,
"name": h.Name,
"description": h.Description,
"default_enabled": h.DefaultEnabled,
"supported_formats": h.SupportedFormats,
})
}
result = append(result, map[string]any{
"id": p.extension.ID,
"display_name": p.extension.Manifest.DisplayName,
"hooks": hooks,
})
}
return marshalJSONString(result)
}
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) { func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
return callExtensionFunctionJSONWithRequestID(extensionID, functionName, timeout, "") return callExtensionFunctionJSONWithRequestID(extensionID, functionName, timeout, "")
} }
@@ -1165,10 +1020,6 @@ func GetExtensionHomeFeedJSONWithRequestID(extensionID, requestID string) (strin
return callExtensionFunctionJSONWithRequestID(extensionID, "getHomeFeed", 60*time.Second, requestID) return callExtensionFunctionJSONWithRequestID(extensionID, "getHomeFeed", 60*time.Second, requestID)
} }
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
}
func CancelExtensionRequestJSON(requestID string) { func CancelExtensionRequestJSON(requestID string) {
cancelExtensionRequest(requestID) cancelExtensionRequest(requestID)
} }
+4 -23
View File
@@ -7,29 +7,10 @@ import (
"strings" "strings"
) )
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
result := map[string]any{
"success": true,
"source": lyrics.Source,
"sync_type": lyrics.SyncType,
"lines": lyrics.Lines,
"instrumental": lyrics.Instrumental,
}
return marshalJSONString(result)
}
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" { if filePath != "" {
lyrics, err := ExtractLyrics(filePath) lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" { if err == nil && rawLyricsHasUsableContent(lyrics) {
return lyrics, nil return lyrics, nil
} }
return "", nil return "", nil
@@ -53,7 +34,7 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" { if filePath != "" {
lyrics, err := ExtractLyrics(filePath) lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" { if err == nil && rawLyricsHasUsableContent(lyrics) {
source := extractLyricsSourceFromLRC(lyrics) source := extractLyricsSourceFromLRC(lyrics)
if source == "" { if source == "" {
source = "Embedded" source = "Embedded"
@@ -62,7 +43,7 @@ func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath st
"lyrics": lyrics, "lyrics": lyrics,
"source": source, "source": source,
"sync_type": "EMBEDDED", "sync_type": "EMBEDDED",
"instrumental": false, "instrumental": isInstrumentalLyricsMarker(lyrics),
} }
return marshalJSONString(result) return marshalJSONString(result)
} }
@@ -119,7 +100,7 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6
// use those directly instead of making redundant network requests. // use those directly instead of making redundant network requests.
if audioFilePath != "" { if audioFilePath != "" {
existing, err := ExtractLyrics(audioFilePath) existing, err := ExtractLyrics(audioFilePath)
if err == nil && strings.TrimSpace(existing) != "" { if err == nil && rawLyricsHasUsableContent(existing) {
if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil { if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil {
return fmt.Errorf("failed to write LRC file: %w", err) return fmt.Errorf("failed to write LRC file: %w", err)
} }
+16
View File
@@ -144,6 +144,13 @@ func ReadFileMetadata(filePath string) (string, error) {
} }
if quality.SampleRate > 0 && quality.TotalSamples > 0 { if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) 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)
}
} }
} }
} }
@@ -164,6 +171,10 @@ func ReadFileMetadata(filePath string) (string, error) {
} }
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) { if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
result["bitrate"] = quality.Bitrate 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 { } else if isMp3 {
@@ -555,6 +566,11 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
coverData, _, err = extractMP3CoverArt(audioPath) coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") { } else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
coverData, _, err = extractOggCoverArt(audioPath) 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 { } else {
return fmt.Errorf("unsupported audio format for cover extraction") return fmt.Errorf("unsupported audio format for cover extraction")
} }
+87 -23
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"sync"
"time" "time"
"golang.org/x/text/cases" "golang.org/x/text/cases"
@@ -14,6 +15,62 @@ import (
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2" 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 { type musicBrainzTag struct {
Count int `json:"count"` Count int `json:"count"`
Name string `json:"name"` Name string `json:"name"`
@@ -170,33 +227,40 @@ func fetchMusicBrainzRecordingByISRC(isrc string, inc string, payload any) (stri
} }
func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) { func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) {
var payload musicBrainzAlbumArtistResponse key := "albumartist\x00" + strings.ToUpper(strings.TrimSpace(isrc)) +
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "releases+artist-credits", &payload) "\x00" + strings.ToLower(strings.TrimSpace(albumName))
if err != nil { return musicBrainzCached(key, func() (string, error) {
return "", err var payload musicBrainzAlbumArtistResponse
} normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "releases+artist-credits", &payload)
for _, recording := range payload.Recordings { if err != nil {
if albumArtist := selectMusicBrainzAlbumArtist(recording.Releases, albumName); albumArtist != "" { return "", err
return albumArtist, nil }
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) return "", fmt.Errorf("no MusicBrainz album artist found for ISRC: %s", normalizedISRC)
})
} }
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) { func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
var payload musicBrainzRecordingResponse key := "genre\x00" + strings.ToUpper(strings.TrimSpace(isrc))
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "tags", &payload) return musicBrainzCached(key, func() (string, error) {
if err != nil { var payload musicBrainzRecordingResponse
return "", err normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "tags", &payload)
} if err != nil {
if len(payload.Recordings) == 0 { return "", err
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC) }
} if len(payload.Recordings) == 0 {
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
}
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags) genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
if genre == "" { if genre == "" {
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC) return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
} }
return genre, nil return genre, nil
})
} }
+9 -1
View File
@@ -43,6 +43,10 @@ type reEnrichRequest struct {
DurationMs int64 `json:"duration_ms"` DurationMs int64 `json:"duration_ms"`
SearchOnline bool `json:"search_online"` SearchOnline bool `json:"search_online"`
UpdateFields []string `json:"update_fields,omitempty"` 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. // shouldUpdateField returns true if the given field group should be updated.
@@ -92,10 +96,14 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
return return
} }
sameRelease := reEnrichSameRelease(req.AlbumName, track.AlbumName) albumMatches := reEnrichSameRelease(req.AlbumName, track.AlbumName)
sameRelease := req.ReplaceReleaseMetadata || albumMatches
if !sameRelease { if !sameRelease {
GoLog("[ReEnrich] Candidate album %q differs from file album %q; keeping release identity (album, cover, positions, date)\n", GoLog("[ReEnrich] Candidate album %q differs from file album %q; keeping release identity (album, cover, positions, date)\n",
track.AlbumName, req.AlbumName) 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 != "" { if track.SpotifyID != "" {
@@ -20,9 +20,6 @@ func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
t.Fatal(err) 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]" { if lrc, err := GetLyricsLRC("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || lrc != "[instrumental:true]" {
t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err) t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err)
} }
@@ -51,6 +48,36 @@ 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) { func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
origClient := globalSongLinkClient origClient := globalSongLinkClient
origRetryConfig := songLinkRetryConfig origRetryConfig := songLinkRetryConfig
@@ -112,6 +139,19 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
t.Fatal("songLinkExtractDeezerTrackID mismatch") 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{ deezerClient = &DeezerClient{
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery) body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
+107 -29
View File
@@ -33,9 +33,8 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) { func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
cases := []string{ cases := []string{
"HTTP 401 for /tickets", "verification_required: canonical gateway challenge",
"HTTP status 428: precondition required", "signed session expired",
"Verification required",
} }
for _, tc := range cases { for _, tc := range cases {
if got := classifyDownloadErrorType(tc); got != "verification_required" { 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) { func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil { if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
@@ -104,6 +173,40 @@ func TestExtensionTrackExportsPreserveExplicitFlag(t *testing.T) {
assertExplicit("URL handler", jsonText, err) 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) { func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
dataDir := filepath.Join(dir, "data") dataDir := filepath.Join(dir, "data")
@@ -134,10 +237,9 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
} }
InitItemProgress("item-1") InitItemProgress("item-1")
FinishItemProgress("item-1")
ClearItemProgress("item-1") ClearItemProgress("item-1")
CancelDownload("item-1") CancelDownload("item-1")
if GetDownloadProgress() == "" || GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" { if GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
t.Fatal("expected progress JSON") t.Fatal("expected progress JSON")
} }
CleanupConnections() CleanupConnections()
@@ -194,9 +296,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if err := SetDownloadDirectory(dir); err != nil { if err := SetDownloadDirectory(dir); err != nil {
t.Fatalf("SetDownloadDirectory: %v", err) 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") { 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) t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err)
} }
@@ -262,9 +361,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
t.Fatal("expected settings JSON error") 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") { if jsonText, err := SearchTracksWithMetadataProvidersJSON("song", 5, true); err != nil || !strings.Contains(jsonText, "search-1") {
t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err) t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err)
} }
@@ -362,9 +458,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if _, err := GetDeezerMetadata("bad", "1"); err == nil { if _, err := GetDeezerMetadata("bad", "1"); err == nil {
t.Fatal("expected unsupported Deezer metadata type") 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") { if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") {
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err) t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
} }
@@ -385,37 +478,22 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if customJSON, err := CustomSearchWithExtensionJSONWithRequestID(ext.ID, "needle", `not-json`, "req-custom"); err != nil || !strings.Contains(customJSON, "custom-1") { 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) 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 { if found := FindURLHandlerJSON("https://example.test/track/1"); found != ext.ID {
t.Fatalf("FindURLHandlerJSON = %q", found) 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") { if handledJSON, err := HandleURLWithExtensionJSON("https://example.test/track/1"); err != nil || !strings.Contains(handledJSON, "url-track") {
t.Fatalf("HandleURLWithExtensionJSON = %q/%v", handledJSON, err) 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}` 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") { if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") {
t.Fatalf("RunPostProcessingV2JSON = %q/%v", postJSON, err) 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") { if feedJSON, err := GetExtensionHomeFeedJSON(ext.ID); err != nil || !strings.Contains(feedJSON, "home-1") {
t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err) t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err)
} }
if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") { if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") {
t.Fatalf("GetExtensionHomeFeedJSONWithRequestID = %q/%v", feedJSON, err) 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") CancelExtensionRequestJSON("req-home")
storeDir := filepath.Join(dir, "store") storeDir := filepath.Join(dir, "store")
+38
View File
@@ -405,6 +405,44 @@ func TestApplyReEnrichTrackMetadataKeepsReleaseIdentityOnAlbumMismatch(t *testin
} }
} }
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) { func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
req := reEnrichRequest{ req := reEnrichRequest{
TrackName: "Song Title", TrackName: "Song Title",
+83 -531
View File
@@ -3,375 +3,10 @@ package gobackend
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
) )
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 ""
}
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, sourceProvider 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, sourceProvider) || !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
}
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
}
// attemptExtensionDownload runs a single provider.Download attempt: builds the // attemptExtensionDownload runs a single provider.Download attempt: builds the
// output path, reports progress, and on success assembles the full // output path, reports progress, and on success assembles the full
// DownloadResponse (overlay, request fallbacks, optional title/artist/composer // DownloadResponse (overlay, request fallbacks, optional title/artist/composer
@@ -391,6 +26,23 @@ func attemptExtensionDownload(
lastRetryAfterSeconds *int, lastRetryAfterSeconds *int,
) (resp *DownloadResponse, cancelledOuter bool) { ) (resp *DownloadResponse, cancelledOuter bool) {
outputPath := buildOutputPathForExtension(req, ext) 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 != "" { if req.ItemID != "" {
SetItemPreparingStage(req.ItemID, "resolving_stream") SetItemPreparingStage(req.ItemID, "resolving_stream")
} }
@@ -408,6 +60,23 @@ func attemptExtensionDownload(
} }
}) })
downloadSucceeded := err == nil && result != nil && result.Success 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 { if req.ItemID != "" && downloadSucceeded {
SetItemFinalizing(req.ItemID) SetItemFinalizing(req.ItemID)
} }
@@ -603,12 +272,37 @@ func attemptVerifiedResumeBeforeMetadata(
Service: selectedProvider, Service: selectedProvider,
}, false }, 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 // A failed fast attempt may still succeed after source enrichment resolves a
// better provider-native ID. The normal path below retains strict/stop- // better provider-native ID. The normal path below retains strict/stop-
// fallback semantics for that prepared retry. // fallback semantics for that prepared retry.
return nil, false 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) { func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
pipelineStartedAt := time.Now() pipelineStartedAt := time.Now()
defer func() { defer func() {
@@ -859,6 +553,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if sourceErrType == "" && lastErr != nil { if sourceErrType == "" && lastErr != nil {
sourceErrType = classifyDownloadErrorType(lastErr.Error()) 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") { if strings.EqualFold(sourceErrType, "verification_required") {
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source) GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
cachePreparedDownloadRequest(preparationKey, req) cachePreparedDownloadRequest(preparationKey, req)
@@ -879,7 +581,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
return &DownloadResponse{ return &DownloadResponse{
Success: false, Success: false,
Error: "Download failed: " + lastErr.Error(), Error: "Download failed: " + lastErr.Error(),
ErrorType: firstNonEmptyString(lastErrType, "extension_error"), ErrorType: firstNonEmptyTrimmed(lastErrType, "extension_error"),
RetryAfterSeconds: lastRetryAfterSeconds, RetryAfterSeconds: lastRetryAfterSeconds,
Service: req.Source, Service: req.Source,
}, nil }, nil
@@ -889,7 +591,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
} }
priority = prioritizeFallbackProvidersByHealth(priority, extManager, req.Source) healthProtectedProvider := selectedProvider
if strings.TrimSpace(healthProtectedProvider) == "" {
healthProtectedProvider = req.Source
}
priority = prioritizeFallbackProvidersByHealth(priority, extManager, healthProtectedProvider)
priority = moveProviderToFront(priority, selectedProvider)
for _, providerID := range priority { for _, providerID := range priority {
if isDownloadCancelled(req.ItemID) { if isDownloadCancelled(req.ItemID) {
@@ -992,6 +699,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if effType == "" { if effType == "" {
effType = classifyDownloadErrorType(lastErr.Error()) 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") { if strings.EqualFold(effType, "verification_required") {
GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID) GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID)
cachePreparedDownloadRequest(preparationKey, req) cachePreparedDownloadRequest(preparationKey, req)
@@ -1013,7 +728,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
if lastErr != nil { if lastErr != nil {
errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error())) errorType := firstNonEmptyTrimmed(lastErrType, classifyDownloadErrorType(lastErr.Error()))
if errorType == "unknown" { if errorType == "unknown" {
errorType = "not_found" errorType = "not_found"
} }
@@ -1031,166 +746,3 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ErrorType: "not_found", ErrorType: "not_found",
}, nil }, nil
} }
// 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 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
}
+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")
}
}
+1
View File
@@ -485,6 +485,7 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"), AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"), ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"), ErrorType: gojaObjectString(obj, "error_type", "errorType"),
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"), RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
@@ -72,7 +72,7 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
} }
func TestCoverAndIDHSHelpers(t *testing.T) { func TestCoverAndIDHSHelpers(t *testing.T) {
if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1800x1800") { if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1900x1900") {
t.Fatalf("deezer cover = %q", got) t.Fatalf("deezer cover = %q", got)
} }
if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") { if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") {
+50 -554
View File
@@ -4,121 +4,15 @@ import (
"archive/zip" "archive/zip"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
func compareVersions(v1, v2 string) int {
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for i := 0; i < maxLen; i++ {
var n1, n2 int
if i < len(parts1) {
n1, _ = strconv.Atoi(parts1[i])
}
if i < len(parts2) {
n2, _ = strconv.Atoi(parts2[i])
}
if n1 < n2 {
return -1
}
if n1 > n2 {
return 1
}
}
return 0
}
func isExtensionPackagePath(filePath string) bool {
lowerPath := strings.ToLower(filePath)
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
}
func managedExtensionPath(root, extensionID string) (string, error) {
if root == "" {
return "", fmt.Errorf("extension directory is not configured")
}
if !extensionIDPattern.MatchString(extensionID) {
return "", fmt.Errorf("invalid extension ID %q", extensionID)
}
fullPath := filepath.Join(root, extensionID)
if !isPathWithinBase(root, fullPath) {
return "", fmt.Errorf("extension path escapes its managed directory")
}
return fullPath, nil
}
func safeExtensionAssetPath(root, assetPath string) (string, bool) {
if root == "" || assetPath == "" || filepath.IsAbs(assetPath) || strings.Contains(assetPath, `\`) {
return "", false
}
cleaned := path.Clean(assetPath)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", false
}
fullPath := filepath.Join(root, filepath.FromSlash(cleaned))
return fullPath, isPathWithinBase(root, fullPath)
}
func extractExtensionArchive(zipReader *zip.ReadCloser, destination string) error {
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
relPath := path.Clean(file.Name)
if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
destPath := filepath.Join(destination, filepath.FromSlash(relPath))
if !isPathWithinBase(destination, destPath) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return fmt.Errorf("failed to create extension directory: %w", err)
}
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create extension file: %w", err)
}
srcFile, err := file.Open()
if err != nil {
destFile.Close()
return fmt.Errorf("failed to open file in archive: %w", err)
}
_, copyErr := io.Copy(destFile, srcFile)
closeSrcErr := srcFile.Close()
closeDestErr := destFile.Close()
if copyErr != nil {
return fmt.Errorf("failed to extract extension file: %w", copyErr)
}
if closeSrcErr != nil || closeDestErr != nil {
return fmt.Errorf("failed to close extracted extension file")
}
}
return nil
}
type loadedExtension struct { type loadedExtension struct {
ID string `json:"id"` ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"` Manifest *ExtensionManifest `json:"manifest"`
@@ -159,6 +53,13 @@ func getExtensionInitSettings(extensionID string) map[string]any {
} }
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error { func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
// Gate enabling too, so a package installed with a failed gate cannot be
// switched on anyway.
if err := validateManifestGates(ext.Manifest); err != nil {
ext.Error = err.Error()
ext.Enabled = false
return err
}
if ext.VM == nil || ext.runtime == nil { if ext.VM == nil || ext.runtime == nil {
if err := initializeVMLocked(ext); err != nil { if err := initializeVMLocked(ext); err != nil {
ext.Error = err.Error() ext.Error = err.Error()
@@ -260,37 +161,9 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
} }
defer zipReader.Close() defer zipReader.Close()
var manifestData []byte manifest, err := inspectExtensionPackage(zipReader.File)
var hasIndexJS bool
for _, file := range zipReader.File {
name := filepath.Base(file.Name)
if name == "manifest.json" {
rc, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
}
manifestData, err = io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
}
}
if name == "index.js" {
hasIndexJS = true
}
}
if manifestData == nil {
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
}
if !hasIndexJS {
return nil, fmt.Errorf("invalid extension package: index.js not found")
}
manifest, err := ParseManifest(manifestData)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid extension manifest: %w", err) return nil, err
} }
m.mu.RLock() m.mu.RLock()
@@ -377,383 +250,55 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
return ext, nil return ext, nil
} }
func initializeVMLocked(ext *loadedExtension) error { var supportedRuntimeFeatures = map[string]int{
ext.VM = nil "signedSession": 3,
ext.runtime = nil "sessionRefresh": 1,
ext.indexProgram = nil "sessionGrant": 1,
ext.initialized = false "globalAction": 1,
vm := goja.New() "webviewAuth": 1,
ext.VM = vm
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return fmt.Errorf("failed to compile extension code: %w", err)
}
ext.indexProgram = indexProgram
runtime := newExtensionRuntime(ext)
ext.runtime = runtime
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]any, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
GoLog("[Extension:%s] %v\n", ext.ID, args)
return goja.Undefined()
})
vm.Set("console", console)
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
})
_, err = vm.RunProgram(indexProgram)
if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err)
}
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
return fmt.Errorf("extension did not call registerExtension()")
}
return nil
} }
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) { // validateManifestGates enforces minAppVersion and requiredRuntimeFeatures
vm := goja.New() // on every load path (.sflx install, upgrade, directory load); the Store UI
// check alone never covered manual installs. An empty app version (tests,
indexProgram := ext.indexProgram // dev harnesses) skips the version gate.
if indexProgram == nil { func validateManifestGates(manifest *ExtensionManifest) error {
indexPath := filepath.Join(ext.SourceDir, "index.js") if manifest == nil {
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
}
}
runtime := &extensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]any),
cookieJar: nil,
dataDir: ext.DataDir,
vm: vm,
}
if ext.runtime != nil && ext.runtime.cookieJar != nil {
runtime.cookieJar = ext.runtime.cookieJar
} else {
jar, _ := newSimpleCookieJar()
runtime.cookieJar = jar
}
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]any, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
GoLog("[Extension:%s] %v\n", ext.ID, args)
return goja.Undefined()
})
vm.Set("console", console)
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
})
if _, err := vm.RunProgram(indexProgram); err != nil {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
}
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
}
settings := getExtensionInitSettings(ext.ID)
if len(settings) > 0 {
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
runtime.closeStorageFlusher()
return nil, nil, err
}
}
return vm, runtime, nil
}
// A goja runtime plus an executed extension program is several MB of live
// heap; rebuilding one per download multiplies that by the number of tracks.
// Extensions already serve many calls on the persistent shared VM, so reusing
// an initialized isolated runtime for consecutive downloads is the same
// lifecycle contract.
const maxIdleIsolatedRuntimes = 1
// acquireIsolatedExtensionRuntime pops an idle pooled runtime or builds one.
func acquireIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
ext.isolatedPoolMu.Lock()
if n := len(ext.isolatedPool); n > 0 {
handle := ext.isolatedPool[n-1]
ext.isolatedPool = ext.isolatedPool[:n-1]
ext.isolatedPoolMu.Unlock()
return handle.vm, handle.runtime, nil
}
ext.isolatedPoolMu.Unlock()
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return newIsolatedExtensionRuntime(ext)
}
// releaseIsolatedExtensionRuntime pools a healthy runtime for reuse or tears
// it down. Pass healthy=false after an interrupt/timeout/script error, whose
// VM state can't be trusted for reuse.
func releaseIsolatedExtensionRuntime(ext *loadedExtension, vm *goja.Runtime, runtime *extensionRuntime, healthy, cleanupSafe bool) {
if runtime != nil {
if err := runtime.flushStorageNow(); err != nil {
GoLog("[Extension:%s] isolated download storage flush failed: %v\n", ext.ID, err)
}
}
if healthy && vm != nil && runtime != nil && ext.Enabled {
ext.isolatedPoolMu.Lock()
if len(ext.isolatedPool) < maxIdleIsolatedRuntimes {
ext.isolatedPool = append(ext.isolatedPool, &isolatedRuntimeHandle{vm: vm, runtime: runtime})
ext.isolatedPoolMu.Unlock()
return
}
ext.isolatedPoolMu.Unlock()
}
if cleanupSafe {
if cleanupErr := runCleanupOnVM(vm); cleanupErr != nil {
GoLog("[Extension:%s] isolated download cleanup failed: %v\n", ext.ID, cleanupErr)
}
}
if runtime != nil {
runtime.closeStorageFlusher()
}
}
// quarantineRuntimeLocked detaches a VM that remained busy after interrupt.
// The caller holds VMMu. Touching or cleaning up that VM would race its stuck
// goroutine; a later call will build a fresh runtime from indexProgram.
func quarantineRuntimeLocked(ext *loadedExtension, vm *goja.Runtime) {
if ext == nil || ext.VM != vm {
return
}
ext.VM = nil
ext.runtime = nil
ext.initialized = false
ext.Error = "extension runtime was quarantined after an unresponsive script"
}
// drainIsolatedRuntimePool tears down idle isolated runtimes. Called on
// extension teardown and on app-wide memory release.
func drainIsolatedRuntimePool(ext *loadedExtension) {
ext.isolatedPoolMu.Lock()
pool := ext.isolatedPool
ext.isolatedPool = nil
ext.isolatedPoolMu.Unlock()
for _, handle := range pool {
if cleanupErr := runCleanupOnVM(handle.vm); cleanupErr != nil {
GoLog("[Extension:%s] isolated pool cleanup failed: %v\n", ext.ID, cleanupErr)
}
if handle.runtime != nil {
if err := handle.runtime.flushStorageNow(); err != nil {
GoLog("[Extension:%s] isolated pool storage flush failed: %v\n", ext.ID, err)
}
handle.runtime.closeStorageFlusher()
}
}
}
// drainAllIsolatedRuntimePools releases every extension's idle isolated
// runtimes (memory-pressure hook).
func drainAllIsolatedRuntimePools() {
m := getExtensionManager()
m.mu.RLock()
exts := make([]*loadedExtension, 0, len(m.extensions))
for _, ext := range m.extensions {
exts = append(exts, ext)
}
m.mu.RUnlock()
for _, ext := range exts {
drainIsolatedRuntimePool(ext)
}
}
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return initializeVMLocked(ext)
}
func initializeExtensionRuntimeWithSettings(
vm *goja.Runtime,
extensionID string,
settings map[string]any,
) error {
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("failed to save settings")
}
script := fmt.Sprintf(`
(function() {
var settings = %s;
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
try {
extension.initialize(settings);
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no initialize function' };
})()
`, string(settingsJSON))
result, err := vm.RunString(script)
if err != nil {
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]any); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
return nil
}
func initializeExtensionWithSettingsLocked(
ext *loadedExtension,
settings map[string]any,
) error {
if ext.VM == nil {
return fmt.Errorf("extension failed to load: please reinstall the extension")
}
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
ext.Error = err.Error()
ext.Enabled = false
return err
}
ext.initialized = true
GoLog("[Extension] Initialized %s\n", ext.ID)
return nil
}
func runCleanupLocked(ext *loadedExtension) error {
if ext.VM != nil {
if err := runCleanupOnVM(ext.VM); err != nil {
return err
}
if ext.VM.Get("extension") != nil {
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
}
}
return nil
}
func runCleanupOnVM(vm *goja.Runtime) error {
if vm == nil {
return nil return nil
} }
minVersion := strings.TrimSpace(manifest.MinAppVersion)
script := ` appVersion := strings.TrimSpace(GetAppVersion())
(function() { if minVersion != "" && appVersion != "" && compareVersions(appVersion, minVersion) < 0 {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { return fmt.Errorf("requires app %s or later (installed: %s)", minVersion, appVersion)
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := vm.RunString(script)
if err != nil {
return err
} }
for _, raw := range manifest.RequiredRuntimeFeatures {
if result != nil && !goja.IsUndefined(result) { name := strings.TrimSpace(raw)
exported := result.Export() if name == "" {
if resultMap, ok := exported.(map[string]any); ok { continue
if success, ok := resultMap["success"].(bool); ok && !success { }
errMsg := "unknown error" wantVersion := 1
if e, ok := resultMap["error"].(string); ok { if at := strings.LastIndex(name, "@"); at > 0 {
errMsg = e if v, err := strconv.Atoi(name[at+1:]); err == nil && v > 0 {
} wantVersion = v
return fmt.Errorf("cleanup failed: %s", errMsg)
} }
name = name[:at]
}
have, ok := supportedRuntimeFeatures[name]
if !ok {
return fmt.Errorf("requires runtime feature %q this app build does not provide", name)
}
if have < wantVersion {
return fmt.Errorf("requires runtime feature %s@%d (app provides @%d)", name, wantVersion, have)
} }
} }
return nil return nil
} }
func teardownVMLocked(ext *loadedExtension) {
drainIsolatedRuntimePool(ext)
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
}
ext.runtime.closeStorageFlusher()
}
ext.runtime = nil
ext.VM = nil
ext.initialized = false
}
func validateExtensionLoad(ext *loadedExtension) error { func validateExtensionLoad(ext *loadedExtension) error {
if err := validateManifestGates(ext.Manifest); err != nil {
return err
}
ext.VMMu.Lock() ext.VMMu.Lock()
defer ext.VMMu.Unlock() defer ext.VMMu.Unlock()
@@ -1006,37 +551,9 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
} }
defer zipReader.Close() defer zipReader.Close()
var manifestData []byte newManifest, err := inspectExtensionPackage(zipReader.File)
var hasIndexJS bool
for _, file := range zipReader.File {
name := filepath.Base(file.Name)
if name == "manifest.json" {
rc, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
}
manifestData, err = io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
}
}
if name == "index.js" {
hasIndexJS = true
}
}
if manifestData == nil {
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
}
if !hasIndexJS {
return nil, fmt.Errorf("invalid extension package: index.js not found")
}
newManifest, err := ParseManifest(manifestData)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid extension manifest: %w", err) return nil, err
} }
m.mu.RLock() m.mu.RLock()
@@ -1159,30 +676,9 @@ func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
} }
defer zipReader.Close() defer zipReader.Close()
var manifestData []byte newManifest, err := inspectExtensionPackage(zipReader.File)
for _, file := range zipReader.File {
name := filepath.Base(file.Name)
if name == "manifest.json" {
rc, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to open manifest.json")
}
manifestData, err = io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("failed to read manifest.json")
}
break
}
}
if manifestData == nil {
return nil, fmt.Errorf("manifest.json not found")
}
newManifest, err := ParseManifest(manifestData)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid manifest: %w", err) return nil, err
} }
m.mu.RLock() m.mu.RLock()
+212
View File
@@ -0,0 +1,212 @@
package gobackend
import (
"archive/zip"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
)
func compareVersions(v1, v2 string) int {
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for i := 0; i < maxLen; i++ {
var n1, n2 int
if i < len(parts1) {
n1, _ = strconv.Atoi(parts1[i])
}
if i < len(parts2) {
n2, _ = strconv.Atoi(parts2[i])
}
if n1 < n2 {
return -1
}
if n1 > n2 {
return 1
}
}
return 0
}
func isExtensionPackagePath(filePath string) bool {
lowerPath := strings.ToLower(filePath)
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
}
func managedExtensionPath(root, extensionID string) (string, error) {
if root == "" {
return "", fmt.Errorf("extension directory is not configured")
}
if !extensionIDPattern.MatchString(extensionID) {
return "", fmt.Errorf("invalid extension ID %q", extensionID)
}
fullPath := filepath.Join(root, extensionID)
if !isPathWithinBase(root, fullPath) {
return "", fmt.Errorf("extension path escapes its managed directory")
}
return fullPath, nil
}
func safeExtensionAssetPath(root, assetPath string) (string, bool) {
if root == "" || assetPath == "" || filepath.IsAbs(assetPath) || strings.Contains(assetPath, `\`) {
return "", false
}
cleaned := path.Clean(assetPath)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", false
}
fullPath := filepath.Join(root, filepath.FromSlash(cleaned))
return fullPath, isPathWithinBase(root, fullPath)
}
const (
maxExtensionArchiveEntries = 2048
maxExtensionArchiveUncompressedBytes = 256 * 1024 * 1024
maxExtensionManifestBytes = 1024 * 1024
)
func validateExtensionArchive(files []*zip.File) error {
if len(files) > maxExtensionArchiveEntries {
return fmt.Errorf(
"extension archive contains too many entries (maximum %d)",
maxExtensionArchiveEntries,
)
}
seenPaths := make(map[string]struct{}, len(files))
var totalUncompressed uint64
for _, file := range files {
if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
relPath := path.Clean(file.Name)
if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
pathKey := strings.ToLower(relPath)
if _, exists := seenPaths[pathKey]; exists {
return fmt.Errorf("duplicate path in extension archive: %s", file.Name)
}
seenPaths[pathKey] = struct{}{}
if file.FileInfo().IsDir() {
continue
}
if file.UncompressedSize64 > maxExtensionArchiveUncompressedBytes-totalUncompressed {
return fmt.Errorf(
"extension archive exceeds the %d MiB extracted size limit",
maxExtensionArchiveUncompressedBytes/(1024*1024),
)
}
totalUncompressed += file.UncompressedSize64
}
return nil
}
func inspectExtensionPackage(files []*zip.File) (*ExtensionManifest, error) {
if err := validateExtensionArchive(files); err != nil {
return nil, err
}
var manifestFile *zip.File
hasIndexJS := false
for _, file := range files {
switch path.Clean(file.Name) {
case "manifest.json":
manifestFile = file
case "index.js":
hasIndexJS = !file.FileInfo().IsDir()
}
}
if manifestFile == nil || manifestFile.FileInfo().IsDir() {
return nil, fmt.Errorf("invalid extension package: root manifest.json not found")
}
if !hasIndexJS {
return nil, fmt.Errorf("invalid extension package: root index.js not found")
}
if manifestFile.UncompressedSize64 > maxExtensionManifestBytes {
return nil, fmt.Errorf("invalid extension package: manifest.json is too large")
}
rc, err := manifestFile.Open()
if err != nil {
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
}
manifestData, readErr := io.ReadAll(io.LimitReader(rc, maxExtensionManifestBytes+1))
closeErr := rc.Close()
if readErr != nil {
return nil, fmt.Errorf("failed to read manifest.json: %w", readErr)
}
if closeErr != nil {
return nil, fmt.Errorf("failed to close manifest.json: %w", closeErr)
}
if len(manifestData) > maxExtensionManifestBytes {
return nil, fmt.Errorf("invalid extension package: manifest.json is too large")
}
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("invalid extension manifest: %w", err)
}
return manifest, nil
}
func extractExtensionArchive(zipReader *zip.ReadCloser, destination string) error {
if err := validateExtensionArchive(zipReader.File); err != nil {
return err
}
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
relPath := path.Clean(file.Name)
if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
destPath := filepath.Join(destination, filepath.FromSlash(relPath))
if !isPathWithinBase(destination, destPath) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return fmt.Errorf("failed to create extension directory: %w", err)
}
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create extension file: %w", err)
}
srcFile, err := file.Open()
if err != nil {
destFile.Close()
return fmt.Errorf("failed to open file in archive: %w", err)
}
_, copyErr := io.Copy(destFile, srcFile)
closeSrcErr := srcFile.Close()
closeDestErr := destFile.Close()
if copyErr != nil {
return fmt.Errorf("failed to extract extension file: %w", copyErr)
}
if closeSrcErr != nil || closeDestErr != nil {
return fmt.Errorf("failed to close extracted extension file")
}
}
return nil
}
+390
View File
@@ -0,0 +1,390 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/dop251/goja"
)
func initializeVMLocked(ext *loadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.indexProgram = nil
ext.initialized = false
vm := goja.New()
ext.VM = vm
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return fmt.Errorf("failed to compile extension code: %w", err)
}
ext.indexProgram = indexProgram
runtime := newExtensionRuntime(ext)
ext.runtime = runtime
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]any, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
GoLog("[Extension:%s] %v\n", ext.ID, args)
return goja.Undefined()
})
vm.Set("console", console)
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
})
_, err = vm.RunProgram(indexProgram)
if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err)
}
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
return fmt.Errorf("extension did not call registerExtension()")
}
return nil
}
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
vm := goja.New()
indexProgram := ext.indexProgram
if indexProgram == nil {
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
}
}
runtime := &extensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]any),
cookieJar: nil,
dataDir: ext.DataDir,
vm: vm,
}
if ext.runtime != nil && ext.runtime.cookieJar != nil {
runtime.cookieJar = ext.runtime.cookieJar
} else {
jar, _ := newSimpleCookieJar()
runtime.cookieJar = jar
}
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]any, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
GoLog("[Extension:%s] %v\n", ext.ID, args)
return goja.Undefined()
})
vm.Set("console", console)
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
})
if _, err := vm.RunProgram(indexProgram); err != nil {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
}
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
}
settings := getExtensionInitSettings(ext.ID)
if len(settings) > 0 {
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
runtime.closeStorageFlusher()
return nil, nil, err
}
}
return vm, runtime, nil
}
// A goja runtime plus an executed extension program is several MB of live
// heap; rebuilding one per download multiplies that by the number of tracks.
// Extensions already serve many calls on the persistent shared VM, so reusing
// an initialized isolated runtime for consecutive downloads is the same
// lifecycle contract.
const maxIdleIsolatedRuntimes = 1
// acquireIsolatedExtensionRuntime pops an idle pooled runtime or builds one.
func acquireIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
ext.isolatedPoolMu.Lock()
if n := len(ext.isolatedPool); n > 0 {
handle := ext.isolatedPool[n-1]
ext.isolatedPool = ext.isolatedPool[:n-1]
ext.isolatedPoolMu.Unlock()
return handle.vm, handle.runtime, nil
}
ext.isolatedPoolMu.Unlock()
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return newIsolatedExtensionRuntime(ext)
}
// releaseIsolatedExtensionRuntime pools a healthy runtime for reuse or tears
// it down. Pass healthy=false after an interrupt/timeout/script error, whose
// VM state can't be trusted for reuse.
func releaseIsolatedExtensionRuntime(ext *loadedExtension, vm *goja.Runtime, runtime *extensionRuntime, healthy, cleanupSafe bool) {
if runtime != nil {
if err := runtime.flushStorageNow(); err != nil {
GoLog("[Extension:%s] isolated download storage flush failed: %v\n", ext.ID, err)
}
}
if healthy && vm != nil && runtime != nil && ext.Enabled {
ext.isolatedPoolMu.Lock()
if len(ext.isolatedPool) < maxIdleIsolatedRuntimes {
ext.isolatedPool = append(ext.isolatedPool, &isolatedRuntimeHandle{vm: vm, runtime: runtime})
ext.isolatedPoolMu.Unlock()
return
}
ext.isolatedPoolMu.Unlock()
}
if cleanupSafe {
if cleanupErr := runCleanupOnVM(vm); cleanupErr != nil {
GoLog("[Extension:%s] isolated download cleanup failed: %v\n", ext.ID, cleanupErr)
}
}
if runtime != nil {
runtime.closeStorageFlusher()
}
}
// quarantineRuntimeLocked detaches a VM that remained busy after interrupt.
// The caller holds VMMu. Touching or cleaning up that VM would race its stuck
// goroutine; a later call will build a fresh runtime from indexProgram.
func quarantineRuntimeLocked(ext *loadedExtension, vm *goja.Runtime) {
if ext == nil || ext.VM != vm {
return
}
ext.VM = nil
ext.runtime = nil
ext.initialized = false
ext.Error = "extension runtime was quarantined after an unresponsive script"
}
// drainIsolatedRuntimePool tears down idle isolated runtimes. Called on
// extension teardown and on app-wide memory release.
func drainIsolatedRuntimePool(ext *loadedExtension) {
ext.isolatedPoolMu.Lock()
pool := ext.isolatedPool
ext.isolatedPool = nil
ext.isolatedPoolMu.Unlock()
for _, handle := range pool {
if cleanupErr := runCleanupOnVM(handle.vm); cleanupErr != nil {
GoLog("[Extension:%s] isolated pool cleanup failed: %v\n", ext.ID, cleanupErr)
}
if handle.runtime != nil {
if err := handle.runtime.flushStorageNow(); err != nil {
GoLog("[Extension:%s] isolated pool storage flush failed: %v\n", ext.ID, err)
}
handle.runtime.closeStorageFlusher()
}
}
}
// drainAllIsolatedRuntimePools releases every extension's idle isolated
// runtimes (memory-pressure hook).
func drainAllIsolatedRuntimePools() {
m := getExtensionManager()
m.mu.RLock()
exts := make([]*loadedExtension, 0, len(m.extensions))
for _, ext := range m.extensions {
exts = append(exts, ext)
}
m.mu.RUnlock()
for _, ext := range exts {
drainIsolatedRuntimePool(ext)
}
}
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return initializeVMLocked(ext)
}
func initializeExtensionRuntimeWithSettings(
vm *goja.Runtime,
extensionID string,
settings map[string]any,
) error {
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("failed to save settings")
}
script := fmt.Sprintf(`
(function() {
var settings = %s;
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
try {
extension.initialize(settings);
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no initialize function' };
})()
`, string(settingsJSON))
result, err := vm.RunString(script)
if err != nil {
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]any); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
return nil
}
func initializeExtensionWithSettingsLocked(
ext *loadedExtension,
settings map[string]any,
) error {
if ext.VM == nil {
return fmt.Errorf("extension failed to load: please reinstall the extension")
}
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
ext.Error = err.Error()
ext.Enabled = false
return err
}
ext.initialized = true
GoLog("[Extension] Initialized %s\n", ext.ID)
return nil
}
func runCleanupLocked(ext *loadedExtension) error {
if ext.VM != nil {
if err := runCleanupOnVM(ext.VM); err != nil {
return err
}
if ext.VM.Get("extension") != nil {
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
}
}
return nil
}
func runCleanupOnVM(vm *goja.Runtime) error {
if vm == nil {
return nil
}
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := vm.RunString(script)
if err != nil {
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]any); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
return nil
}
func teardownVMLocked(ext *loadedExtension) {
drainIsolatedRuntimePool(ext)
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
}
ext.runtime.closeStorageFlusher()
}
ext.runtime = nil
ext.VM = nil
ext.initialized = false
}
// supportedRuntimeFeatures maps every feature name the goja runtime provides
// to its current contract version (documented in SIGNED_SESSION_GUIDE.md).
@@ -155,3 +155,41 @@ registerExtension({
t.Fatal("expected all extensions unloaded") t.Fatal("expected all extensions unloaded")
} }
} }
func TestValidateManifestGates(t *testing.T) {
originalVersion := GetAppVersion()
defer SetAppVersion(originalVersion)
SetAppVersion("4.5.0")
if err := validateManifestGates(nil); err != nil {
t.Fatalf("nil manifest = %v", err)
}
if err := validateManifestGates(&ExtensionManifest{MinAppVersion: "4.9.0"}); err == nil {
t.Fatal("expected minAppVersion gate to fail")
}
if err := validateManifestGates(&ExtensionManifest{MinAppVersion: "4.5.0"}); err != nil {
t.Fatalf("equal version should pass: %v", err)
}
SetAppVersion("")
if err := validateManifestGates(&ExtensionManifest{MinAppVersion: "9.9.9"}); err != nil {
t.Fatalf("empty app version must skip the gate: %v", err)
}
SetAppVersion("4.5.0")
pass := &ExtensionManifest{
RequiredRuntimeFeatures: []string{"signedSession@3", "sessionGrant"},
}
if err := validateManifestGates(pass); err != nil {
t.Fatalf("supported features should pass: %v", err)
}
if err := validateManifestGates(&ExtensionManifest{
RequiredRuntimeFeatures: []string{"quantumDecrypt"},
}); err == nil {
t.Fatal("unknown feature must fail")
}
if err := validateManifestGates(&ExtensionManifest{
RequiredRuntimeFeatures: []string{"signedSession@99"},
}); err == nil {
t.Fatal("future contract version must fail")
}
}
+42 -2
View File
@@ -371,17 +371,57 @@ func (m *ExtensionManifest) HasURLHandler() bool {
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0 return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
} }
// MatchesURL reports whether one of the handler's patterns matches the URL.
// Web patterns are anchored to the URL's host (exact domain or subdomain,
// optional path prefix) — never matched as a raw substring, so "spotify.com"
// cannot match a URL that merely embeds it in a query parameter. Patterns
// ending in ":" (e.g. "spotify:") match custom URI schemes as prefixes.
func (m *ExtensionManifest) MatchesURL(urlStr string) bool { func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
if !m.HasURLHandler() { if !m.HasURLHandler() {
return false return false
} }
urlStr = strings.ToLower(strings.TrimSpace(urlStr)) urlStr = strings.ToLower(strings.TrimSpace(urlStr))
parsed, parseErr := url.Parse(urlStr)
for _, pattern := range m.URLHandler.Patterns { for _, pattern := range m.URLHandler.Patterns {
pattern = strings.ToLower(strings.TrimSpace(pattern)) pattern = strings.ToLower(strings.TrimSpace(pattern))
if strings.Contains(urlStr, pattern) { if pattern == "" {
return true continue
} }
// Scheme patterns anchor to the front of the URI.
if !strings.Contains(pattern, "/") && strings.HasSuffix(pattern, ":") {
if strings.HasPrefix(urlStr, pattern) {
return true
}
continue
}
if parseErr != nil || parsed.Host == "" {
continue
}
host := parsed.Hostname()
urlPath := parsed.Path
if urlPath == "" {
urlPath = "/"
}
if idx := strings.Index(pattern, "://"); idx >= 0 {
pattern = pattern[idx+3:]
}
patternHost, patternPath, hasPath := strings.Cut(pattern, "/")
if patternHost == "" {
continue
}
if host != patternHost && !strings.HasSuffix(host, "."+patternHost) {
continue
}
if hasPath && patternPath != "" &&
!strings.HasPrefix(urlPath, "/"+patternPath) {
continue
}
return true
} }
return false return false
} }
@@ -0,0 +1,184 @@
package gobackend
import (
"archive/zip"
"bytes"
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteVerifiedExtensionPackageAcceptsMatchingSHA256(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "verified.spotiflac-ext")
payload := []byte("extension package")
checksum := fmt.Sprintf("%x", sha256.Sum256(payload))
if err := writeVerifiedExtensionPackage(bytes.NewReader(payload), dest, checksum); err != nil {
t.Fatalf("writeVerifiedExtensionPackage: %v", err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("read verified package: %v", err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("verified package = %q, want %q", got, payload)
}
}
func TestWriteVerifiedExtensionPackageRejectsMismatchBeforeReplace(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "cached.spotiflac-ext")
if err := os.WriteFile(dest, []byte("existing"), 0600); err != nil {
t.Fatal(err)
}
err := writeVerifiedExtensionPackage(
bytes.NewReader([]byte("tampered")),
dest,
strings.Repeat("0", sha256.Size*2),
)
if err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") {
t.Fatalf("expected checksum mismatch, got %v", err)
}
got, readErr := os.ReadFile(dest)
if readErr != nil {
t.Fatal(readErr)
}
if string(got) != "existing" {
t.Fatalf("checksum failure replaced existing package with %q", got)
}
}
func TestRegistrySkipsOnlyExtensionWithMalformedChecksum(t *testing.T) {
checksum := strings.Repeat("a", sha256.Size*2)
registry, err := parseRegistryBody([]byte(
`{"version":1,"extensions":[` +
`{"id":"bad","name":"bad","version":"1.0.0","sha256":"not-a-hash"},` +
`{"id":"verified","name":"verified","version":"1.0.0","checksumSha256":"sha256:` +
checksum +
`"},` +
`{"id":"legacy","name":"legacy","version":"1.0.0"}` +
`]}`,
))
if err != nil {
t.Fatalf("parse registry: %v", err)
}
if len(registry.Extensions) != 2 {
t.Fatalf("registry extensions = %#v, want valid entries only", registry.Extensions)
}
if registry.Extensions[0].ID != "verified" || registry.Extensions[1].ID != "legacy" {
t.Fatalf("registry extension order = %#v", registry.Extensions)
}
if got := registry.Extensions[0].getSHA256(); got != checksum {
t.Fatalf("normalized checksum = %q, want %q", got, checksum)
}
}
func TestExtensionPackageRequiresUniqueRootEntrypoints(t *testing.T) {
dir := t.TempDir()
duplicate := filepath.Join(dir, "duplicate.spotiflac-ext")
createTestExtensionPackage(
t,
duplicate,
"duplicate-ext",
"1.0.0",
`registerExtension({});`,
map[string]string{"MANIFEST.JSON": "{}"},
)
reader, err := zip.OpenReader(duplicate)
if err != nil {
t.Fatal(err)
}
_, inspectErr := inspectExtensionPackage(reader.File)
_ = reader.Close()
if inspectErr == nil || !strings.Contains(inspectErr.Error(), "duplicate path") {
t.Fatalf("expected duplicate archive path error, got %v", inspectErr)
}
nested := filepath.Join(dir, "nested.spotiflac-ext")
writeTestZip(t, nested, map[string]string{
"nested/manifest.json": validSecurityTestManifest("nested-ext"),
"nested/index.js": `registerExtension({});`,
})
reader, err = zip.OpenReader(nested)
if err != nil {
t.Fatal(err)
}
_, inspectErr = inspectExtensionPackage(reader.File)
_ = reader.Close()
if inspectErr == nil || !strings.Contains(inspectErr.Error(), "root manifest.json") {
t.Fatalf("expected root entrypoint error, got %v", inspectErr)
}
}
func TestExtensionPackageRejectsUnsafeAndOversizedEntries(t *testing.T) {
for _, unsafePath := range []string{
"../outside.js",
`nested\outside.js`,
"/absolute.js",
} {
t.Run(unsafePath, func(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "unsafe.spotiflac-ext")
writeTestZip(t, archivePath, map[string]string{
"manifest.json": validSecurityTestManifest("unsafe-ext"),
"index.js": `registerExtension({});`,
unsafePath: "unsafe",
})
reader, err := zip.OpenReader(archivePath)
if err != nil {
t.Fatal(err)
}
_, inspectErr := inspectExtensionPackage(reader.File)
_ = reader.Close()
if inspectErr == nil || !strings.Contains(inspectErr.Error(), "unsafe path") {
t.Fatalf("expected unsafe archive path error, got %v", inspectErr)
}
})
}
oversized := &zip.File{FileHeader: zip.FileHeader{
Name: "payload.bin",
UncompressedSize64: maxExtensionArchiveUncompressedBytes + 1,
}}
if err := validateExtensionArchive([]*zip.File{oversized}); err == nil ||
!strings.Contains(err.Error(), "extracted size limit") {
t.Fatalf("expected extracted size error, got %v", err)
}
}
func validSecurityTestManifest(name string) string {
return fmt.Sprintf(
`{"name":%q,"displayName":%q,"version":"1.0.0","description":"test","type":["metadata_provider"],"permissions":{}}`,
name,
name,
)
}
func writeTestZip(t *testing.T, filePath string, files map[string]string) {
t.Helper()
output, err := os.Create(filePath)
if err != nil {
t.Fatal(err)
}
archive := zip.NewWriter(output)
for name, content := range files {
writer, createErr := archive.Create(name)
if createErr != nil {
t.Fatal(createErr)
}
if _, writeErr := writer.Write([]byte(content)); writeErr != nil {
t.Fatal(writeErr)
}
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
if err := output.Close(); err != nil {
t.Fatal(err)
}
}
@@ -92,9 +92,9 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) {
t.Fatalf("url result = %#v", urlResult) t.Fatalf("url result = %#v", urlResult)
} }
post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]any{"title": "Song"}, "hook") post, err := provider.PostProcessV2(PostProcessInput{Path: filepath.Join(t.TempDir(), "song.flac")}, map[string]any{"title": "Song"}, "hook")
if err != nil { if err != nil {
t.Fatalf("PostProcess: %v", err) t.Fatalf("PostProcessV2: %v", err)
} }
if !post.Success || post.BitDepth != 24 || post.SampleRate != 96000 { if !post.Success || post.BitDepth != 24 || post.SampleRate != 96000 {
t.Fatalf("post = %#v", post) t.Fatalf("post = %#v", post)
+1
View File
@@ -107,6 +107,7 @@ type ExtDownloadResult struct {
BitDepth int `json:"bit_depth,omitempty"` BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"` SampleRate int `json:"sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"` AudioCodec string `json:"audio_codec,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"` ErrorType string `json:"error_type,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
+16 -32
View File
@@ -151,11 +151,15 @@ func invokeExtensionMethod(vm *goja.Runtime, method string, args ...any) (goja.V
return goja.Null(), nil return goja.Null(), nil
} }
return callable(extensionObject, gojaArgumentValues(vm, args)...)
}
func gojaArgumentValues(vm *goja.Runtime, args []any) []goja.Value {
values := make([]goja.Value, len(args)) values := make([]goja.Value, len(args))
for i, arg := range args { for i, arg := range args {
values[i] = gojaArgumentValue(vm, arg) values[i] = gojaArgumentValue(vm, arg)
} }
return callable(extensionObject, values...) return values
} }
func gojaArgumentValue(vm *goja.Runtime, value any) goja.Value { func gojaArgumentValue(vm *goja.Runtime, value any) goja.Value {
@@ -214,11 +218,7 @@ func invokeExtensionOrGlobal(vm *goja.Runtime, method string, args ...any) (goja
if !ok { if !ok {
return goja.Null(), nil return goja.Null(), nil
} }
values := make([]goja.Value, len(args)) return callable(vm.GlobalObject(), gojaArgumentValues(vm, args)...)
for i, arg := range args {
values[i] = gojaArgumentValue(vm, arg)
}
return callable(vm.GlobalObject(), values...)
} }
func extensionTrackInput(track *ExtTrackMetadata) map[string]any { func extensionTrackInput(track *ExtTrackMetadata) map[string]any {
@@ -548,7 +548,7 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a
if !availability.Available && p.extension.runtime != nil { if !availability.Available && p.extension.runtime != nil {
if p.extension.runtime.consumeVerificationRequired() != "" { if p.extension.runtime.consumeVerificationRequired() != "" {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"VERIFY_REQUIRED: extension '%s' needs signed-session verification", "verification_required: extension '%s' needs signed-session verification",
p.extension.ID, p.extension.ID,
) )
} }
@@ -847,11 +847,9 @@ func postProcessInputMap(input PostProcessInput) map[string]any {
const PostProcessTimeout = 2 * time.Minute const PostProcessTimeout = 2 * time.Minute
// postProcessCommon backs both PostProcess (V1) and PostProcessV2. V1 probes // PostProcessV2 probes extension.postProcessV2 first, then falls back to the
// only extension.postProcess (its original contract: V2-only extensions are // legacy extension.postProcess contract.
// not invoked via V1); V2 probes postProcessV2 first, then falls back to func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]any, hookID string) (*PostProcessResult, error) {
// postProcess.
func (p *extensionProviderWrapper) postProcessCommon(input PostProcessInput, metadata map[string]any, hookID string, preferV2 bool) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() { if !p.extension.Manifest.HasPostProcessing() {
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
} }
@@ -861,19 +859,13 @@ func (p *extensionProviderWrapper) postProcessCommon(input PostProcessInput, met
filePath := input.Path filePath := input.Path
perfName := "postProcess" perfName := "postProcessV2"
var invoke func(*goja.Runtime) (goja.Value, error) inputMap := postProcessInputMap(input)
if preferV2 { invoke := func(vm *goja.Runtime) (goja.Value, error) {
perfName = "postProcessV2" if hasExtensionMethod(vm, "postProcessV2") {
inputMap := postProcessInputMap(input) return invokeExtensionMethod(vm, "postProcessV2", inputMap, metadata, hookID)
invoke = func(vm *goja.Runtime) (goja.Value, error) {
if hasExtensionMethod(vm, "postProcessV2") {
return invokeExtensionMethod(vm, "postProcessV2", inputMap, metadata, hookID)
}
return invokeExtensionMethod(vm, "postProcess", filePath, metadata, hookID)
} }
} else { return invokeExtensionMethod(vm, "postProcess", filePath, metadata, hookID)
invoke = extensionMethodInvocation("postProcess", filePath, metadata, hookID)
} }
result, err := callExtension(p, extCallOpts{ result, err := callExtension(p, extCallOpts{
@@ -898,14 +890,6 @@ func (p *extensionProviderWrapper) postProcessCommon(input PostProcessInput, met
return result, nil return result, nil
} }
func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[string]any, hookID string) (*PostProcessResult, error) {
return p.postProcessCommon(PostProcessInput{Path: filePath}, metadata, hookID, false)
}
func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]any, hookID string) (*PostProcessResult, error) {
return p.postProcessCommon(input, metadata, hookID, true)
}
type ExtLyricsResult struct { type ExtLyricsResult struct {
Lines []ExtLyricsLine `json:"lines"` Lines []ExtLyricsLine `json:"lines"`
SyncType string `json:"syncType"` SyncType string `json:"syncType"`
+75 -102
View File
@@ -34,49 +34,6 @@ func (m *extensionManager) GetDownloadProviders() []*extensionProviderWrapper {
return providers return providers
} }
func (m *extensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
providers := m.GetMetadataProviders()
if len(providers) == 0 {
return nil, nil
}
providerByID := make(map[string]*extensionProviderWrapper, len(providers))
orderedProviders := make([]*extensionProviderWrapper, 0, len(providers))
for _, provider := range providers {
providerByID[provider.extension.ID] = provider
}
for _, providerID := range GetMetadataProviderPriority() {
if provider := providerByID[providerID]; provider != nil {
orderedProviders = append(orderedProviders, provider)
delete(providerByID, providerID)
}
}
if len(providerByID) > 0 {
remainingIDs := make([]string, 0, len(providerByID))
for providerID := range providerByID {
remainingIDs = append(remainingIDs, providerID)
}
sort.Strings(remainingIDs)
for _, providerID := range remainingIDs {
orderedProviders = append(orderedProviders, providerByID[providerID])
}
}
var allTracks []ExtTrackMetadata
for _, provider := range orderedProviders {
result, err := provider.SearchTracks(query, limit)
if err != nil {
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
continue
}
if result != nil {
allTracks = append(allTracks, result.Tracks...)
}
}
return allTracks, nil
}
func metadataTrackDedupKey(track ExtTrackMetadata) string { func metadataTrackDedupKey(track ExtTrackMetadata) string {
if isrc := strings.TrimSpace(track.ISRC); isrc != "" { if isrc := strings.TrimSpace(track.ISRC); isrc != "" {
return "isrc:" + strings.ToUpper(isrc) return "isrc:" + strings.ToUpper(isrc)
@@ -94,6 +51,46 @@ func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit
return m.SearchTracksWithMetadataProvidersForItemID(query, limit, includeExtensions, "") return m.SearchTracksWithMetadataProvidersForItemID(query, limit, includeExtensions, "")
} }
// SearchTracksWithMetadataProvider searches one explicitly selected metadata
// provider. Unlike the priority-based search, this never falls through to a
// different extension, so callers can reliably attribute the returned fields
// to the provider selected by the user.
func (m *extensionManager) SearchTracksWithMetadataProvider(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
providerID = strings.TrimSpace(providerID)
if providerID == "" {
return nil, fmt.Errorf("metadata provider ID is required")
}
if limit <= 0 {
limit = 20
}
ext, err := m.GetExtension(providerID)
if err != nil {
return nil, err
}
if ext == nil || ext.Manifest == nil || !ext.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", providerID)
}
if !ext.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", providerID)
}
if ext.Error != "" {
return nil, fmt.Errorf("extension '%s' is unavailable: %s", providerID, ext.Error)
}
result, err := newExtensionProviderWrapper(ext).SearchTracks(query, limit)
if err != nil {
return nil, err
}
if result == nil || len(result.Tracks) <= limit {
if result == nil {
return []ExtTrackMetadata{}, nil
}
return result.Tracks, nil
}
return result.Tracks[:limit], nil
}
func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query string, limit int, includeExtensions bool, itemID string) ([]ExtTrackMetadata, error) { func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query string, limit int, includeExtensions bool, itemID string) ([]ExtTrackMetadata, error) {
priority := GetMetadataProviderPriority() priority := GetMetadataProviderPriority()
if limit <= 0 { if limit <= 0 {
@@ -176,42 +173,44 @@ func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query stri
return tracks, nil return tracks, nil
} }
func (m *extensionManager) GetSearchProviders() []*extensionProviderWrapper { // FindURLHandler returns the enabled handler matching the URL. When several
m.mu.RLock() // extensions match (e.g. two Spotify handlers), the user's metadata provider
defer m.mu.RUnlock() // priority breaks the tie deterministically instead of Go's random map
// iteration order.
var providers []*extensionProviderWrapper
for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" {
providers = append(providers, newExtensionProviderWrapper(ext))
}
}
return providers
}
func (m *extensionManager) GetURLHandlers() []*extensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
var providers []*extensionProviderWrapper
for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" {
providers = append(providers, newExtensionProviderWrapper(ext))
}
}
return providers
}
func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper { func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() matches := make([]*loadedExtension, 0, 2)
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" { if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" {
return newExtensionProviderWrapper(ext) matches = append(matches, ext)
} }
} }
return nil m.mu.RUnlock()
if len(matches) == 0 {
return nil
}
if len(matches) > 1 {
rank := map[string]int{}
for i, id := range GetMetadataProviderPriority() {
rank[strings.ToLower(strings.TrimSpace(id))] = i
}
sort.SliceStable(matches, func(i, j int) bool {
ri, oki := rank[strings.ToLower(matches[i].ID)]
rj, okj := rank[strings.ToLower(matches[j].ID)]
switch {
case oki && okj:
return ri < rj
case oki:
return true
case okj:
return false
default:
return matches[i].ID < matches[j].ID
}
})
}
return newExtensionProviderWrapper(matches[0])
} }
type ExtURLHandleResultWithExtID struct { type ExtURLHandleResultWithExtID struct {
@@ -252,21 +251,13 @@ func (m *extensionManager) GetPostProcessingProviders() []*extensionProviderWrap
return providers return providers
} }
// runPostProcessingCommon backs both RunPostProcessing (V1) and func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]any) (*PostProcessResult, error) {
// RunPostProcessingV2. V1 delegates into this shared loop with an equivalent
// PostProcessInput; preferV2 controls whether each hook is invoked via
// provider.PostProcessV2 or provider.PostProcess, so V1 keeps calling
// PostProcess (not PostProcessV2) exactly as it did before.
func (m *extensionManager) runPostProcessingCommon(input PostProcessInput, metadata map[string]any, preferV2 bool) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders() providers := m.GetPostProcessingProviders()
if len(providers) == 0 { if len(providers) == 0 {
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
} }
logTag := "[PostProcess]" logTag := "[PostProcessV2]"
if preferV2 {
logTag = "[PostProcessV2]"
}
currentInput := input currentInput := input
for _, provider := range providers { for _, provider := range providers {
@@ -295,13 +286,7 @@ func (m *extensionManager) runPostProcessingCommon(input PostProcessInput, metad
GoLog("%s Running hook %s from %s on %s\n", logTag, hook.ID, provider.extension.ID, currentInput.Path) GoLog("%s Running hook %s from %s on %s\n", logTag, hook.ID, provider.extension.ID, currentInput.Path)
var result *PostProcessResult result, err := provider.PostProcessV2(currentInput, metadata, hook.ID)
var err error
if preferV2 {
result, err = provider.PostProcessV2(currentInput, metadata, hook.ID)
} else {
result, err = provider.PostProcess(currentInput.Path, metadata, hook.ID)
}
if err != nil { if err != nil {
GoLog("%s Hook %s failed: %v\n", logTag, hook.ID, err) GoLog("%s Hook %s failed: %v\n", logTag, hook.ID, err)
continue continue
@@ -360,18 +345,6 @@ func validatePostProcessResult(ext *loadedExtension, input PostProcessInput, res
return fmt.Errorf("replacement file path is outside allowed directories") return fmt.Errorf("replacement file path is outside allowed directories")
} }
func (m *extensionManager) RunPostProcessing(filePath string, metadata map[string]any) (*PostProcessResult, error) {
result, err := m.runPostProcessingCommon(PostProcessInput{Path: filePath}, metadata, false)
if err != nil {
return result, err
}
return &PostProcessResult{Success: result.Success, NewFilePath: result.NewFilePath}, nil
}
func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]any) (*PostProcessResult, error) {
return m.runPostProcessingCommon(input, metadata, true)
}
func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper { func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
+102
View File
@@ -10,6 +10,7 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -457,6 +458,58 @@ func TestShouldStopProviderFallback(t *testing.T) {
} }
} }
func TestMoveProviderToFrontPreservesExplicitSelection(t *testing.T) {
priority := []string{"qobuz-web", "amazon-web", "tidal-web"}
got := moveProviderToFront(priority, "AMAZON-WEB")
want := []string{"amazon-web", "qobuz-web", "tidal-web"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("moveProviderToFront() = %#v, want %#v", got, want)
}
if !reflect.DeepEqual(priority, []string{"qobuz-web", "amazon-web", "tidal-web"}) {
t.Fatalf("moveProviderToFront mutated input: %#v", priority)
}
}
func TestDiscardRejectedExtensionOutputStaysInsideRequestedDirectory(t *testing.T) {
outputDir := t.TempDir()
requestedPath := filepath.Join(outputDir, "Artist - Song.flac")
rejectedPath := filepath.Join(outputDir, "Artist - Song.m4a")
outsidePath := filepath.Join(t.TempDir(), "keep.flac")
if err := os.WriteFile(rejectedPath, []byte("wrong audio"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(outsidePath, []byte("keep"), 0o600); err != nil {
t.Fatal(err)
}
discardRejectedExtensionOutput(&ExtDownloadResult{FilePath: rejectedPath}, requestedPath)
if _, err := os.Stat(rejectedPath); !os.IsNotExist(err) {
t.Fatalf("rejected output was not removed: %v", err)
}
discardRejectedExtensionOutput(&ExtDownloadResult{FilePath: outsidePath}, requestedPath)
if _, err := os.Stat(outsidePath); err != nil {
t.Fatalf("output outside requested directory was removed: %v", err)
}
}
func TestDiscardRejectedExtensionOutputPreservesExistingLibraryHit(t *testing.T) {
outputDir := t.TempDir()
path := filepath.Join(outputDir, "existing.flac")
if err := os.WriteFile(path, []byte("existing audio"), 0o600); err != nil {
t.Fatal(err)
}
discardRejectedExtensionOutput(&ExtDownloadResult{
FilePath: path,
AlreadyExists: true,
}, filepath.Join(outputDir, "requested.flac"))
if _, err := os.Stat(path); err != nil {
t.Fatalf("existing library file was removed: %v", err)
}
}
func TestBuildExtensionFallbackStoppedResponsePrefersAvailabilityReason(t *testing.T) { func TestBuildExtensionFallbackStoppedResponsePrefersAvailabilityReason(t *testing.T) {
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{ resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
Reason: "direct SoundCloud track ID", Reason: "direct SoundCloud track ID",
@@ -671,6 +724,7 @@ func TestParseExtensionMetadataAndDownloadResults(t *testing.T) {
alreadyExists: true, alreadyExists: true,
bitDepth: 24, bitDepth: 24,
sampleRate: 96000, sampleRate: 96000,
durationMs: 181000,
title: "Song", title: "Song",
albumArtist: "Album Artist", albumArtist: "Album Artist",
lyricsLrc: "[00:00.00]Line", lyricsLrc: "[00:00.00]Line",
@@ -692,6 +746,7 @@ func TestParseExtensionMetadataAndDownloadResults(t *testing.T) {
!download.AlreadyExists || !download.AlreadyExists ||
download.BitDepth != 24 || download.BitDepth != 24 ||
download.SampleRate != 96000 || download.SampleRate != 96000 ||
download.DurationMS != 181000 ||
download.AlbumArtist != "Album Artist" || download.AlbumArtist != "Album Artist" ||
download.LyricsLRC != "[00:00.00]Line" || download.LyricsLRC != "[00:00.00]Line" ||
download.Decryption == nil || download.Decryption == nil ||
@@ -777,3 +832,50 @@ func TestParseExtensionAuxiliaryResults(t *testing.T) {
t.Fatalf("unexpected lyrics result: %+v", lyrics) t.Fatalf("unexpected lyrics result: %+v", lyrics)
} }
} }
func TestMatchesURLHostAnchored(t *testing.T) {
manifest := &ExtensionManifest{
URLHandler: &URLHandlerConfig{
Enabled: true,
Patterns: []string{"spotify.com", "deezer.page.link", "spotify:"},
},
}
for _, urlStr := range []string{
"https://open.spotify.com/track/abc",
"https://spotify.com/track/abc",
"HTTPS://OPEN.SPOTIFY.COM/track/ABC",
"https://deezer.page.link/xyz",
"spotify:track:abc123",
} {
if !manifest.MatchesURL(urlStr) {
t.Fatalf("expected match for %q", urlStr)
}
}
for _, urlStr := range []string{
// The old substring matching accepted all of these.
"https://evil.example/?next=https://spotify.com/track/abc",
"https://notspotify.com/track/abc",
"https://spotify.com.evil.example/track/abc",
"https://example.com/spotify.com",
"not a url at all",
} {
if manifest.MatchesURL(urlStr) {
t.Fatalf("expected no match for %q", urlStr)
}
}
withPath := &ExtensionManifest{
URLHandler: &URLHandlerConfig{
Enabled: true,
Patterns: []string{"youtube.com/watch"},
},
}
if !withPath.MatchesURL("https://www.youtube.com/watch?v=abc") {
t.Fatal("expected host+path prefix to match")
}
if withPath.MatchesURL("https://www.youtube.com/playlist?list=abc") {
t.Fatal("expected different path to not match")
}
}
+125 -10
View File
@@ -1,6 +1,8 @@
package gobackend package gobackend
import ( import (
"crypto/sha256"
"crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -34,10 +36,13 @@ type repoExtension struct {
Downloads int `json:"downloads"` Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"` MinAppVersion string `json:"min_app_version,omitempty"`
SHA256 string `json:"sha256,omitempty"`
ChecksumSHA256 string `json:"checksum_sha256,omitempty"`
DisplayNameAlt string `json:"displayName,omitempty"` DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"` DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"` IconURLAlt string `json:"iconUrl,omitempty"`
MinAppVersionAlt string `json:"minAppVersion,omitempty"` MinAppVersionAlt string `json:"minAppVersion,omitempty"`
ChecksumAlt string `json:"checksumSha256,omitempty"`
} }
func (e *repoExtension) getDisplayName() string { func (e *repoExtension) getDisplayName() string {
@@ -71,6 +76,14 @@ func (e *repoExtension) getMinAppVersion() string {
return e.MinAppVersionAlt return e.MinAppVersionAlt
} }
func (e *repoExtension) getRawSHA256() string {
return firstNonEmptyTrimmed(e.SHA256, e.ChecksumSHA256, e.ChecksumAlt)
}
func (e *repoExtension) getSHA256() string {
return normalizeSHA256(e.getRawSHA256())
}
type repoRegistry struct { type repoRegistry struct {
Version int `json:"version"` Version int `json:"version"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
@@ -90,6 +103,7 @@ type repoExtensionResponse struct {
Downloads int `json:"downloads"` Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"` MinAppVersion string `json:"min_app_version,omitempty"`
SHA256 string `json:"sha256,omitempty"`
IsInstalled bool `json:"is_installed"` IsInstalled bool `json:"is_installed"`
InstalledVersion string `json:"installed_version,omitempty"` InstalledVersion string `json:"installed_version,omitempty"`
HasUpdate bool `json:"has_update"` HasUpdate bool `json:"has_update"`
@@ -108,6 +122,7 @@ func (e *repoExtension) toResponse() repoExtensionResponse {
Downloads: e.Downloads, Downloads: e.Downloads,
UpdatedAt: e.UpdatedAt, UpdatedAt: e.UpdatedAt,
MinAppVersion: e.getMinAppVersion(), MinAppVersion: e.getMinAppVersion(),
SHA256: e.getSHA256(),
} }
if len(e.Tags) > 0 { if len(e.Tags) > 0 {
@@ -316,6 +331,22 @@ func parseRegistryBody(body []byte) (*repoRegistry, error) {
} }
return nil, fmt.Errorf("failed to parse registry: %w", err) return nil, fmt.Errorf("failed to parse registry: %w", err)
} }
validExtensions := make([]repoExtension, 0, len(registry.Extensions))
for index := range registry.Extensions {
ext := &registry.Extensions[index]
rawChecksum := ext.getRawSHA256()
if rawChecksum != "" && normalizeSHA256(rawChecksum) == "" {
LogWarn(
"ExtensionRepo",
"Skipping registry extension %q at index %d: invalid SHA-256 checksum",
ext.ID,
index,
)
continue
}
validExtensions = append(validExtensions, *ext)
}
registry.Extensions = validExtensions
return &registry, nil return &registry, nil
} }
@@ -398,22 +429,106 @@ func (s *extensionRepo) downloadExtension(extensionID string, destPath string) e
return fmt.Errorf("download returned HTTP %d", resp.StatusCode) return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
} }
out, err := os.Create(destPath) if err := writeVerifiedExtensionPackage(
if err != nil { resp.Body,
return fmt.Errorf("failed to create file: %w", err) destPath,
} ext.getRawSHA256(),
defer out.Close() ); err != nil {
return err
_, err = io.Copy(out, resp.Body)
if err != nil {
os.Remove(destPath)
return fmt.Errorf("failed to write file: %w", err)
} }
LogInfo("ExtensionRepo", "Downloaded %s to %s", ext.getDisplayName(), destPath) LogInfo("ExtensionRepo", "Downloaded %s to %s", ext.getDisplayName(), destPath)
return nil return nil
} }
const maxExtensionPackageBytes int64 = 64 * 1024 * 1024
func normalizeSHA256(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = strings.TrimPrefix(normalized, "sha256:")
if len(normalized) != sha256.Size*2 {
return ""
}
for _, char := range normalized {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
return ""
}
}
return normalized
}
func writeVerifiedExtensionPackage(
reader io.Reader,
destPath string,
expectedSHA256 string,
) error {
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return fmt.Errorf("failed to prepare extension download directory: %w", err)
}
tempFile, err := os.CreateTemp(
filepath.Dir(destPath),
"."+filepath.Base(destPath)+".download-*",
)
if err != nil {
return fmt.Errorf("failed to create extension download: %w", err)
}
tempPath := tempFile.Name()
committed := false
defer func() {
_ = tempFile.Close()
if !committed {
_ = os.Remove(tempPath)
}
}()
hasher := sha256.New()
limited := &io.LimitedReader{R: reader, N: maxExtensionPackageBytes + 1}
written, copyErr := io.Copy(io.MultiWriter(tempFile, hasher), limited)
if copyErr != nil {
return fmt.Errorf("failed to write extension package: %w", copyErr)
}
if written > maxExtensionPackageBytes {
return fmt.Errorf(
"extension package exceeds the %d MiB size limit",
maxExtensionPackageBytes/(1024*1024),
)
}
if err := tempFile.Sync(); err != nil {
return fmt.Errorf("failed to flush extension package: %w", err)
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("failed to close extension package: %w", err)
}
expected := normalizeSHA256(expectedSHA256)
if strings.TrimSpace(expectedSHA256) != "" && expected == "" {
return fmt.Errorf("registry contains an invalid extension SHA-256 checksum")
}
if expected != "" {
actual := fmt.Sprintf("%x", hasher.Sum(nil))
if subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) != 1 {
return fmt.Errorf(
"extension package integrity check failed: SHA-256 mismatch",
)
}
} else {
LogWarn(
"ExtensionRepo",
"Registry entry has no SHA-256 checksum; package integrity cannot be verified",
)
}
if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to replace cached extension package: %w", err)
}
if err := os.Rename(tempPath, destPath); err != nil {
return fmt.Errorf("failed to publish extension package: %w", err)
}
committed = true
return nil
}
func resolveRegistryURL(input string) (string, error) { func resolveRegistryURL(input string) (string, error) {
input = strings.TrimSpace(input) input = strings.TrimSpace(input)
if input == "" { if input == "" {
+23 -9
View File
@@ -129,6 +129,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
var onProgress goja.Callable var onProgress goja.Callable
var headers map[string]string var headers map[string]string
var chunkedDownload bool var chunkedDownload bool
var resumeDownload bool
trackItemBytes := true trackItemBytes := true
var chunkSize int64 var chunkSize int64
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
@@ -170,6 +171,11 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
} }
} }
if resume, ok := opts["resume"]; ok {
if v, ok := resume.(bool); ok {
resumeDownload = v
}
}
} }
} }
@@ -286,6 +292,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
var written int64 var written int64
var lastProgressNotify int64
buf := make([]byte, 32*1024) buf := make([]byte, 32*1024)
// copyBody streams resp into progressWriter. fatal is a terminal JS error // copyBody streams resp into progressWriter. fatal is a terminal JS error
@@ -315,7 +322,11 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
return r.jsError("short write"), nil return r.jsError("short write"), nil
} }
if onProgress != nil && contentLength > 0 { // Throttle the JS callback like the native progress writer:
// per-read invocation is interpreter work inside the copy loop.
if onProgress != nil && contentLength > 0 &&
(written-lastProgressNotify >= progressUpdateThreshold || written >= contentLength) {
lastProgressNotify = written
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength)) _, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
} }
} }
@@ -328,14 +339,14 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
} }
// Mid-body network failures resume from the current offset instead of // Mid-body resume is opt-in because switching networks can route a stable
// failing the whole download. Only attempted when the server gave a // URL to a different CDN object even when its validator is unchanged. The
// validator (If-Range guards against splicing two versions of the file) // safe default is to fail and delete the staged partial file. Extensions
// and the caller did not set its own Range. A server that ignores Range // that know their origin supports byte-identical Range resumes can request
// answers 200 and the download restarts from zero — same as today. // it explicitly with { resume: true }.
validator := resumeValidator(resp.Header) validator := resumeValidator(resp.Header)
_, callerSetRange := headers["Range"] _, callerSetRange := headers["Range"]
canResume := validator != "" && !callerSetRange canResume := resumeDownload && validator != "" && !callerSetRange
const maxResumes = 3 const maxResumes = 3
resumes := 0 resumes := 0
@@ -401,7 +412,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
} }
validator = resumeValidator(resp.Header) validator = resumeValidator(resp.Header)
canResume = validator != "" canResume = resumeDownload && validator != ""
default: default:
code := resp.StatusCode code := resp.StatusCode
resp.Body.Close() resp.Body.Close()
@@ -529,6 +540,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
} }
var totalWritten int64 var totalWritten int64
var lastProgressNotify int64
buf := make([]byte, 32*1024) buf := make([]byte, 32*1024)
maxRetries := 3 maxRetries := 3
@@ -610,7 +622,9 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
return r.jsError("short write") return r.jsError("short write")
} }
if onProgress != nil && totalSize > 0 { if onProgress != nil && totalSize > 0 &&
(totalWritten-lastProgressNotify >= progressUpdateThreshold || totalWritten >= totalSize) {
lastProgressNotify = totalWritten
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize)) _, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize))
} }
} }
@@ -267,7 +267,42 @@ func TestFileDownloadFailureLeavesNoFinalFile(t *testing.T) {
} }
} }
func TestFileDownloadResumesAfterMidBodyCut(t *testing.T) { func TestFileDownloadDoesNotResumeMidBodyCutByDefault(t *testing.T) {
const full = "hello-world!"
var attempts int
runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
attempts++
h := make(http.Header)
h.Set("ETag", `"v1"`)
return &http.Response{
StatusCode: 200,
Header: h,
Body: io.NopCloser(&failingBodyReader{data: []byte(full[:6])}),
ContentLength: int64(len(full)),
Request: req,
}, nil
})
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
runtime.vm.ToValue("out/track.flac"),
}}).Export().(map[string]any)
if result["success"] != false {
t.Fatalf("expected failed download, got %#v", result)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want no automatic resume", attempts)
}
finalPath := filepath.Join(runtime.dataDir, "out", "track.flac")
if _, err := os.Stat(finalPath); !os.IsNotExist(err) {
t.Fatalf("partial download visible at final path: %v", err)
}
if _, err := os.Stat(stagedDownloadPath(finalPath)); !os.IsNotExist(err) {
t.Fatalf("staged file left behind: %v", err)
}
}
func TestFileDownloadResumesAfterMidBodyCutWhenEnabled(t *testing.T) {
const full = "hello-world!" const full = "hello-world!"
var attempts int var attempts int
var resumeRange, resumeIfRange string var resumeRange, resumeIfRange string
@@ -300,6 +335,7 @@ func TestFileDownloadResumesAfterMidBodyCut(t *testing.T) {
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{ result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
runtime.vm.ToValue("https://cdn.example.com/track.flac"), runtime.vm.ToValue("https://cdn.example.com/track.flac"),
runtime.vm.ToValue("out/track.flac"), runtime.vm.ToValue("out/track.flac"),
runtime.vm.ToValue(map[string]any{"resume": true}),
}}).Export().(map[string]any) }}).Export().(map[string]any)
if result["success"] != true { if result["success"] != true {
t.Fatalf("download result = %#v", result) t.Fatalf("download result = %#v", result)
@@ -342,6 +378,7 @@ func TestFileDownloadResumeRestartsWhenRangeIgnored(t *testing.T) {
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{ result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
runtime.vm.ToValue("https://cdn.example.com/track.flac"), runtime.vm.ToValue("https://cdn.example.com/track.flac"),
runtime.vm.ToValue("out/track.flac"), runtime.vm.ToValue("out/track.flac"),
runtime.vm.ToValue(map[string]any{"resume": true}),
}}).Export().(map[string]any) }}).Export().(map[string]any)
if result["success"] != true { if result["success"] != true {
t.Fatalf("download result = %#v", result) t.Fatalf("download result = %#v", result)
+639 -99
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -24,11 +25,94 @@ import (
const signedSessionRefreshSkew = time.Hour const signedSessionRefreshSkew = time.Hour
const (
signedSessionExchangeMaxAttempts = 3
signedSessionMaxRetryAfter = 5 * time.Minute
signedSessionMaxSessionRetries = 1
signedSessionMaxProviderRetries = 2
signedSessionProviderRetryDelay = time.Second
)
var ( var (
pendingSignedSessionGrants = make(map[string]string) pendingSignedSessionGrants = make(map[string]string)
pendingSignedSessionGrantsMu sync.Mutex pendingSignedSessionGrantsMu sync.Mutex
signedSessionCoordinators sync.Map
signedSessionRetryWait = time.Sleep
signedSessionProviderWait = sleepRetry
signedSessionRequestNow = time.Now
) )
// signedSessionCoordinator serializes authentication state for every runtime
// that shares one persisted signed-session file. Parallel downloads use
// isolated extension runtimes, so a runtime-local mutex cannot prevent two
// bootstraps or an old 401 response from overwriting a newly exchanged session.
type signedSessionCoordinator struct {
mu sync.Mutex
authURL string
callbackURL string
challengeCreatedAt time.Time
pendingExtensionIDs map[string]struct{}
completedGrantHash string
blockedGeneration string
}
func (r *extensionRuntime) signedSessionCoordinator(config SignedSessionConfig) (*signedSessionCoordinator, error) {
path, err := r.signedSessionFilePath(config)
if err != nil {
return nil, err
}
value, _ := signedSessionCoordinators.LoadOrStore(path, &signedSessionCoordinator{})
return value.(*signedSessionCoordinator), nil
}
func (c *signedSessionCoordinator) clearChallenge() {
for extensionID := range c.pendingExtensionIDs {
ClearPendingAuthRequest(extensionID)
}
c.authURL = ""
c.callbackURL = ""
c.challengeCreatedAt = time.Time{}
c.pendingExtensionIDs = nil
}
func (c *signedSessionCoordinator) rememberChallenge(extensionID, authURL, callbackURL string) {
if c.pendingExtensionIDs == nil {
c.pendingExtensionIDs = make(map[string]struct{})
}
c.authURL = authURL
c.callbackURL = callbackURL
c.challengeCreatedAt = time.Now()
c.pendingExtensionIDs[extensionID] = struct{}{}
}
func (c *signedSessionCoordinator) activeChallenge() bool {
return strings.TrimSpace(c.authURL) != "" &&
!c.challengeCreatedAt.IsZero() &&
time.Since(c.challengeCreatedAt) < pendingAuthRequestTTL
}
func signedSessionGeneration(record *signedSessionRecord) string {
if record == nil || record.SessionID == "" || record.SessionSecret == "" {
return ""
}
sum := sha256.Sum256([]byte(record.SessionID + "\n" + record.SessionSecret))
return hex.EncodeToString(sum[:])
}
func (c *signedSessionCoordinator) blockGeneration(record *signedSessionRecord) {
c.blockedGeneration = signedSessionGeneration(record)
}
func (c *signedSessionCoordinator) generationIsBlocked(record *signedSessionRecord) bool {
generation := signedSessionGeneration(record)
return generation != "" && generation == c.blockedGeneration
}
func (c *signedSessionCoordinator) clearBlockedGeneration() {
c.blockedGeneration = ""
}
type signedSessionRecord struct { type signedSessionRecord struct {
InstallID string `json:"install_id"` InstallID string `json:"install_id"`
SessionID string `json:"session_id,omitempty"` SessionID string `json:"session_id,omitempty"`
@@ -49,6 +133,20 @@ type signedSessionExchangeResponse struct {
AuthURL string `json:"auth_url,omitempty"` AuthURL string `json:"auth_url,omitempty"`
} }
// signedSessionErrorContract is the gateway-owned error envelope. Decisions
// that mutate authentication state must use these fields together with the
// HTTP status; a provider response must never be able to masquerade as a
// gateway session failure merely by returning 401 or 403 upstream.
type signedSessionErrorContract struct {
Error string `json:"error,omitempty"`
Code string `json:"code,omitempty"`
Origin string `json:"origin,omitempty"`
Action string `json:"action,omitempty"`
Retryable bool `json:"retryable,omitempty"`
RetryMode string `json:"retry_mode,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
}
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig { func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
if config == nil { if config == nil {
return SignedSessionConfig{} return SignedSessionConfig{}
@@ -94,9 +192,6 @@ func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (st
baseDir = r.dataDir baseDir = r.dataDir
} }
dir := filepath.Join(baseDir, "signed_sessions") dir := filepath.Join(baseDir, "signed_sessions")
if err := os.MkdirAll(dir, 0700); err != nil {
return "", err
}
scope := strings.Join([]string{ scope := strings.Join([]string{
namespace, namespace,
strings.TrimSpace(strings.ToLower(config.BaseURL)), strings.TrimSpace(strings.ToLower(config.BaseURL)),
@@ -127,32 +222,41 @@ func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signe
if data, err := os.ReadFile(path); err == nil { if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, record) _ = json.Unmarshal(data, record)
} }
changed := false
if strings.TrimSpace(record.InstallID) == "" { if strings.TrimSpace(record.InstallID) == "" {
record.InstallID = randomHex(16) record.InstallID = randomHex(16)
changed = true
} }
normalizeSignedSessionRecordScope(config, record) if normalizeSignedSessionRecordScope(config, record) {
if err := r.saveSignedSession(config, record); err != nil { changed = true
return nil, err }
// Only rewrite the file when the record actually changed; loads happen on
// every signed request and preflight.
if changed {
if err := r.saveSignedSession(config, record); err != nil {
return nil, err
}
} }
return record, nil return record, nil
} }
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) { // normalizeSignedSessionRecordScope stamps the config scope onto the record,
// resetting the session when the scope changed. Returns whether the record
// was modified.
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) bool {
namespace := sanitizeSignedSessionNamespace(config.Namespace) namespace := sanitizeSignedSessionNamespace(config.Namespace)
baseURL := strings.TrimSpace(config.BaseURL) baseURL := strings.TrimSpace(config.BaseURL)
appVersion := strings.TrimSpace(config.AppVersion) appVersion := strings.TrimSpace(config.AppVersion)
platform := strings.TrimSpace(config.Platform) platform := strings.TrimSpace(config.Platform)
if record.Namespace == "" && record.BaseURL == "" && record.AppVersion == "" && record.Platform == "" { if record.Namespace == namespace &&
record.Namespace = namespace record.BaseURL == baseURL &&
record.BaseURL = baseURL record.AppVersion == appVersion &&
record.AppVersion = appVersion record.Platform == platform {
record.Platform = platform return false
return
} }
if record.Namespace != namespace || blankScope := record.Namespace == "" && record.BaseURL == "" &&
record.BaseURL != baseURL || record.AppVersion == "" && record.Platform == ""
record.AppVersion != appVersion || if !blankScope {
record.Platform != platform {
record.SessionID = "" record.SessionID = ""
record.SessionSecret = "" record.SessionSecret = ""
record.ExpiresAt = "" record.ExpiresAt = ""
@@ -161,6 +265,7 @@ func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signe
record.BaseURL = baseURL record.BaseURL = baseURL
record.AppVersion = appVersion record.AppVersion = appVersion
record.Platform = platform record.Platform = platform
return true
} }
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error { func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
@@ -168,6 +273,9 @@ func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record
if err != nil { if err != nil {
return err return err
} }
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
data, err := json.MarshalIndent(record, "", " ") data, err := json.MarshalIndent(record, "", " ")
if err != nil { if err != nil {
return err return err
@@ -211,11 +319,78 @@ func signedSessionRecordIsUsable(record *signedSessionRecord) bool {
return true return true
} }
func sameSignedSession(a, b *signedSessionRecord) bool {
return a != nil && b != nil &&
a.SessionID != "" &&
a.SessionID == b.SessionID &&
a.SessionSecret == b.SessionSecret
}
func parseSignedSessionErrorContract(body []byte) (signedSessionErrorContract, bool) {
var contract signedSessionErrorContract
if len(body) == 0 || json.Unmarshal(body, &contract) != nil {
return signedSessionErrorContract{}, false
}
contract.Error = strings.TrimSpace(contract.Error)
contract.Code = strings.ToUpper(strings.TrimSpace(contract.Code))
contract.Origin = strings.ToLower(strings.TrimSpace(contract.Origin))
contract.Action = strings.ToLower(strings.TrimSpace(contract.Action))
contract.RetryMode = strings.ToLower(strings.TrimSpace(contract.RetryMode))
if contract.RetryAfterSeconds < 0 {
contract.RetryAfterSeconds = 0
}
return contract, contract.Code != "" || contract.Origin != "" || contract.Action != ""
}
func signedSessionGatewayAction(statusCode int, contract signedSessionErrorContract) string {
if contract.Origin != "gateway" {
return ""
}
switch {
case statusCode == http.StatusUnauthorized &&
contract.Code == "SESSION_INVALID" &&
contract.Action == "bootstrap_session":
return "bootstrap_session"
case statusCode == http.StatusPreconditionRequired &&
contract.Code == "VERIFY_REQUIRED" &&
contract.Action == "verify":
return "verify"
default:
return ""
}
}
func signedSessionSameOperationRetry(statusCode int, contract signedSessionErrorContract) bool {
return statusCode == http.StatusServiceUnavailable &&
contract.Origin == "provider" &&
contract.Code == "PROVIDER_UNAVAILABLE" &&
contract.Retryable &&
contract.RetryMode == "same_operation"
}
func signedSessionRequestAuthInvalid(statusCode int, contract signedSessionErrorContract) bool {
return statusCode == http.StatusForbidden &&
contract.Origin == "gateway" &&
contract.Code == "REQUEST_AUTH_INVALID" &&
contract.Action == ""
}
func signedSessionProviderRetryDuration(resp *http.Response, contract signedSessionErrorContract) time.Duration {
if retryAfter := getRetryAfterDuration(resp); retryAfter > 0 {
return retryAfter
}
if contract.RetryAfterSeconds > 0 {
maxSeconds := int(maxRetryAfterDelay / time.Second)
return time.Duration(min(contract.RetryAfterSeconds, maxSeconds)) * time.Second
}
return signedSessionProviderRetryDelay
}
// preflightSignedSession prepares a signed session before download metadata // preflightSignedSession prepares a signed session before download metadata
// enrichment starts. A fresh pending challenge is reused, while bootstrap // enrichment starts. A fresh pending challenge is reused, while bootstrap
// responses that can issue a session silently are accepted without prompting // responses that can issue a session silently are accepted without prompting
// the user. Bootstrap failures remain non-fatal to the caller so the normal // the user. Bootstrap failures are returned so callers do not continue into
// provider path can still surface its more specific error. // the provider and accidentally issue the same failing bootstrap repeatedly.
func (r *extensionRuntime) preflightSignedSession() (bool, error) { func (r *extensionRuntime) preflightSignedSession() (bool, error) {
if r == nil || r.manifest == nil || r.manifest.SignedSession == nil { if r == nil || r.manifest == nil || r.manifest.SignedSession == nil {
return false, nil return false, nil
@@ -225,24 +400,25 @@ func (r *extensionRuntime) preflightSignedSession() (bool, error) {
if config.Namespace == "" || config.BaseURL == "" { if config.Namespace == "" || config.BaseURL == "" {
return false, fmt.Errorf("signedSession is not configured") return false, fmt.Errorf("signedSession is not configured")
} }
coordinator, err := r.signedSessionCoordinator(config)
if err != nil {
return false, err
}
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
record, err := r.loadSignedSession(config) record, err := r.loadSignedSession(config)
if err != nil { if err != nil {
return false, err return false, err
} }
if signedSessionRecordIsUsable(record) { if signedSessionRecordIsUsable(record) &&
!coordinator.generationIsBlocked(record) {
return false, nil return false, nil
} }
if pending := GetPendingAuthRequest(r.extensionID); pending != nil { if authURL, err := r.startSignedSessionVerificationLocked(config, coordinator, "download-preflight"); err != nil {
if time.Since(pending.CreatedAt) < pendingAuthRequestTTL && return false, err
strings.TrimSpace(pending.AuthURL) != "" { } else if authURL != "" {
return true, nil
}
ClearPendingAuthRequest(r.extensionID)
}
if authURL := r.startSignedSessionVerification(config, "download-preflight"); authURL != "" {
return true, nil return true, nil
} }
@@ -264,23 +440,37 @@ func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Valu
if config.Namespace == "" || config.BaseURL == "" { if config.Namespace == "" || config.BaseURL == "" {
return r.vm.ToValue(map[string]any{"authenticated": false, "error": "signedSession is not configured"}) return r.vm.ToValue(map[string]any{"authenticated": false, "error": "signedSession is not configured"})
} }
coordinator, err := r.signedSessionCoordinator(config)
if err != nil {
return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()})
}
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
record, err := r.loadSignedSession(config) record, err := r.loadSignedSession(config)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()}) return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()})
} }
authenticated := signedSessionRecordIsUsable(record) blocked := coordinator.generationIsBlocked(record)
authenticated := signedSessionRecordIsUsable(record) && !blocked
return r.vm.ToValue(map[string]any{ return r.vm.ToValue(map[string]any{
"authenticated": authenticated, "authenticated": authenticated,
"expires_at": record.ExpiresAt, "verification_required": blocked,
"install_id": record.InstallID, "expires_at": record.ExpiresAt,
"session_id": record.SessionID, "install_id": record.InstallID,
"app_version": config.AppVersion, "session_id": record.SessionID,
"platform": config.Platform, "app_version": config.AppVersion,
"platform": config.Platform,
}) })
} }
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession) config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
coordinator, err := r.signedSessionCoordinator(config)
if err != nil {
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
}
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
record, err := r.loadSignedSession(config) record, err := r.loadSignedSession(config)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()}) return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
@@ -291,6 +481,8 @@ func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value
if err := r.saveSignedSession(config, record); err != nil { if err := r.saveSignedSession(config, record); err != nil {
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()}) return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
} }
coordinator.clearBlockedGeneration()
coordinator.clearChallenge()
ClearPendingAuthRequest(r.extensionID) ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]any{"success": true}) return r.vm.ToValue(map[string]any{"success": true})
} }
@@ -300,10 +492,12 @@ func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) go
if len(call.Arguments) > 0 { if len(call.Arguments) > 0 {
grant = strings.TrimSpace(call.Arguments[0].String()) grant = strings.TrimSpace(call.Arguments[0].String())
} }
if grant != "" {
setPendingSignedSessionGrant(r.extensionID, grant)
}
if grant == "" { if grant == "" {
pendingSignedSessionGrantsMu.Lock() pendingSignedSessionGrantsMu.Lock()
grant = pendingSignedSessionGrants[r.extensionID] grant = pendingSignedSessionGrants[r.extensionID]
delete(pendingSignedSessionGrants, r.extensionID)
pendingSignedSessionGrantsMu.Unlock() pendingSignedSessionGrantsMu.Unlock()
} }
if grant == "" { if grant == "" {
@@ -312,16 +506,44 @@ func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) go
if err := r.exchangeSignedSessionGrant(grant); err != nil { if err := r.exchangeSignedSessionGrant(grant); err != nil {
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()}) return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
} }
pendingSignedSessionGrantsMu.Lock()
delete(pendingSignedSessionGrants, r.extensionID)
pendingSignedSessionGrantsMu.Unlock()
ClearPendingAuthRequest(r.extensionID) ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]any{"success": true}) return r.vm.ToValue(map[string]any{"success": true})
} }
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error { func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession) config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
coordinator, err := r.signedSessionCoordinator(config)
if err != nil {
return err
}
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
return r.exchangeSignedSessionGrantLocked(config, coordinator, grant)
}
func (r *extensionRuntime) exchangeSignedSessionGrantLocked(
config SignedSessionConfig,
coordinator *signedSessionCoordinator,
grant string,
) error {
record, err := r.loadSignedSession(config) record, err := r.loadSignedSession(config)
if err != nil { if err != nil {
return err return err
} }
grantHashBytes := sha256.Sum256([]byte(grant))
grantHash := hex.EncodeToString(grantHashBytes[:])
// A duplicated callback may arrive after another runtime already exchanged
// the same one-time grant. Treat that exact shared result as completed
// instead of consuming the grant again.
if coordinator.completedGrantHash == grantHash &&
signedSessionRecordIsUsable(record) {
coordinator.clearBlockedGeneration()
coordinator.clearChallenge()
return nil
}
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange) endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
if err != nil { if err != nil {
return err return err
@@ -333,24 +555,51 @@ func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
"platform": config.Platform, "platform": config.Platform,
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) var respBody []byte
if err != nil { for attempt := 1; attempt <= signedSessionExchangeMaxAttempts; attempt++ {
return err req, requestErr := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
} if requestErr != nil {
req.Header.Set("Content-Type", "application/json") return requestErr
req.Header.Set("Accept", "application/json") }
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion) req.Header.Set("Content-Type", "application/json")
resp, err := r.httpClient.Do(req) req.Header.Set("Accept", "application/json")
if err != nil { req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
return err resp, requestErr := r.httpClient.Do(req)
} if requestErr != nil {
defer resp.Body.Close() return requestErr
respBody, err := readExtensionHTTPResponseBody(resp) }
if err != nil { respBody, requestErr = readExtensionHTTPResponseBody(resp)
return err resp.Body.Close()
} if requestErr != nil {
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return requestErr
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode) }
if resp.StatusCode == http.StatusTooManyRequests && attempt < signedSessionExchangeMaxAttempts {
retryAfter := time.Duration(signedSessionRetryAfterSeconds(resp)) * time.Second
if retryAfter <= 0 {
retryAfter = time.Second
}
if retryAfter > signedSessionMaxRetryAfter {
retryAfter = signedSessionMaxRetryAfter
}
LogWarn(
"SignedSession",
"Grant exchange rate limited for extension %s; retrying in %s (attempt %d/%d)",
r.extensionID,
retryAfter,
attempt+1,
signedSessionExchangeMaxAttempts,
)
signedSessionRetryWait(retryAfter)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
message := fmt.Sprintf("session exchange failed: HTTP %d", resp.StatusCode)
if retryAfter := signedSessionRetryAfterSeconds(resp); retryAfter > 0 {
message += fmt.Sprintf("; retry-after seconds: %d", retryAfter)
}
return errors.New(message)
}
break
} }
var exchanged signedSessionExchangeResponse var exchanged signedSessionExchangeResponse
if err := json.Unmarshal(respBody, &exchanged); err != nil { if err := json.Unmarshal(respBody, &exchanged); err != nil {
@@ -362,7 +611,13 @@ func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
record.SessionID = exchanged.SessionID record.SessionID = exchanged.SessionID
record.SessionSecret = exchanged.SessionSecret record.SessionSecret = exchanged.SessionSecret
record.ExpiresAt = exchanged.ExpiresAt record.ExpiresAt = exchanged.ExpiresAt
return r.saveSignedSession(config, record) if err := r.saveSignedSession(config, record); err != nil {
return err
}
coordinator.completedGrantHash = grantHash
coordinator.clearBlockedGeneration()
coordinator.clearChallenge()
return nil
} }
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
@@ -392,36 +647,226 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
} }
extraHeaders := parseGojaHeaders(call.Argument(3).Export()) extraHeaders := parseGojaHeaders(call.Argument(3).Export())
coordinator, err := r.signedSessionCoordinator(config)
if err != nil {
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
}
coordinator.mu.Lock()
record, err := r.ensureSignedSession(config) record, err := r.ensureSignedSession(config)
if err != nil { if err != nil {
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" { authURL, verificationErr := r.startSignedSessionVerificationLocked(config, coordinator, "signed-fetch")
coordinator.mu.Unlock()
if authURL != "" {
return r.signedSessionVerificationRequiredValue(authURL) return r.signedSessionVerificationRequiredValue(authURL)
} else if verificationErr != nil {
return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()})
} }
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()}) return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
} }
if coordinator.generationIsBlocked(record) {
authURL, verificationErr := r.startSignedSessionVerificationLocked(
config,
coordinator,
"signed-fetch-blocked-generation",
)
if authURL != "" {
coordinator.mu.Unlock()
return r.signedSessionVerificationRequiredValue(authURL)
}
if verificationErr != nil {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()})
}
record, err = r.loadSignedSession(config)
if err != nil {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
}
if !signedSessionRecordIsUsable(record) || coordinator.generationIsBlocked(record) {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{
"ok": false,
"error": "verification_required: signed-session generation is blocked",
})
}
}
coordinator.mu.Unlock()
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders) // A request that loses a race with a successful grant exchange may return a
if err != nil { // canonical SESSION_INVALID response for the old secret. Reload and retry
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()}) // with the newer shared session; never let that stale response erase its
} // replacement. Provider retries have an independent, bounded budget.
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired { sessionRetries := 0
record.SessionID = "" providerRetries := 0
record.SessionSecret = "" requestAuthRetryUsed := false
record.ExpiresAt = "" for {
_ = r.saveSignedSession(config, record) resp, respBody, respHeaders, requestErr := r.doSignedSessionRequest(
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" { config,
return r.signedSessionVerificationRequiredValue(authURL) record,
method,
requestPath,
body,
extraHeaders,
)
if requestErr != nil {
return r.vm.ToValue(map[string]any{"ok": false, "error": requestErr.Error()})
} }
contract, _ := parseSignedSessionErrorContract(respBody)
if signedSessionSameOperationRetry(resp.StatusCode, contract) {
if providerRetries >= signedSessionMaxProviderRetries {
return r.signedSessionResponseValue(resp, respBody, respHeaders)
}
providerRetries++
delay := signedSessionProviderRetryDuration(resp, contract)
LogWarn(
"SignedSession",
"Provider temporarily unavailable for extension %s; retrying in %s (attempt %d/%d)",
r.extensionID,
delay,
providerRetries+1,
signedSessionMaxProviderRetries+1,
)
if waitErr := signedSessionProviderWait(resp.Request.Context(), delay); waitErr != nil {
return r.vm.ToValue(map[string]any{"ok": false, "error": waitErr.Error()})
}
continue
}
if signedSessionRequestAuthInvalid(resp.StatusCode, contract) {
coordinator.mu.Lock()
latest, loadErr := r.loadSignedSession(config)
if loadErr != nil {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{"ok": false, "error": loadErr.Error()})
}
if !requestAuthRetryUsed &&
signedSessionRecordIsUsable(latest) &&
!sameSignedSession(latest, record) {
requestAuthRetryUsed = true
record = latest
coordinator.mu.Unlock()
LogDebug(
"SignedSession",
"Retrying stale REQUEST_AUTH_INVALID for extension %s with the current session generation",
r.extensionID,
)
continue
}
coordinator.mu.Unlock()
LogWarn(
"SignedSession",
"REQUEST_AUTH_INVALID for extension %s on the current session generation; preserving session state",
r.extensionID,
)
return r.signedSessionResponseValue(resp, respBody, respHeaders)
}
gatewayAction := signedSessionGatewayAction(resp.StatusCode, contract)
if gatewayAction == "" {
return r.signedSessionResponseValue(resp, respBody, respHeaders)
}
coordinator.mu.Lock()
latest, loadErr := r.loadSignedSession(config)
if loadErr != nil {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{"ok": false, "error": loadErr.Error()})
}
if signedSessionRecordIsUsable(latest) &&
!sameSignedSession(latest, record) {
if sessionRetries >= signedSessionMaxSessionRetries {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{"ok": false, "error": "signed-session retry limit reached"})
}
sessionRetries++
record = latest
coordinator.mu.Unlock()
LogDebug(
"SignedSession",
"Discarding stale %s response for extension %s and retrying with the exchanged session",
contract.Code,
r.extensionID,
)
continue
}
// VERIFY_REQUIRED is an explicit challenge request, not a revocation.
// Only SESSION_INVALID is allowed to clear the current gateway session.
if gatewayAction == "bootstrap_session" && sameSignedSession(latest, record) {
coordinator.clearBlockedGeneration()
latest.SessionID = ""
latest.SessionSecret = ""
latest.ExpiresAt = ""
if saveErr := r.saveSignedSession(config, latest); saveErr != nil {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{"ok": false, "error": saveErr.Error()})
}
} else if gatewayAction == "verify" && sameSignedSession(latest, record) {
// Stop subsequent requests from repeatedly hitting the gateway with
// a generation that is known to require human verification.
coordinator.blockGeneration(record)
}
authURL, verificationErr := r.startSignedSessionVerificationLocked(
config,
coordinator,
"signed-fetch-"+gatewayAction,
)
if authURL != "" {
coordinator.mu.Unlock()
return r.signedSessionVerificationRequiredValue(authURL)
} else if verificationErr != nil {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()})
}
// Bootstrap may silently issue a replacement session instead of a
// challenge. Retry the original operation with that generation.
bootstrapped, loadErr := r.loadSignedSession(config)
if loadErr != nil {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{"ok": false, "error": loadErr.Error()})
}
if signedSessionRecordIsUsable(bootstrapped) &&
!sameSignedSession(bootstrapped, record) &&
sessionRetries < signedSessionMaxSessionRetries {
sessionRetries++
record = bootstrapped
coordinator.mu.Unlock()
continue
}
coordinator.mu.Unlock()
return r.signedSessionResponseValue(resp, respBody, respHeaders)
} }
return r.vm.ToValue(map[string]any{ }
func (r *extensionRuntime) signedSessionResponseValue(
resp *http.Response,
respBody []byte,
respHeaders map[string]any,
) goja.Value {
contract, hasContract := parseSignedSessionErrorContract(respBody)
retryAfterSeconds := signedSessionRetryAfterSeconds(resp)
if retryAfterSeconds <= 0 && contract.RetryAfterSeconds > 0 {
retryAfterSeconds = contract.RetryAfterSeconds
}
result := map[string]any{
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(), "url": resp.Request.URL.String(),
"body": string(respBody), "body": string(respBody),
"headers": respHeaders, "headers": respHeaders,
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp), "retryAfterSeconds": retryAfterSeconds,
}) }
if hasContract {
result["error"] = contract.Error
result["code"] = contract.Code
result["origin"] = contract.Origin
result["action"] = contract.Action
result["retryable"] = contract.Retryable
result["retryMode"] = contract.RetryMode
}
return r.vm.ToValue(result)
} }
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value { func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
@@ -490,45 +935,138 @@ func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, reco
return nil return nil
} }
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, _ string) string { func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) (string, error) {
coordinator, err := r.signedSessionCoordinator(config)
if err != nil {
return "", err
}
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
return r.startSignedSessionVerificationLocked(config, coordinator, reason)
}
func (r *extensionRuntime) startSignedSessionVerificationLocked(
config SignedSessionConfig,
coordinator *signedSessionCoordinator,
reason string,
) (string, error) {
if coordinator.activeChallenge() {
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: coordinator.authURL,
CallbackURL: coordinator.callbackURL,
CreatedAt: coordinator.challengeCreatedAt,
}
pendingAuthRequestsMu.Unlock()
coordinator.pendingExtensionIDs[r.extensionID] = struct{}{}
return coordinator.authURL, nil
}
if coordinator.authURL != "" {
coordinator.clearChallenge()
}
if pending := GetPendingAuthRequest(r.extensionID); pending != nil {
if time.Since(pending.CreatedAt) < pendingAuthRequestTTL &&
strings.TrimSpace(pending.AuthURL) != "" {
coordinator.rememberChallenge(
r.extensionID,
pending.AuthURL,
pending.CallbackURL,
)
return pending.AuthURL, nil
}
ClearPendingAuthRequest(r.extensionID)
}
record, err := r.loadSignedSession(config) record, err := r.loadSignedSession(config)
if err != nil { if err != nil {
return "" return "", fmt.Errorf("load signed-session bootstrap state: %w", err)
} }
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap) bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
if err != nil { if err != nil {
return "" return "", fmt.Errorf("build signed-session bootstrap URL: %w", err)
}
parsed, err := url.Parse(bootstrapURL)
if err != nil {
return "", fmt.Errorf("parse signed-session bootstrap URL: %w", err)
} }
parsed, _ := url.Parse(bootstrapURL)
query := parsed.Query() query := parsed.Query()
query.Set("app_version", config.AppVersion) query.Set("app_version", config.AppVersion)
query.Set("install_id", record.InstallID) query.Set("install_id", record.InstallID)
parsed.RawQuery = query.Encode() parsed.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil) if r.httpClient == nil {
if err != nil { return "", fmt.Errorf("signed-session bootstrap HTTP client is unavailable")
return "" }
var resp *http.Response
for attempt := 0; attempt < 2; attempt++ {
req, requestErr := http.NewRequest(http.MethodGet, parsed.String(), nil)
if requestErr != nil {
return "", fmt.Errorf("build signed-session bootstrap request: %w", requestErr)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
resp, err = r.httpClient.Do(req)
if err == nil {
break
}
if resp != nil && resp.Body != nil {
resp.Body.Close()
resp = nil
}
if attempt == 0 {
// Android can retain pooled connections across a Wi-Fi/cellular
// transition. Rebuild the GET once after dropping those sockets.
r.httpClient.CloseIdleConnections()
}
} }
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return "" var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Err != nil {
err = urlErr.Err
}
bootstrapErr := fmt.Errorf(
"signed-session bootstrap network request to %s failed: %v",
parsed.Host,
err,
)
LogWarn("SignedSession", "Bootstrap failed for extension %s (%s): %v", r.extensionID, reason, bootstrapErr)
return "", bootstrapErr
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes)) if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { // Drain a bounded error response so the transport can reuse the
return "" // connection without exposing response bodies that may contain secrets.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
message := fmt.Sprintf("signed-session bootstrap returned HTTP %d", resp.StatusCode)
if resp.StatusCode >= 500 {
message = fmt.Sprintf("signed-session bootstrap network request returned HTTP %d", resp.StatusCode)
}
if retryAfter := signedSessionRetryAfterSeconds(resp); retryAfter > 0 {
message += fmt.Sprintf("; retry-after seconds: %d", retryAfter)
}
bootstrapErr := errors.New(message)
LogWarn("SignedSession", "Bootstrap failed for extension %s (%s): %v", r.extensionID, reason, bootstrapErr)
return "", bootstrapErr
}
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return "", fmt.Errorf("read signed-session bootstrap response: %w", err)
} }
var boot signedSessionExchangeResponse var boot signedSessionExchangeResponse
if err := json.Unmarshal(body, &boot); err != nil { if err := json.Unmarshal(body, &boot); err != nil {
return "" return "", fmt.Errorf("decode signed-session bootstrap response: %w", err)
} }
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" { if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
record.SessionID = boot.SessionID record.SessionID = boot.SessionID
record.SessionSecret = boot.SessionSecret record.SessionSecret = boot.SessionSecret
record.ExpiresAt = boot.ExpiresAt record.ExpiresAt = boot.ExpiresAt
_ = r.saveSignedSession(config, record) if err := r.saveSignedSession(config, record); err != nil {
return "" return "", fmt.Errorf("save bootstrapped signed session: %w", err)
}
coordinator.clearBlockedGeneration()
coordinator.clearChallenge()
return "", nil
} }
authURL := boot.AuthURL authURL := boot.AuthURL
if authURL == "" && boot.ChallengeURL != "" { if authURL == "" && boot.ChallengeURL != "" {
@@ -537,17 +1075,19 @@ func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionCo
if authURL == "" && boot.ChallengeID != "" { if authURL == "" && boot.ChallengeID != "" {
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID) authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
} }
if authURL != "" { if authURL == "" {
pendingAuthRequestsMu.Lock() return "", fmt.Errorf("signed-session bootstrap did not return a session or verification challenge")
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: authURL,
CallbackURL: config.CallbackURL,
CreatedAt: time.Now(),
}
pendingAuthRequestsMu.Unlock()
} }
return authURL pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: authURL,
CallbackURL: config.CallbackURL,
CreatedAt: time.Now(),
}
pendingAuthRequestsMu.Unlock()
coordinator.rememberChallenge(r.extensionID, authURL, config.CallbackURL)
return authURL, nil
} }
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string { func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
@@ -608,7 +1148,7 @@ func (r *extensionRuntime) doSignedSessionRequest(
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") ts := signedSessionRequestNow().UTC().Format("2006-01-02T15:04:05.000Z")
nonce := randomHex(12) nonce := randomHex(12)
bodyHashBytes := sha256.Sum256(body) bodyHashBytes := sha256.Sum256(body)
bodyHash := hex.EncodeToString(bodyHashBytes[:]) bodyHash := hex.EncodeToString(bodyHashBytes[:])
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -379,9 +379,9 @@ func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
} }
req = runtime.bindDownloadCancelContext(req) req = runtime.bindDownloadCancelContext(req)
cancelMu.Lock() downloadCancels.mu.Lock()
refs := cancelMap["test-item"].refs refs := downloadCancels.entries["test-item"].refs
cancelMu.Unlock() downloadCancels.mu.Unlock()
if refs != 1 { if refs != 1 {
t.Fatalf("binding a request leaked a cancellation reference: %d", refs) t.Fatalf("binding a request leaked a cancellation reference: %d", refs)
} }
@@ -468,9 +468,9 @@ func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) {
t.Fatalf("new request: %v", err) t.Fatalf("new request: %v", err)
} }
req = runtime.bindDownloadCancelContext(req) req = runtime.bindDownloadCancelContext(req)
extensionRequestCancelMu.Lock() extensionRequestCancels.mu.Lock()
refs := extensionRequestCancelMap[requestID].refs refs := extensionRequestCancels.entries[requestID].refs
extensionRequestCancelMu.Unlock() extensionRequestCancels.mu.Unlock()
if refs != 1 { if refs != 1 {
t.Fatalf("binding a request leaked a cancellation reference: %d", refs) t.Fatalf("binding a request leaked a cancellation reference: %d", refs)
} }
+1 -1
View File
@@ -12,7 +12,7 @@ require (
github.com/go-flac/go-flac/v2 v2.0.4 github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2 github.com/refraction-networking/utls v1.8.2
golang.org/x/crypto v0.54.0 golang.org/x/crypto v0.54.0
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 golang.org/x/mobile v0.0.0-20260730202154-c700fe717e6e
golang.org/x/net v0.57.0 golang.org/x/net v0.57.0
golang.org/x/text v0.40.0 golang.org/x/text v0.40.0
) )
+2 -2
View File
@@ -34,8 +34,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY= golang.org/x/mobile v0.0.0-20260730202154-c700fe717e6e h1:v5PGTbI7uer7FhpDeGryGytfbI8t7CDWkKukozvIsNU=
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA= golang.org/x/mobile v0.0.0-20260730202154-c700fe717e6e/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+2 -2
View File
@@ -126,8 +126,8 @@ var metadataTransport = &http.Transport{
ReadBufferSize: 32 * 1024, ReadBufferSize: 32 * 1024,
// Metadata responses are JSON; transparent gzip cuts transfer size several // Metadata responses are JSON; transparent gzip cuts transfer size several
// times over. Downloads stay on sharedTransport with compression disabled. // times over. Downloads stay on sharedTransport with compression disabled.
DisableCompression: false, DisableCompression: false,
TLSClientConfig: newTLSCompatibilityConfig(false), TLSClientConfig: newTLSCompatibilityConfig(false),
} }
var sharedClient = &http.Client{ var sharedClient = &http.Client{
+1 -666
View File
@@ -1,13 +1,11 @@
package gobackend package gobackend
import ( import (
"bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -32,7 +30,7 @@ type LibraryScanResult struct {
ReleaseDate string `json:"releaseDate,omitempty"` ReleaseDate string `json:"releaseDate,omitempty"`
BitDepth int `json:"bitDepth,omitempty"` BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"` SampleRate int `json:"sampleRate,omitempty"`
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis) Bitrate int `json:"bitrate,omitempty"` // average kbps for both lossless and lossy audio
Genre string `json:"genre,omitempty"` Genre string `json:"genre,omitempty"`
Composer string `json:"composer,omitempty"` Composer string `json:"composer,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
@@ -467,390 +465,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
}
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
ext := resolveLibraryAudioExt(filePath, displayNameHint)
result := &LibraryScanResult{
ID: generateLibraryID(filePath),
FilePath: filePath,
ScannedAt: scanTime,
Format: strings.TrimPrefix(ext, "."),
}
if knownModTime > 0 {
result.FileModTime = knownModTime
} else if info, err := os.Stat(filePath); err == nil {
result.FileModTime = info.ModTime().UnixMilli()
}
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if ext == ".flac" {
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
}
if ext == ".m4a" || ext == ".mp4" || ext == ".aac" {
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
}
if coverCacheDir != "" {
coverPath, err := SaveCoverToCacheWithHintAndKey(
filePath,
displayNameHint,
coverCacheDir,
coverCacheKey,
)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
}
}
switch ext {
case ".mp3":
return scanMP3File(filePath, result, displayNameHint)
case ".opus", ".ogg":
return scanOggFile(filePath, result, displayNameHint)
case ".ape", ".wv", ".mpc":
return scanAPEFile(filePath, result, displayNameHint)
case ".wav":
return scanWAVFile(filePath, result, displayNameHint)
case ".aiff", ".aif", ".aifc":
return scanAIFFFile(filePath, result, displayNameHint)
default:
return scanFromFilename(filePath, displayNameHint, result)
}
}
func embeddedCoverMIME(data []byte) string {
if len(data) >= 8 &&
data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4e &&
data[3] == 0x47 {
return "image/png"
}
return "image/jpeg"
}
func cacheScannedCover(filePath, cacheDir, coverCacheKey string, coverData []byte) string {
if cacheDir == "" || len(coverData) == 0 {
return ""
}
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
path, err := saveLibraryCoverDataToCache(cacheDir, cacheKey, coverData, embeddedCoverMIME(coverData))
if err != nil {
return ""
}
return path
}
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
ext := strings.ToLower(filepath.Ext(filePath))
if ext != "" {
return ext
}
return strings.ToLower(filepath.Ext(displayNameHint))
}
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
if displayNameHint != "" {
return displayNameHint
}
return filePath
}
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
}
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, "", "")
}
func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
f, err := parseFlacFile(filePath)
if err != nil {
return scanFromFilename(filePath, displayNameHint, result)
}
defer f.Close()
metadata := metadataFromParsedFlac(f)
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.ReleaseDate = metadata.Date
result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := audioQualityFromParsedFlac(f)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
if coverCacheDir != "" {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
result.CoverPath = existing
} else if coverData, coverErr := coverArtFromParsedFlac(f); coverErr == nil {
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
}
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, "", "")
}
func scanM4AFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
f, err := os.Open(filePath)
if err != nil {
return scanFromFilename(filePath, displayNameHint, result)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return scanFromFilename(filePath, displayNameHint, result)
}
fileSize := info.Size()
var metadata *AudioMetadata
ilst, ilstErr := findM4AIlstAtom(f, fileSize)
if ilstErr == nil {
metadata, err = readM4ATagsFromIlst(f, fileSize, ilst)
} else {
err = ilstErr
}
if err != nil {
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
}
if metadata != nil {
applyAudioMetadataToScan(metadata, result)
}
quality, err := m4aQualityFromFile(f, fileSize)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate
}
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
result.Format = format
if isLosslessLibraryFormat(format) {
result.Bitrate = 0
}
}
}
if coverCacheDir != "" {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
result.CoverPath = existing
} else if ilstErr == nil {
if coverData, coverErr := extractCoverFromM4AIlst(f, fileSize, ilst); coverErr == nil {
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
}
}
}
if metadata == nil {
return scanFromFilename(filePath, displayNameHint, result)
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func libraryFormatForM4ACodec(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "flac":
return "flac"
case "alac":
return "alac"
case "eac3", "ec-3":
return "eac3"
case "ac3", "ac-3":
return "ac3"
case "ac4", "ac-4":
return "ac4"
case "aac", "mp4a":
return "m4a"
default:
return ""
}
}
func isLosslessLibraryFormat(format string) bool {
switch strings.ToLower(strings.TrimSpace(format)) {
case "flac", "alac", "wav", "aiff", "aif", "aifc":
return true
default:
return false
}
}
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
applyAudioMetadataToScan(metadata, result)
quality, err := GetMP3Quality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadOggVorbisComments(filePath)
if err != nil {
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.Genre = metadata.Genre
result.ReleaseDate = metadata.Date
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetOggQuality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
tag, err := ReadAPETags(filePath)
if err != nil {
GoLog("[LibraryScan] APE tag read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
metadata := APETagToAudioMetadata(tag)
if metadata == nil {
return scanFromFilename(filePath, displayNameHint, result)
}
applyAudioMetadataToScan(metadata, result)
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
result.MetadataFromFilename = true
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
parts := strings.SplitN(filename, " - ", 2)
if len(parts) == 2 {
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
result.TrackName = parts[1]
result.ArtistName = "Unknown Artist"
} else {
result.ArtistName = parts[0]
result.TrackName = parts[1]
}
} else {
if len(filename) > 3 && isNumeric(filename[:2]) {
title := strings.TrimLeft(filename[2:], " .-")
result.TrackName = title
} else {
result.TrackName = filename
}
result.ArtistName = "Unknown Artist"
}
dir := filepath.Dir(filePath)
result.AlbumName = filepath.Base(dir)
if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
func generateLibraryID(filePath string) string {
return fmt.Sprintf("lib_%x", hashString(filePath))
}
func hashString(s string) uint32 {
var hash uint32 = 5381
for _, c := range s {
hash = ((hash << 5) + hash) + uint32(c)
}
return hash
}
func GetLibraryScanProgress() string { func GetLibraryScanProgress() string {
libraryScanProgressMu.RLock() libraryScanProgressMu.RLock()
defer libraryScanProgressMu.RUnlock() defer libraryScanProgressMu.RUnlock()
@@ -897,282 +511,3 @@ func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint,
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
existingFiles := make(map[string]int64)
if snapshotPath == "" {
return existingFiles, nil
}
file, err := os.Open(snapshotPath)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 2)
if len(parts) != 2 {
continue
}
modTime, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
continue
}
existingFiles[parts[1]] = modTime
}
if err := scanner.Err(); err != nil {
return nil, err
}
return existingFiles, nil
}
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
if folderPath == "" {
return "{}", fmt.Errorf("folder path is empty")
}
info, err := os.Stat(folderPath)
if err != nil {
return "{}", fmt.Errorf("folder not found: %w", err)
}
if !info.IsDir() {
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
}
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
libraryScanProgressMu.Lock()
libraryScanProgress = LibraryScanProgress{}
libraryScanProgressMu.Unlock()
libraryScanCancelMu.Lock()
if libraryScanCancel != nil {
close(libraryScanCancel)
}
libraryScanCancel = make(chan struct{})
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
if err != nil {
return "{}", err
}
currentPathSet := make(map[string]bool, len(currentFiles))
for _, fileInfo := range currentFiles {
currentPathSet[fileInfo.path] = true
}
totalFiles := len(currentFiles)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
var filesToScan []libraryAudioFileInfo
skippedCount := 0
existingCueTrackModTimes := make(map[string]int64)
for existingPath, modTime := range existingFiles {
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
existingCueTrackModTimes[baseCuePath] = modTime
}
}
}
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
if f.modTime == cueTrackModTime {
skippedCount++
} else {
filesToScan = append(filesToScan, f)
}
continue
}
}
filesToScan = append(filesToScan, f)
} else if f.modTime != existingModTime {
filesToScan = append(filesToScan, f)
} else {
skippedCount++
}
}
var deletedPaths []string
for existingPath := range existingFiles {
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if currentPathSet[baseCuePath] {
continue
}
deletedPaths = append(deletedPaths, existingPath)
} else if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
}
}
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
len(filesToScan), skippedCount, len(deletedPaths))
if len(filesToScan) == 0 {
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.IsComplete = true
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
result := IncrementalScanResult{
Scanned: []LibraryScanResult{},
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
results := make([]LibraryScanResult, 0, len(filesToScan))
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
cueReferencedAudioFilesInc := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
for _, f := range filesToScan {
ext := strings.ToLower(filepath.Ext(f.path))
if ext == ".cue" {
sheet, err := ParseCueFile(f.path)
if err == nil && sheet.FileName != "" {
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
if audioPath != "" {
parsedCueFiles[f.path] = scannedCueFileInfo{
sheet: sheet,
audioPath: audioPath,
}
cueReferencedAudioFilesInc[audioPath] = true
}
}
}
}
resultsByIndex := make(map[int][]LibraryScanResult, len(filesToScan))
audioTasks := make([]libraryScanTask, 0, len(filesToScan))
completedFiles := skippedCount
for i, f := range filesToScan {
select {
case <-cancelCh:
return "{}", fmt.Errorf("scan cancelled")
default:
}
ext := strings.ToLower(filepath.Ext(f.path))
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[f.path]
if ok {
cueResults, err = scanCueSheetForLibrary(
f.path,
cueInfo.sheet,
cueInfo.audioPath,
"",
f.modTime,
"",
scanTime,
)
} else {
cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
}
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
resultsByIndex[i] = cueResults
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
if cueReferencedAudioFilesInc[f.path] {
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
}
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
audioTasks,
scanTime,
cancelCh,
totalFiles,
&completedFiles,
)
if err != nil {
return "{}", err
}
errorCount += audioErrors
for index, scanResults := range audioResults {
resultsByIndex[index] = scanResults
}
for i := range filesToScan {
results = append(results, resultsByIndex[i]...)
}
libraryScanProgressMu.Lock()
libraryScanProgress.ErrorCount = errorCount
libraryScanProgress.IsComplete = true
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
len(results), skippedCount, len(deletedPaths), errorCount)
scanResult := IncrementalScanResult{
Scanned: results,
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, err := json.Marshal(scanResult)
if err != nil {
return "{}", fmt.Errorf("failed to marshal results: %w", err)
}
return string(jsonBytes), nil
}
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" {
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
}
}
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
}
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
if err != nil {
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
}
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
}
+393
View File
@@ -0,0 +1,393 @@
package gobackend
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
}
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
ext := resolveLibraryAudioExt(filePath, displayNameHint)
result := &LibraryScanResult{
ID: generateLibraryID(filePath),
FilePath: filePath,
ScannedAt: scanTime,
Format: strings.TrimPrefix(ext, "."),
}
if knownModTime > 0 {
result.FileModTime = knownModTime
} else if info, err := os.Stat(filePath); err == nil {
result.FileModTime = info.ModTime().UnixMilli()
}
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if ext == ".flac" {
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
}
if ext == ".m4a" || ext == ".mp4" || ext == ".aac" {
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
}
if coverCacheDir != "" {
coverPath, err := SaveCoverToCacheWithHintAndKey(
filePath,
displayNameHint,
coverCacheDir,
coverCacheKey,
)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
}
}
switch ext {
case ".mp3":
return scanMP3File(filePath, result, displayNameHint)
case ".opus", ".ogg":
return scanOggFile(filePath, result, displayNameHint)
case ".ape", ".wv", ".mpc":
return scanAPEFile(filePath, result, displayNameHint)
case ".wav":
return scanWAVFile(filePath, result, displayNameHint)
case ".aiff", ".aif", ".aifc":
return scanAIFFFile(filePath, result, displayNameHint)
default:
return scanFromFilename(filePath, displayNameHint, result)
}
}
func embeddedCoverMIME(data []byte) string {
if len(data) >= 8 &&
data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4e &&
data[3] == 0x47 {
return "image/png"
}
return "image/jpeg"
}
func cacheScannedCover(filePath, cacheDir, coverCacheKey string, coverData []byte) string {
if cacheDir == "" || len(coverData) == 0 {
return ""
}
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
path, err := saveLibraryCoverDataToCache(cacheDir, cacheKey, coverData, embeddedCoverMIME(coverData))
if err != nil {
return ""
}
return path
}
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
ext := strings.ToLower(filepath.Ext(filePath))
if ext != "" {
return ext
}
return strings.ToLower(filepath.Ext(displayNameHint))
}
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
if displayNameHint != "" {
return displayNameHint
}
return filePath
}
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
}
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, "", "")
}
func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
f, err := parseFlacFile(filePath)
if err != nil {
return scanFromFilename(filePath, displayNameHint, result)
}
defer f.Close()
metadata := metadataFromParsedFlac(f)
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.ReleaseDate = metadata.Date
result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := audioQualityFromParsedFlac(f)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
durationSeconds := float64(quality.TotalSamples) / float64(quality.SampleRate)
result.Duration = int(durationSeconds)
if info, statErr := os.Stat(filePath); statErr == nil && info.Size() > 0 {
result.Bitrate = int(float64(info.Size()) * 8 / durationSeconds / 1000)
}
}
}
if coverCacheDir != "" {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
result.CoverPath = existing
} else if coverData, coverErr := coverArtFromParsedFlac(f); coverErr == nil {
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
}
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, "", "")
}
func scanM4AFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
f, err := os.Open(filePath)
if err != nil {
return scanFromFilename(filePath, displayNameHint, result)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return scanFromFilename(filePath, displayNameHint, result)
}
fileSize := info.Size()
var metadata *AudioMetadata
ilst, ilstErr := findM4AIlstAtom(f, fileSize)
if ilstErr == nil {
metadata, err = readM4ATagsFromIlst(f, fileSize, ilst)
} else {
err = ilstErr
}
if err != nil {
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
}
if metadata != nil {
applyAudioMetadataToScan(metadata, result)
}
quality, err := m4aQualityFromFile(f, fileSize)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate
}
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
result.Format = format
}
}
if coverCacheDir != "" {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
result.CoverPath = existing
} else if ilstErr == nil {
if coverData, coverErr := extractCoverFromM4AIlst(f, fileSize, ilst); coverErr == nil {
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
}
}
}
if metadata == nil {
return scanFromFilename(filePath, displayNameHint, result)
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func libraryFormatForM4ACodec(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "flac":
return "flac"
case "alac":
return "alac"
case "eac3", "ec-3":
return "eac3"
case "ac3", "ac-3":
return "ac3"
case "ac4", "ac-4":
return "ac4"
case "aac", "mp4a":
return "m4a"
default:
return ""
}
}
func isLosslessLibraryFormat(format string) bool {
switch strings.ToLower(strings.TrimSpace(format)) {
case "flac", "alac", "wav", "aiff", "aif", "aifc":
return true
default:
return false
}
}
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
applyAudioMetadataToScan(metadata, result)
quality, err := GetMP3Quality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadOggVorbisComments(filePath)
if err != nil {
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.Genre = metadata.Genre
result.ReleaseDate = metadata.Date
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetOggQuality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
tag, err := ReadAPETags(filePath)
if err != nil {
GoLog("[LibraryScan] APE tag read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
metadata := APETagToAudioMetadata(tag)
if metadata == nil {
return scanFromFilename(filePath, displayNameHint, result)
}
applyAudioMetadataToScan(metadata, result)
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
result.MetadataFromFilename = true
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
parts := strings.SplitN(filename, " - ", 2)
if len(parts) == 2 {
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
result.TrackName = parts[1]
result.ArtistName = "Unknown Artist"
} else {
result.ArtistName = parts[0]
result.TrackName = parts[1]
}
} else {
if len(filename) > 3 && isNumeric(filename[:2]) {
title := strings.TrimLeft(filename[2:], " .-")
result.TrackName = title
} else {
result.TrackName = filename
}
result.ArtistName = "Unknown Artist"
}
dir := filepath.Dir(filePath)
result.AlbumName = filepath.Base(dir)
if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
func generateLibraryID(filePath string) string {
return fmt.Sprintf("lib_%x", hashString(filePath))
}
func hashString(s string) uint32 {
var hash uint32 = 5381
for _, c := range s {
hash = ((hash << 5) + hash) + uint32(c)
}
return hash
}
+291
View File
@@ -0,0 +1,291 @@
package gobackend
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
existingFiles := make(map[string]int64)
if snapshotPath == "" {
return existingFiles, nil
}
file, err := os.Open(snapshotPath)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 2)
if len(parts) != 2 {
continue
}
modTime, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
continue
}
existingFiles[parts[1]] = modTime
}
if err := scanner.Err(); err != nil {
return nil, err
}
return existingFiles, nil
}
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
if folderPath == "" {
return "{}", fmt.Errorf("folder path is empty")
}
info, err := os.Stat(folderPath)
if err != nil {
return "{}", fmt.Errorf("folder not found: %w", err)
}
if !info.IsDir() {
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
}
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
libraryScanProgressMu.Lock()
libraryScanProgress = LibraryScanProgress{}
libraryScanProgressMu.Unlock()
libraryScanCancelMu.Lock()
if libraryScanCancel != nil {
close(libraryScanCancel)
}
libraryScanCancel = make(chan struct{})
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
if err != nil {
return "{}", err
}
currentPathSet := make(map[string]bool, len(currentFiles))
for _, fileInfo := range currentFiles {
currentPathSet[fileInfo.path] = true
}
totalFiles := len(currentFiles)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
var filesToScan []libraryAudioFileInfo
skippedCount := 0
existingCueTrackModTimes := make(map[string]int64)
for existingPath, modTime := range existingFiles {
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
existingCueTrackModTimes[baseCuePath] = modTime
}
}
}
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
if f.modTime == cueTrackModTime {
skippedCount++
} else {
filesToScan = append(filesToScan, f)
}
continue
}
}
filesToScan = append(filesToScan, f)
} else if f.modTime != existingModTime {
filesToScan = append(filesToScan, f)
} else {
skippedCount++
}
}
var deletedPaths []string
for existingPath := range existingFiles {
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if currentPathSet[baseCuePath] {
continue
}
deletedPaths = append(deletedPaths, existingPath)
} else if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
}
}
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
len(filesToScan), skippedCount, len(deletedPaths))
if len(filesToScan) == 0 {
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.IsComplete = true
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
result := IncrementalScanResult{
Scanned: []LibraryScanResult{},
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
results := make([]LibraryScanResult, 0, len(filesToScan))
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
cueReferencedAudioFilesInc := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
for _, f := range filesToScan {
ext := strings.ToLower(filepath.Ext(f.path))
if ext == ".cue" {
sheet, err := ParseCueFile(f.path)
if err == nil && sheet.FileName != "" {
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
if audioPath != "" {
parsedCueFiles[f.path] = scannedCueFileInfo{
sheet: sheet,
audioPath: audioPath,
}
cueReferencedAudioFilesInc[audioPath] = true
}
}
}
}
resultsByIndex := make(map[int][]LibraryScanResult, len(filesToScan))
audioTasks := make([]libraryScanTask, 0, len(filesToScan))
completedFiles := skippedCount
for i, f := range filesToScan {
select {
case <-cancelCh:
return "{}", fmt.Errorf("scan cancelled")
default:
}
ext := strings.ToLower(filepath.Ext(f.path))
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[f.path]
if ok {
cueResults, err = scanCueSheetForLibrary(
f.path,
cueInfo.sheet,
cueInfo.audioPath,
"",
f.modTime,
"",
scanTime,
)
} else {
cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
}
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
resultsByIndex[i] = cueResults
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
if cueReferencedAudioFilesInc[f.path] {
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
}
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
audioTasks,
scanTime,
cancelCh,
totalFiles,
&completedFiles,
)
if err != nil {
return "{}", err
}
errorCount += audioErrors
for index, scanResults := range audioResults {
resultsByIndex[index] = scanResults
}
for i := range filesToScan {
results = append(results, resultsByIndex[i]...)
}
libraryScanProgressMu.Lock()
libraryScanProgress.ErrorCount = errorCount
libraryScanProgress.IsComplete = true
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
len(results), skippedCount, len(deletedPaths), errorCount)
scanResult := IncrementalScanResult{
Scanned: results,
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, err := json.Marshal(scanResult)
if err != nil {
return "{}", fmt.Errorf("failed to marshal results: %w", err)
}
return string(jsonBytes), nil
}
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" {
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
}
}
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
}
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
if err != nil {
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
}
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
}

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