Compare commits

..
418 Commits
Author SHA1 Message Date
zarzet eabafecaeb fix(navigation): open download queue from snackbar actions 2026-07-24 22:19:03 +07:00
zarzet 01c08322c1 fix(extensions): preserve explicit track metadata 2026-07-24 21:32:13 +07:00
zarzet a41074d271 chore(deps): update Flutter, Go, and native dependencies 2026-07-24 20:37:07 +07:00
zarzet 452a15b652 fix(lyrics): reject mismatched search results 2026-07-24 18:48:05 +07:00
zarzet 4713be02de chore(settings): graduate native download worker 2026-07-24 12:23:36 +07:00
zarzet 89dcef8ffd test(native-worker): add Android regression gates 2026-07-24 12:16:41 +07:00
zarzet 0d8bc7b269 fix(native-worker): harden background queue handling 2026-07-24 12:15:55 +07:00
zarzet fa432266f3 chore(settings): graduate backup and restore from beta 2026-07-24 11:40:09 +07:00
zarzet 0cbe51ad19 fix(analysis): render full-track spectrograms 2026-07-23 18:24:14 +07:00
zarzet 5856729e9a fix(library): persist records before download completion 2026-07-23 18:00:22 +07:00
zarzet c35b11a155 fix(library): preserve downloaded file records 2026-07-23 17:40:09 +07:00
zarzet 3f487c03cc chore(release): bump version to 4.8.0 2026-07-23 13:38:48 +07:00
zarzet 5481f8e8cf feat(ui): smooth download progress updates 2026-07-23 13:36:22 +07:00
zarzet cdbb0d6bd7 fix(notifications): restore verification alert sound 2026-07-23 13:23:54 +07:00
zarzet c9114fef01 fix(android): sanitize restored installation state 2026-07-23 10:39:31 +07:00
zarzet 480a96bd0e fix(queue): clarify and coalesce verification progress 2026-07-23 09:57:02 +07:00
zarzet 685392f073 fix(download): accelerate verified extension resumes 2026-07-23 09:56:39 +07:00
zarzet 0c558fb33b test(backend): use a valid descriptor in fd coverage 2026-07-23 09:56:22 +07:00
zarzet ac7840c4e5 fix(download): persist and reconcile history reliably 2026-07-22 15:57:39 +07:00
zarzet f0988070bf fix(convert): simplify output names and harden writes 2026-07-22 15:56:07 +07:00
zarzet 87376ee73b fix(metadata): resolve audio format and local artwork 2026-07-22 15:55:30 +07:00
zarzet b2726db84a fix(queue): bridge completed downloads into library 2026-07-22 15:55:07 +07:00
zarzet 4d77a0b178 fix(ui): honor disabled hero animations 2026-07-22 15:54:50 +07:00
zarzet bd6e2b4472 fix(ui): restore plain loading skeleton on extension album screen
The hero-era loading machinery survived the hero-flight revert: the
loading scaffold rendered the real cover, _RouteSettled deferred the
swap until the push transition ended, and SkeletonCrossfade faded
between the two. The loaded header decodes the cover under a different
mem-cache key (viewport-sized vs full-res), so opening an album from
the artist screen flashed the cover: shown, blanked, shown again.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The centerpiece is TestDoSignedSessionRequestSignature, which
recomputes the rolling-key HMAC signature server-side from the
request headers the client actually sent, the same way a real
backend would validate it, to catch any accidental regression in
the signing scheme itself (field order, rolling-key derivation, or
header names).
2026-07-10 10:11:12 +07:00
AmixandGitHub c281763718 fix(ui): use State's context instead of stale parameter in CSV import (#460)
_importCsv guards every other post-await BuildContext use with the
State's own `mounted`/`this.context`, but one spot read the l10n
strings off the function's `context` parameter instead, requiring a
`// ignore: use_build_context_synchronously` to silence the analyzer.
Switching to `this.context` matches the surrounding guard and lets the
lint verify the usage on its own, so the suppression is no longer
needed.
2026-07-10 10:11:04 +07:00
Hamidreza BayatandGitHub e97ecf38c5 Use more specific MediaQuery methods to reduce unnecessary rebuilds (#467) 2026-07-10 10:11:00 +07:00
Zarz Eleutherius e2b2e2aac6 New translations app_en.arb (Spanish)
[ci skip]
2026-07-07 05:19:13 +07:00
Zarz Eleutherius 5fe644b079 New translations app_en.arb (Spanish)
[ci skip]
2026-07-07 04:05:05 +07:00
Zarz Eleutherius 9e299c52cd New translations app_en.arb (Korean)
[ci skip]
2026-07-07 00:23:25 +07:00
Zarz Eleutherius 4b69278bec New translations app_en.arb (Korean)
[ci skip]
2026-07-05 22:15:21 +07:00
Zarz Eleutherius 0f3a16c79a New translations app_en.arb (Portuguese)
[ci skip]
2026-07-05 13:29:43 +07:00
Zarz Eleutherius 25f1bd3f10 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-05 12:15:35 +07:00
Zarz Eleutherius aba72ca6b6 New translations app_en.arb (Korean)
[ci skip]
2026-07-05 11:16:45 +07:00
Zarz Eleutherius 6ec6f79b83 New translations app_en.arb (Spanish)
[ci skip]
2026-07-05 02:06:22 +07:00
Zarz Eleutherius 0266ad5ea5 New translations app_en.arb (Spanish)
[ci skip]
2026-07-05 01:09:04 +07:00
Zarz Eleutherius 91c141b7ee New translations app_en.arb (Spanish)
[ci skip]
2026-07-03 04:25:28 +07:00
Zarz Eleutherius 4577ce5ca7 New translations app_en.arb (Spanish)
[ci skip]
2026-07-03 03:26:55 +07:00
Zarz Eleutherius 7d843ca02a New translations app_en.arb (Indonesian)
[ci skip]
2026-07-03 01:58:22 +07:00
Zarz Eleutherius 89de08e08c New translations app_en.arb (Korean)
[ci skip]
2026-07-03 00:04:38 +07:00
Zarz Eleutherius 02213d1edf New translations app_en.arb (Korean)
[ci skip]
2026-07-02 21:58:18 +07:00
Zarz Eleutherius 9dedb66800 New translations app_en.arb (French)
[ci skip]
2026-07-02 14:36:38 +07:00
Zarz Eleutherius 24408e0cba New translations app_en.arb (Korean)
[ci skip]
2026-07-02 12:18:01 +07:00
Zarz Eleutherius 4d251a2a0d New translations app_en.arb (Korean)
[ci skip]
2026-07-02 10:04:54 +07:00
Zarz Eleutherius 52972eefef New translations app_en.arb (Korean)
[ci skip]
2026-07-02 09:09:05 +07:00
Zarz Eleutherius 471b412dc5 New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 07:02:34 +07:00
Zarz Eleutherius 4c85a8f05e New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 05:43:37 +07:00
Zarz Eleutherius 0c441b2c86 New translations app_en.arb (Hindi)
[ci skip]
2026-07-02 02:32:59 +07:00
Zarz Eleutherius f6ddd62b13 New translations app_en.arb (Indonesian)
[ci skip]
2026-07-02 02:32:58 +07:00
Zarz Eleutherius 5fd67e53c4 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-07-02 02:32:56 +07:00
Zarz Eleutherius 1206c57d6c New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-07-02 02:32:54 +07:00
Zarz Eleutherius 38815fa0aa New translations app_en.arb (Ukrainian)
[ci skip]
2026-07-02 02:32:52 +07:00
Zarz Eleutherius 959206b0be New translations app_en.arb (Turkish)
[ci skip]
2026-07-02 02:32:51 +07:00
Zarz Eleutherius 7b7ee523fd New translations app_en.arb (Russian)
[ci skip]
2026-07-02 02:32:49 +07:00
Zarz Eleutherius b1220b4c19 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-02 02:32:47 +07:00
Zarz Eleutherius 62b894a7ba New translations app_en.arb (Dutch)
[ci skip]
2026-07-02 02:32:45 +07:00
Zarz Eleutherius 45188ef87d New translations app_en.arb (Korean)
[ci skip]
2026-07-02 02:32:43 +07:00
Zarz Eleutherius 2185ccf2c9 New translations app_en.arb (Japanese)
[ci skip]
2026-07-02 02:32:42 +07:00
Zarz Eleutherius e8d95178c7 New translations app_en.arb (German)
[ci skip]
2026-07-02 02:32:40 +07:00
Zarz Eleutherius a3ebe27b76 New translations app_en.arb (Arabic)
[ci skip]
2026-07-02 02:32:38 +07:00
Zarz Eleutherius b39d4fa15a New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 02:32:37 +07:00
Zarz Eleutherius f86b2b816b New translations app_en.arb (French)
[ci skip]
2026-07-02 02:32:35 +07:00
github-actions[bot] fe72286273 chore: update AltStore source to v4.7.1 2026-07-01 18:42:31 +00:00
zarzet 2b8ec744dd feat(extensions): embed full metadata and cover after extension downloads
Replace duplicate genre/label-only embedding with a shared post-download
step that writes complete FLAC tags and optional cover art when the output
file is available locally.
2026-07-02 01:24:22 +07:00
zarzet 5f11f5b114 chore(release): bump version to 4.7.1+137 2026-07-02 01:24:22 +07:00
zarzet 61f62363b3 fix(convert): make convert bottom sheets draggable and scroll-controlled
Use DraggableScrollableSheet for single and batch convert flows and open
convert sheets with isScrollControlled so long option lists remain usable
on smaller screens.
2026-07-02 01:24:21 +07:00
zarzet 3278e32711 fix(extensions): default verification browser to in-app first
Prefer the in-app browser for signed-session verification challenges,
normalize invalid saved modes to the new default, and keep the help
dialog modal until the user explicitly dismisses it.
2026-07-02 01:24:21 +07:00
zarzet 0be6455d46 fix(download): hand off native worker verification to interactive queue
Detect verification-required failures from the Android native worker,
cancel the worker, and route the item back through the interactive
verification flow with the correct service identifier.
2026-07-02 01:24:21 +07:00
zarzet 0bf5a39a92 fix(ac4): reject truncated AC-4 sample entries safely
Validate audio sample entry header bounds before QuickTime v1
normalization and dac4 injection so malformed MP4 trees are left
unchanged or rejected instead of panicking on truncated boxes.
2026-07-02 01:24:21 +07:00
zarzet 5424648158 feat(audio): add dither and resampler options for lossless conversion
Let users choose FFmpeg dithering when reducing bit depth and SoXr or
SWR resampling when changing sample rate across single-track and batch
lossless conversion flows.
2026-07-02 01:24:20 +07:00
zarzet dcfd95f276 feat(extensions): manual verification help when browser launch fails
Expose a root navigator for global dialogs, show a fallback help sheet
with copy and reopen actions when verification URLs cannot launch, and
schedule the same prompt after a timeout during pending grants.
2026-07-02 01:24:20 +07:00
zarzet 4d6f7d8b08 l10n: add extension verification and lossless processing strings
Add localization keys for verification browser settings, manual
verification help dialog actions, and lossless conversion dithering and
resampler option labels.
2026-07-02 01:24:20 +07:00
zarzet 2c2cf8cdf8 fix(extensions): bootstrap and clear pending signed-session auth
Ensure pending auth requests are created when verification is needed but
missing, and clear them after signed-session grant completion or clear
so stale challenges do not block later verification flows.
2026-07-02 01:24:19 +07:00
zarzet 08c738dc69 feat(settings): add extension verification browser mode preference
Let users choose whether signed-session verification opens in the
external browser or in-app browser first, with automatic fallback to
the other mode when launch fails.
2026-07-02 01:24:19 +07:00
Zarz Eleutherius 308a75037e New translations app_en.arb (Korean)
[ci skip]
2026-07-02 00:58:11 +07:00
Zarz Eleutherius bda41a0c1a New translations app_en.arb (Korean)
[ci skip]
2026-07-02 00:01:11 +07:00
Zarz Eleutherius ec5e7ed1cd New translations app_en.arb (Arabic)
[ci skip]
2026-07-02 00:01:10 +07:00
Zarz Eleutherius 6528d445d3 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 22:52:54 +07:00
Zarz Eleutherius aacf1bef9c New translations app_en.arb (Korean)
[ci skip]
2026-07-01 21:38:38 +07:00
Zarz Eleutherius 4262553036 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 20:35:15 +07:00
Zarz Eleutherius a3d6c6f916 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 19:03:56 +07:00
Zarz Eleutherius bce05eb071 New translations app_en.arb (French)
[ci skip]
2026-07-01 19:03:54 +07:00
Zarz Eleutherius e35ea061d5 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 17:52:36 +07:00
Zarz Eleutherius 8c7c168fdf New translations app_en.arb (French)
[ci skip]
2026-07-01 17:52:34 +07:00
Zarz Eleutherius 7a811ad55e New translations app_en.arb (Korean)
[ci skip]
2026-07-01 16:56:33 +07:00
Zarz Eleutherius 8d29be2a6d New translations app_en.arb (Korean)
[ci skip]
2026-07-01 15:56:58 +07:00
Zarz Eleutherius 36567f40a1 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 14:31:17 +07:00
Zarz Eleutherius 76e0de7d20 New translations app_en.arb (French)
[ci skip]
2026-07-01 14:31:15 +07:00
Zarz Eleutherius 74a41a2d49 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 12:53:58 +07:00
Zarz Eleutherius 1ba82df9e3 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 11:36:48 +07:00
Zarz Eleutherius ccf995ec11 New translations app_en.arb (French)
[ci skip]
2026-07-01 05:21:22 +07:00
Zarz Eleutherius 159b7c2ab6 New translations app_en.arb (Hindi)
[ci skip]
2026-07-01 04:22:04 +07:00
Zarz Eleutherius dc60ad1137 New translations app_en.arb (Indonesian)
[ci skip]
2026-07-01 04:22:02 +07:00
Zarz Eleutherius 1c46708303 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-07-01 04:22:01 +07:00
Zarz Eleutherius 532042e39e New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-07-01 04:21:59 +07:00
Zarz Eleutherius 5cb6faa2d6 New translations app_en.arb (Ukrainian)
[ci skip]
2026-07-01 04:21:57 +07:00
Zarz Eleutherius 91b88a40ea New translations app_en.arb (Turkish)
[ci skip]
2026-07-01 04:21:56 +07:00
Zarz Eleutherius 72a9691c55 New translations app_en.arb (Russian)
[ci skip]
2026-07-01 04:21:54 +07:00
Zarz Eleutherius 00e8280d22 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-01 04:21:52 +07:00
Zarz Eleutherius ed2c7606c3 New translations app_en.arb (Dutch)
[ci skip]
2026-07-01 04:21:51 +07:00
Zarz Eleutherius c1a6262d96 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 04:21:49 +07:00
Zarz Eleutherius 59b953d577 New translations app_en.arb (Japanese)
[ci skip]
2026-07-01 04:21:47 +07:00
Zarz Eleutherius 0854174140 New translations app_en.arb (German)
[ci skip]
2026-07-01 04:21:45 +07:00
Zarz Eleutherius 3802df0abd New translations app_en.arb (Arabic)
[ci skip]
2026-07-01 04:21:44 +07:00
Zarz Eleutherius 57f09cc50c New translations app_en.arb (Spanish)
[ci skip]
2026-07-01 04:21:42 +07:00
Zarz Eleutherius 5257a035d7 New translations app_en.arb (French)
[ci skip]
2026-07-01 04:21:40 +07:00
github-actions[bot] eb36b0bb7b chore: update AltStore source to v4.7.0 2026-06-30 20:59:56 +00:00
zarzet 3fd14e21eb feat(extension-repo): preserve package suffix from download URL
Detect .sflx and .spotiflac-ext from the registry download URL when saving
repo downloads, and extract findExtension so the destination path can use
the correct package extension.
2026-07-01 03:19:02 +07:00
zarzet 408895b607 fix(download): track verification retries per service
Key verification retry state by item and service so a completed challenge
for one provider does not block retries when another service also requires
verification for the same download.
2026-07-01 02:03:19 +07:00
zarzet 1a01147a95 fix(extensions): scope signed session files by endpoint and app context
Hash session cache paths from namespace, base URL, app version, and
platform so credentials do not leak across environments, and invalidate
stored sessions when that scope changes.
2026-07-01 02:03:14 +07:00
zarzet 8950907428 feat(extensions): route Deezer metadata through enabled extension
Prefer the enabled Deezer metadata extension over the built-in provider
when resolving album, track, and playlist metadata requests.
2026-07-01 02:03:10 +07:00
Zarz Eleutherius 0cab036718 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 01:47:32 +07:00
Zarz Eleutherius 4fabd8cfd0 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 23:54:56 +07:00
Zarz Eleutherius 6b7f63d784 New translations app_en.arb (Korean)
[ci skip]
2026-06-30 23:54:55 +07:00
Zarz Eleutherius 95b56c80c5 New translations app_en.arb (Korean)
[ci skip]
2026-06-30 21:51:26 +07:00
Zarz Eleutherius f2020b7653 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-30 17:01:36 +07:00
Zarz Eleutherius 97bb4f6641 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 17:01:34 +07:00
Zarz Eleutherius adeed5ae13 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 14:43:55 +07:00
zarzet eb40a88437 fix(lyrics): sync provider priority to backend on save
Await backend lyrics provider sync when saving the priority page so
fetch order changes take effect immediately instead of only updating
local settings.
2026-06-30 11:13:23 +07:00
zarzet 7f82049beb fix(lyrics): prefer workers.dev LyricsPlus mirror first
Trim the LyricsPlus server list to the workers.dev and binimum mirrors
and update tests to match the new primary endpoint order.
2026-06-30 09:13:55 +07:00
Zarz Eleutherius 5e9cce4b8c New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 08:55:15 +07:00
zarzet c0c1d745f3 refactor: hoist lossless labels before async convert paths
Capture lossless conversion labels before await boundaries in queue,
downloaded album, and track metadata flows to avoid BuildContext usage
across async gaps.
2026-06-30 06:20:57 +07:00
zarzet c2b38a7c5a fix(banner): allow motion artwork video to mix with other audio
Enable mixWithOthers on HLS motion header playback so preview audio and
other apps are less likely to be ducked or interrupted.
2026-06-30 06:20:55 +07:00
zarzet ae8638a4b2 chore(player): log internal pause and audio interruption events
Add debug/info logging around audio focus interruptions, becoming-noisy
events, and user pause requests to make preview playback issues easier
to diagnose.
2026-06-30 06:20:51 +07:00
zarzet b864fafa82 feat(ui): stabilize album and playlist header layouts
Keep header metadata and action buttons visible while track lists are
still loading, disable download-all when empty, and show consistent
placeholder artwork when cover art is missing.
2026-06-30 06:20:47 +07:00
zarzet ee5ab1a751 fix(queue): resolve local album tracks by album_key
Load queue local album tracks from album_key when available and fall back
to album/artist matching with empty album_artist handling. Thread album_key
through grouped local album rows for more reliable navigation.
2026-06-30 06:20:37 +07:00
zarzet 64b884e27a fix(lyrics): sync provider settings to backend before fetch
Expose an awaitable lyrics settings sync on SettingsNotifier and call it
before metadata re-enrich, lyrics autofill, and queue/local embed flows so
the backend uses the latest provider order and fetch options.
2026-06-30 06:20:25 +07:00
zarzet dc8bb2cbc2 feat(extension-health): lengthen cache TTL and honor per-check minimum
Raise default extension health cache to 10 minutes with a 1-minute floor
and a shorter TTL for unknown status. Mirror the TTL rules in the Go
backend and stop force-refreshing health checks from the download
service picker on every open.
2026-06-30 06:20:10 +07:00
zarzet d882fc292c l10n: localize settings, store, and metadata UI strings
Wire remaining hardcoded strings in about, download region picker, file
organization settings, extension repo snackbars, release type badges,
metadata queue actions, online cover labels, and update changelog fallback.
2026-06-30 05:10:37 +07:00
zarzet 5dc0980ced l10n: localize audio conversion labels and confirmations
Pass localized lossless conversion labels through shared helpers and
replace hardcoded capped-lossless confirmation text in single-track and
batch convert flows across metadata, queue, and album screens.
2026-06-30 05:10:36 +07:00
zarzet 1cd668c869 l10n: localize library settings and announcements
Move hardcoded strings in library settings, announcement link errors,
and unknown track title/artist fallbacks to AppLocalizations. Sync
locale-dependent fallback strings from MainShell.
2026-06-30 05:10:34 +07:00
Zarz Eleutherius 22a8fab1c7 New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 03:44:33 +07:00
zarzet a827ebf6f4 l10n: add localization keys for hardcoded UI strings
Add English template strings and Indonesian translations for playback,
audio conversion, library settings, metadata actions, store snackbars,
SongLink region names, and other previously hardcoded user-facing text.
Regenerate AppLocalizations from the updated ARB files.
2026-06-30 03:40:13 +07:00
Zarz Eleutherius f6a0cc9fad New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 02:11:11 +07:00
Zarz Eleutherius 765f0d63b6 New translations app_en.arb (Portuguese)
[ci skip]
2026-06-29 23:32:47 +07:00
Zarz Eleutherius c193e88990 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 22:21:01 +07:00
Zarz Eleutherius 6398d8f7eb New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 22:20:59 +07:00
Zarz Eleutherius 030b8c5459 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 20:52:21 +07:00
Zarz Eleutherius 469af1ac5d New translations app_en.arb (Korean)
[ci skip]
2026-06-29 18:37:22 +07:00
Zarz Eleutherius 6c635dfd01 New translations app_en.arb (Spanish)
[ci skip]
2026-06-29 16:35:21 +07:00
Zarz Eleutherius 299e93ffe3 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 15:38:14 +07:00
Zarz Eleutherius 4dc5b2ee30 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 13:39:38 +07:00
Zarz Eleutherius 6613461eea New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 09:49:23 +07:00
Zarz Eleutherius 9de442b0c8 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 08:39:04 +07:00
Zarz Eleutherius 6bc65fe559 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 07:35:09 +07:00
zarzet 3917ae02e2 feat: apply lossless conversion quality cap to album screen batch convert
Wire bit depth/sample rate caps and real converted-quality persistence
into the downloaded/local album batch conversion paths, matching the
queue tab behavior.
2026-06-29 06:47:36 +07:00
zarzet bd14c7dc63 fix(go): widen lyrics priority grace to 5s so priority provider wins
Apple Music (priority) was losing to LRCLIB when slightly slower than the
1.2s grace window. Widen to 5s so a slower priority provider still wins,
while still bounding the wait if it hangs/fails.
2026-06-29 06:47:04 +07:00
zarzet e0e28aee38 fix: preview player lifecycle and minor safety cleanups
- search_screen caches PreviewPlayerController in initState to avoid
  ref access during dispose
- extension_provider: convert enabledExtensions/searchProviders getters
  to methods
- artist_screen: drop redundant null assertions on motion banner inputs
2026-06-29 06:46:47 +07:00
zarzet 1550eedc12 feat: reorderable up-next queue + playback stability
- Up Next sheet supports drag-to-reorder via ReorderableListView with
  drag handles; MusicPlayerHandler.moveQueueItem keeps current track active
- Active lyric line centers higher in viewport (0.35) so it sits visually
  centered instead of leaning low
- Audio focus rework: explicit music AudioContext, ignore duck
  interruptions, drop the fragile ignore-complete timer, recover playback
  on transient stop/complete during track switches
2026-06-29 06:46:31 +07:00
zarzet b2074dfd02 feat: cap bit depth/sample rate on lossless conversion + WAV/AIFF
- LosslessConversionQuality model with bit depth/sample rate caps,
  applied only when they reduce source quality
- FFmpegService probes sample rate and appends codec-specific args
  (-ar, -sample_fmt, -bits_per_raw_sample) for FLAC/ALAC/WAV/AIFF
- Batch + single-track convert sheets expose quality cap options
- Persist real converted bit depth/sample rate to history/library DB
- track_metadata: recognize and convert to WAV/AIFF targets
- convertedAudioQualityLabel reflects actual output quality
2026-06-29 06:46:19 +07:00
zarzet e9171d6f21 feat(ui): hide audio quality badge on downloading queue items 2026-06-29 06:45:54 +07:00
Zarz Eleutherius 7da423ee1b New translations app_en.arb (Korean)
[ci skip]
2026-06-29 06:39:15 +07:00
Zarz Eleutherius c6ccefbd72 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 06:39:13 +07:00
Zarz Eleutherius 72d25f576c New translations app_en.arb (Korean)
[ci skip]
2026-06-29 01:20:27 +07:00
zarzet ef60bba2e1 feat(ui): redesign local/downloaded album and folder screen headers
- Consistent header design across all local screens: blurred cover,
  black overlay, bottom gradient, centered square cover, adaptive title,
  inline meta line, Play + Shuffle buttons
- Height normalized to 0.6x (clamp 400-580) everywhere
2026-06-28 22:23:08 +07:00
zarzet 12fb942f16 feat: playback queue, preview exclusivity, player bug fixes
- PlaybackController with queue methods for albums/library/playlists
- Library tab play builds merged queue (downloaded + local together)
- Preview vs main player exclusivity
- Preview stops on bottom-nav tab switch
- Duration 0:00 fix, deleted track cleanup, Up Next sheet
- Animation utilities improvements
2026-06-28 22:22:51 +07:00
zarzet 3a2481e8b2 feat: add new playback experience and media integration 2026-06-28 22:22:33 +07:00
zarzet bede5ae8d7 feat(go): verification early-abort in fallback + album metadata from tracks
- DownloadWithExtensionFallback now immediately surfaces verification_required
  when any provider needs verification (availability + download stages),
  instead of letting later providers mask it
- classifyDownloadErrorType treats 'session is not authenticated' as
  verification_required (Go + Dart side)
- parseExtensionAlbumValue.withTrackFallbacks() derives album artist,
  release date, and audio traits from tracks when album-level missing
- albumAudioTraitsFromTracks detects dolby_atmos/hi_res_lossless/lossless
  from per-track audio_quality/audio_modes fields
- parseBitDepthSampleRate parses '24bit/96kHz' style quality labels
2026-06-28 22:16:36 +07:00
zarzet 445b186e3b fix(go): classify transient timeout/5xx in extension health as unknown (grey) 2026-06-28 22:15:55 +07:00
Zarz Eleutherius 7ab9481fab New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 22:07:34 +07:00
Zarz Eleutherius f494e03a6e New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 20:57:58 +07:00
Zarz Eleutherius a90dce95a3 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 19:35:19 +07:00
Zarz Eleutherius a35f6ad939 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 18:37:44 +07:00
Zarz Eleutherius 80e704fd26 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 11:03:47 +07:00
Zarz Eleutherius 88fc958729 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 09:34:15 +07:00
zarzet 354fe61b85 refactor(track-provider): rework verification retry, parse preview/header video
Replace the pending-verification-search bookkeeping with an inline open-and-wait flow that retries the custom search once the session grant arrives (with timeout). Parse preview_url into Track, and carry headerVideoUrl through TrackState for URL-resolved tracks/albums/artists.
2026-06-28 06:07:15 +07:00
zarzet 95f5ae610e feat(banner): HLS motion-artwork header banners and audio-quality badges
Add a MotionHeaderBanner (video_player) that plays a looping muted HLS header video with a static image fallback for artist, album, and playlist screens. The Go backend now exposes header_video, header_image, and audio_traits from extensions. Album/playlist headers show the release year and Dolby Atmos / Lossless badges inline, full date and song count in a footer, a centered square cover when no video is present, and a full-bleed video when one is.
2026-06-28 06:06:54 +07:00
zarzet 2e806a28b9 fix(ui): prevent home search skeleton row from overflowing on narrow screens 2026-06-28 06:06:32 +07:00
zarzet 2ab0350733 feat(preview): play short track preview snippets in lists
Add a preview player provider and PreviewButton, a previewUrl field on Track (parsed from search and local redownload), preview buttons in search results, and stop the preview on navigation/tab change via per-tab navigator observers. Includes preview play/stop/unavailable localization strings.
2026-06-28 06:06:12 +07:00
zarzet ce813bc216 feat(extension-health): cache results, force refresh, treat lookup errors as transient
Cache health results after each check, add a force flag to bypass the cache when the download picker opens or a service is selected, guard stale concurrent results via a per-extension request serial, and treat DNS lookup failures as indeterminate/transient.
2026-06-28 06:05:48 +07:00
Zarz Eleutherius 12d6940a6e New translations app_en.arb (Korean)
[ci skip]
2026-06-27 22:36:44 +07:00
Zarz Eleutherius 34bf726cc1 New translations app_en.arb (Korean)
[ci skip]
2026-06-27 21:33:08 +07:00
zarzet 21fe047e00 feat(ui): polish album folder structure picker bottom sheet
Add a localized description header and Material 3 styling (rounded
corners, scrollable layout, surface color) to the album folder structure
picker in Files settings.
2026-06-27 10:46:04 +07:00
zarzet 8558450378 fix(lyrics): improve provider fallback and health handling 2026-06-27 06:35:32 +07:00
zarzet f9e68b628d chore: bump version to 4.7.0+136 2026-06-27 02:21:44 +07:00
Zarz Eleutherius 68af217b7b New translations app_en.arb (Korean)
[ci skip]
2026-06-27 01:30:31 +07:00
zarzet 50509d0a16 chore: clean up redundant comments across Go backend and Flutter sources 2026-06-26 22:57:03 +07:00
zarzet c1c0494912 perf: optimize queue/library pagination, counts, scan, and extension runtime
- Queue/Library: switch from growing-limit refetch to offset-based append pagination per filter/search/sort, with page cache reassembly and protected-page eviction to avoid top-of-list gaps

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

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

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

- extension_manager: cache compiled goja.Program per extension and RunProgram per isolated download instead of re-reading and re-parsing index.js
2026-06-26 22:39:22 +07:00
zarzet 58e615462c feat(ui): add Beta badge to Backup & Restore settings entry
Extract the BETA pill into a reusable BetaBadge widget in settings_group.dart and add titleTrailing support to SettingsItem. Show the badge on the Backup & Restore entry in the settings list, and reuse the shared widget in the download settings page (removing the duplicated private badge).
2026-06-26 22:16:21 +07:00
zarzet f0bf769f0d refactor(ui): make backup/restore page consistent with other settings
Replace the custom _ActionCard/SwitchListTile layout with the shared SettingsSectionHeader + SettingsGroup + SettingsItem/SettingsSwitchItem components used across the other settings pages, so spacing, grouping and switch styling match. Busy state shows an inline progress indicator on the action row.
2026-06-26 22:12:07 +07:00
Zarz Eleutherius 5eda1c4cb0 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 22:10:10 +07:00
zarzet 423d50cfb5 build(android): bump Java to 25 and targetSdk to 37
Raise sourceCompatibility/targetCompatibility to Java 25 and Kotlin jvmTarget to JVM_25 across app and subprojects, and CI java-version to 25 (Kotlin 2.3.21 supports JVM_25). Bump targetSdk to 37 to match compileSdk. Building locally requires Flutter to use JDK 25 (flutter config --jdk-dir).
2026-06-26 22:08:20 +07:00
zarzet 2f4a62e03c build(android): bump compileSdk to 37 and pin file_picker to beta.5
Pin file_picker to exact 12.0.0-beta.5 since beta.7's published artifact fails to compile on Android. Set compileSdk = 37 so receive_sharing_intent 1.9.0 (compiles against SDK 37) builds against a stable platform.
2026-06-26 21:40:04 +07:00
zarzet e64bea41e6 fix(deps): keep file_picker on 12.x beta to stay win32 6 compatible
file_picker has no stable release compatible with win32 ^6.x (required by device_info_plus/share_plus/connectivity_plus). Its Windows FFI code is compiled even for Android builds, so a win32 override breaks compilation. Revert file_picker to 12.0.0-beta and restore the original pickFile usage. Other Flutter/Go dependency updates remain on latest stable.
2026-06-26 21:33:13 +07:00
Zarz Eleutherius f5c934f744 New translations app_en.arb (Turkish)
[ci skip]
2026-06-26 20:46:23 +07:00
Zarz Eleutherius 89c71f6b16 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 20:46:21 +07:00
zarzet f0acda0f01 feat(network): add opt-in allow local/private network setting
Add a setting that relaxes the SSRF guard so extensions and built-in network code can reach private/local/loopback targets, for users routing traffic through a local proxy or custom DNS. Disabled by default. Wired end-to-end: Go backend (SetAllowPrivateNetwork toggles isPrivateIP guard), Android/iOS platform bridge, Dart settings model/provider, and a toggle in Download settings.
2026-06-26 20:29:15 +07:00
zarzet af4e4561ec chore(deps): update Flutter and Go dependencies to latest stable
Flutter: bump connectivity_plus, device_info_plus, share_plus, receive_sharing_intent, path_provider, flutter_local_notifications to latest stable. Revert file_picker from 12.0.0-beta to 11.0.2 stable and migrate pickFile (singular) to pickFiles. Add win32 ^6.0.1 dependency_overrides to resolve the win32 5 vs 6 conflict between file_picker stable and device_info_plus (Windows-desktop-only transitive dep, not used on mobile targets). Go: update goja, golang.org/x/mobile, golang.org/x/tools, regexp2/v2 to latest.
2026-06-26 20:26:43 +07:00
zarzet 1787059f42 feat(backup): include installed extensions and settings in backup/restore
Back up the store registry URL plus each installed extension (id, version, enabled flag and settings) and restore them on a new device by reinstalling from the store and re-applying settings. Secret-flagged settings (tokens/API keys) are excluded by default behind an opt-in 'Include extension credentials' toggle. Device-bound signed sessions are never backed up. Settings are merged on restore so omitted secrets are not wiped; failed reinstalls are reported.
2026-06-26 20:11:51 +07:00
zarzet b2705cb2ae feat(settings): add backup and restore for settings, history and library
Add a Backup & Restore page that exports app settings, download history, liked tracks, wishlist, playlists (with cover images) and favorite artists into a single JSON file, and restores them on another device. Settings restore preserves device-specific storage location (SAF tree URI, download dir). Includes EN strings and ID translations.
2026-06-26 19:54:20 +07:00
Zarz Eleutherius 4523bc5532 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-26 19:18:41 +07:00
zarzet f236d72a19 fix(download): detect actual output format from audio codec on fallback
When a fallback provider returns a non-FLAC container (e.g. YouTube Opus) and neither an explicit extension field nor a recognizable path suffix is available (such as SAF content URIs), infer the output extension from the backend-probed audio codec. Prevents Opus/MP3/AAC downloads from being mislabeled and embedded as FLAC, which left metadata empty.
2026-06-26 19:06:04 +07:00
zarzet cf270a36ff feat(library): add rename action to playlist folder screen
Add an edit button in the playlist folder app bar that opens a rename dialog with validation, calling renamePlaylist on the collections provider.
2026-06-26 18:35:41 +07:00
zarzet 6d932386b0 feat(download): add {playlist_position} filename placeholder
Add a playlist position token usable in filename templates, plumbed from the playlist screen through the download queue to the Go backend. Auto-prefix playlist downloads with the position when the template lacks the token. Includes Go filename test.
2026-06-26 18:35:32 +07:00
Zarz Eleutherius b9c66e0c7b New translations app_en.arb (Korean)
[ci skip]
2026-06-26 14:12:22 +07:00
Zarz Eleutherius f26c7cfe02 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 11:13:48 +07:00
zarzet 9c054b9e3a feat: handle extension verification and retry-after on download
Classify verification-required and rate-limit errors from extensions, propagate Retry-After seconds through the download fallback, report session-grant success/failure, and add a completeGrant action fallback in the runtime.
2026-06-26 07:12:12 +07:00
zarzet d9f0007a2d fix: navigate to correct artist when tapping artist name
Resolve artist taps by id when available and fall back to name-similarity matching instead of blindly picking the first search result. Pass provider id at more call sites and map multiple artist ids positionally to their names.
2026-06-26 07:11:54 +07:00
zarzet ee35f52baf feat: extension runtime and provider improvements 2026-06-26 04:14:51 +07:00
Zarz Eleutherius 3bbe29c2e8 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 19:21:07 +07:00
Zarz Eleutherius e0cc1f7cb2 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 07:08:49 +07:00
Zarz Eleutherius 9faa1b7961 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 06:01:55 +07:00
Zarz Eleutherius 142d7e639b New translations app_en.arb (Korean)
[ci skip]
2026-06-25 00:45:57 +07:00
Zarz Eleutherius 9e115902b7 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 23:08:31 +07:00
Zarz Eleutherius d2ec68808c New translations app_en.arb (Korean)
[ci skip]
2026-06-24 21:57:39 +07:00
Zarz Eleutherius 352186eb40 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 11:55:48 +07:00
Zarz Eleutherius 68e742b670 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 10:55:05 +07:00
Zarz Eleutherius d7c4586358 New translations app_en.arb (Korean)
[ci skip]
2026-06-23 19:57:10 +07:00
Zarz Eleutherius 84f784e538 New translations app_en.arb (Korean)
[ci skip]
2026-06-23 16:02:14 +07:00
zarzet 21347420f3 feat(download): AC-4 passthrough support
Decrypt AC-4 via the FFmpeg mov muxer with a -f mov fallback, then repair the output to a standards-compliant ISO MP4: inject the dac4 config box from the encrypted source, normalize the QuickTime container/sample entry, and write iTunes metadata (incl. cover and lyrics) natively. Codec-keyed and generic, so it applies to any extension that returns AC-4 streams. Wired through PlatformBridge/MainActivity for both SAF and local decrypt paths.
2026-06-23 02:44:08 +07:00
zarzet 26987459f3 fix(metadata): cap oversized cover art and support QuickTime/MP4 tags
Re-encode/downscale cover art that exceeds the FLAC 24-bit picture block limit (go-flac silently truncated it into a corrupt file). Locate ilst in both ISO and QuickTime-style meta atoms, and skip freeform tags gracefully when no iTunes container exists.
2026-06-23 02:41:42 +07:00
Zarz Eleutherius 5f9822f726 New translations app_en.arb (Korean)
[ci skip]
2026-06-22 23:06:40 +07:00
Zarz Eleutherius 41a1b94811 New translations app_en.arb (Korean)
[ci skip]
2026-06-22 22:05:04 +07:00
Zarz Eleutherius e4b1d39c4e New translations app_en.arb (Korean)
[ci skip]
2026-06-22 20:58:17 +07:00
Zarz Eleutherius 136de95290 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-22 19:25:42 +07:00
zarzet 897388853b chore: move website to spotiflacapp/Website repo 2026-06-22 17:25:31 +07:00
Zarz Eleutherius 1afe66cb66 New translations app_en.arb (French)
[ci skip]
2026-06-21 23:58:16 +07:00
Zarz Eleutherius bdf1626273 New translations app_en.arb (French)
[ci skip]
2026-06-21 22:50:25 +07:00
Zarz Eleutherius 87131ad633 New translations app_en.arb (Hindi)
[ci skip]
2026-06-21 21:33:23 +07:00
Zarz Eleutherius 439418e419 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-21 21:33:21 +07:00
Zarz Eleutherius 0eb4e15a8f New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-06-21 21:33:20 +07:00
Zarz Eleutherius 5f3928399c New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-21 21:33:18 +07:00
Zarz Eleutherius 0c0f2d54ad New translations app_en.arb (Ukrainian)
[ci skip]
2026-06-21 21:33:17 +07:00
Zarz Eleutherius 2f54fe6cf6 New translations app_en.arb (Turkish)
[ci skip]
2026-06-21 21:33:15 +07:00
Zarz Eleutherius e827a26458 New translations app_en.arb (Russian)
[ci skip]
2026-06-21 21:33:14 +07:00
Zarz Eleutherius c886d55317 New translations app_en.arb (Portuguese)
[ci skip]
2026-06-21 21:33:13 +07:00
Zarz Eleutherius 0189f576c7 New translations app_en.arb (Dutch)
[ci skip]
2026-06-21 21:33:11 +07:00
Zarz Eleutherius 13c46d0f5e New translations app_en.arb (Korean)
[ci skip]
2026-06-21 21:33:10 +07:00
Zarz Eleutherius 60175108df New translations app_en.arb (Japanese)
[ci skip]
2026-06-21 21:33:08 +07:00
Zarz Eleutherius 7c2b87f49a New translations app_en.arb (German)
[ci skip]
2026-06-21 21:33:07 +07:00
Zarz Eleutherius f33ee40779 New translations app_en.arb (Arabic)
[ci skip]
2026-06-21 21:33:05 +07:00
Zarz Eleutherius b9142bc40c New translations app_en.arb (Spanish)
[ci skip]
2026-06-21 21:33:04 +07:00
Zarz Eleutherius 680e0e0976 New translations app_en.arb (French)
[ci skip]
2026-06-21 21:33:02 +07:00
zarzet ef52332b8b feat(replaygain): add Opus R128 gain tags
Write R128_TRACK_GAIN/R128_ALBUM_GAIN (Q7.8, ref -23 LUFS) alongside the existing REPLAYGAIN_* tags for .opus files so spec-compliant Opus players (RFC 7845) apply gain. Covers per-track and album embed during download (incl. SAF) and standalone re-scan.
2026-06-20 02:24:50 +07:00
zarzet 1489378ffd fix(android): disable Impeller on Sony audio players and Vivante GPUs
The GL renderer string is empty when Flutter shell args are built (no GL context yet), so the GPU-pattern check never matched Sony Walkman devices and Impeller crashed in the Vivante driver's glLinkProgram. Match MANUFACTURER 'SonyAudio' (distinct from Xperia 'Sony') and add Vivante GC GPU patterns.
2026-06-19 00:50:52 +07:00
zarzet ccc93f881a fix(metadata): write M4A ISRC/label natively and read all edited fields from file
FFmpeg's MP4 muxer drops ISRC and label, so write them as iTunes freeform atoms natively after every M4A embed pass. The metadata screen now reads all edited fields from the file on load (file is the source of truth), fixing edited values reverting to stale cached values after reopening.
2026-06-14 15:00:34 +07:00
zarzet ded8b68098 fix(download): honor requested quality when fallback provider recognizes it 2026-06-14 14:12:37 +07:00
zarzet 983be8b37a feat(queue): tap a failed download to view its error details
Tapping a failed download tile (grid or list) now opens a dialog showing the full error message with retry and remove actions.
2026-06-14 05:26:30 +07:00
github-actions[bot] 7b22bbf25f chore: update AltStore source to v4.6.0 2026-06-13 19:43:33 +00:00
352 changed files with 132080 additions and 111107 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.41.5"
"flutter": "3.44.8"
}
+27
View File
@@ -0,0 +1,27 @@
## Description
<!-- What does this PR do? Why is it needed? -->
## Related Issues
<!-- Link related issues, e.g. "Fixes #123" -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor (no functional change)
- [ ] Documentation
- [ ] Other (describe below)
## Checklist
- [ ] Code follows project conventions
- [ ] `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
<!-- For UI changes, add before/after screenshots. Delete this section otherwise. -->
+185
View File
@@ -0,0 +1,185 @@
name: CI
on:
pull_request:
push:
branches:
- main
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
name: Detect changed paths
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
dart: ${{ steps.filter.outputs.dart }}
go: ${{ steps.filter.outputs.go }}
android: ${{ steps.filter.outputs.android }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Filter paths
uses: dorny/paths-filter@v3
id: filter
with:
filters: |
dart:
- 'lib/**'
- 'test/**'
- 'assets/**'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'l10n.yaml'
- '.github/workflows/ci.yml'
go:
- 'go_backend/**'
- '.github/workflows/ci.yml'
android:
- 'android/**'
- 'go_backend/**'
- 'lib/models/settings.dart'
- 'lib/providers/download_queue_provider*.dart'
- 'lib/services/download_request_payload.dart'
- 'lib/services/history_database.dart'
- 'lib/services/platform_bridge.dart'
- 'pubspec.yaml'
- 'pubspec.lock'
- '.github/workflows/ci.yml'
flutter:
name: Flutter analyze & test
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.dart == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Cache pub dependencies
uses: actions/cache@v5
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-${{ runner.os }}-
- name: Get Flutter dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests
run: flutter test
go:
name: Go vet & test
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.go == 'true'
defaults:
run:
working-directory: go_backend
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
# Keep in sync with release.yml
go-version: "1.26.5"
cache-dependency-path: go_backend/go.sum
- name: Check formatting
run: test -z "$(gofmt -l .)"
- name: Vet
run: go vet ./...
- name: Run tests
run: go test ./...
android:
name: Android compile & native tests
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.android == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "25"
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.26.5"
cache-dependency-path: go_backend/go.sum
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Cache Gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Install Android SDK & NDK
run: |
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
"ndk;29.0.14206865" \
"platforms;android-37" \
"build-tools;37.0.0"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
- name: Build Go backend for Android
working-directory: go_backend
run: |
go install golang.org/x/mobile/cmd/gomobile
gomobile init
mkdir -p ../android/app/libs
gomobile bind \
-target=android/arm,android/arm64 \
-androidapi 24 \
-o ../android/app/libs/gobackend.aar \
.
env:
CGO_ENABLED: 1
- name: Get Flutter dependencies
run: flutter pub get
- name: Configure Flutter SDK for Gradle
run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties
- name: Compile Kotlin and run native unit tests
run: ./android/gradlew -p android :app:compileDebugKotlin :app:testDebugUnitTest
+46 -19
View File
@@ -47,15 +47,14 @@ jobs:
steps:
- name: Free disk space
run: |
# Remove large unused tools (~15GB total)
# Remove large unused tools (~15GB total). Docker prune was dropped:
# it took 1-2 minutes and the rm below already frees enough.
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo rm -rf /usr/local/share/boost
sudo rm -rf /usr/share/swift
sudo rm -rf /usr/local/.ghcup
# Clean docker images
sudo docker image prune --all --force
# Show available space
df -h
@@ -66,12 +65,12 @@ jobs:
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "17"
java-version: "25"
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25.8"
go-version: "1.26.5"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
@@ -84,6 +83,12 @@ jobs:
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Cache Android NDK
uses: actions/cache@v5
with:
path: /usr/local/lib/android/sdk/ndk/29.0.14206865
key: ndk-29.0.14206865
- name: Install Android SDK & NDK
run: |
# Use pre-installed Android SDK on GitHub runners
@@ -94,22 +99,29 @@ jobs:
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
# Install NDK r29 (supports 16KB page size for Android 15+)
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
# Keep the installed platform aligned with compileSdk/targetSdk.
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37" "build-tools;37.0.0"
# Set NDK path
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
- name: Install gomobile
working-directory: go_backend
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
# Installed from inside the module so the go.mod-pinned x/mobile
# version is used (reproducible + covered by the setup-go cache).
go install golang.org/x/mobile/cmd/gomobile
gomobile init
- name: Build Go backend for Android
working-directory: go_backend
run: |
mkdir -p ../android/app/libs
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
# arm/arm64 only: ndk.abiFilters in app/build.gradle.kts strips
# every other ABI from all outputs (universal APK included), so an
# amd64 slice would never reach a shipped APK — it only bloats the
# aar and slows this step.
gomobile bind -target=android/arm,android/arm64 -androidapi 24 -o ../android/app/libs/gobackend.aar .
env:
CGO_ENABLED: 1
@@ -119,6 +131,13 @@ jobs:
channel: "stable"
cache: true
- name: Cache pub dependencies
uses: actions/cache@v5
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-${{ runner.os }}-
- name: Get Flutter dependencies
run: flutter pub get
@@ -179,7 +198,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25.8"
go-version: "1.26.5"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
@@ -191,8 +210,10 @@ jobs:
restore-keys: pods-${{ runner.os }}-
- name: Install gomobile
working-directory: go_backend
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
# go.mod-pinned x/mobile version (reproducible + cached by setup-go).
go install golang.org/x/mobile/cmd/gomobile
gomobile init
- name: Build Go backend for iOS
@@ -210,8 +231,9 @@ jobs:
- name: Add XCFramework to Xcode project
run: |
# Install xcodeproj gem for modifying Xcode project
sudo gem install xcodeproj
# xcodeproj usually ships with the preinstalled CocoaPods; only
# install it when missing.
gem list -i xcodeproj >/dev/null || sudo gem install xcodeproj
# Create Ruby script to add framework
cat > add_framework.rb << 'EOF'
@@ -254,6 +276,13 @@ jobs:
channel: "stable"
cache: true
- name: Cache pub dependencies
uses: actions/cache@v5
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-${{ runner.os }}-
- name: Get Flutter dependencies
run: flutter pub get
@@ -388,8 +417,6 @@ jobs:
### Installation
**Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6)
FOOTER
echo "Release body:"
@@ -399,7 +426,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
body_path: /tmp/release_body.txt
files: ./release/*
draft: false
@@ -565,7 +592,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM64_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
fi
# Upload arm32 APK to channel
@@ -574,7 +601,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM32_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm32"
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
fi
# Upload iOS IPA to channel
@@ -584,7 +611,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${IOS_IPA}" \
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
fi
echo "Telegram notification sent!"
+5 -1
View File
@@ -60,7 +60,9 @@ ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
# Extension folder
extension/
extension/*
extension/v2/
extension/v2/**
# Agent instructions
AGENTS.md
@@ -82,6 +84,8 @@ flutter_*.log
tool/
.claude/settings.local.json
.playwright-mcp/
.rtk/
CLAUDE.md
# FVM Version Cache
.fvm/
+22 -15
View File
@@ -49,10 +49,10 @@ Feature requests are welcome! Please use the feature request template and:
### Code Contributions
1. **Fork the repository** and create your branch from `dev`
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 `dev` branch
4. **Submit a pull request** to the `main` branch
### Translations
@@ -83,7 +83,7 @@ Translation files are located in `lib/l10n/arb/`.
2. **Add upstream remote**
```bash
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
```
3. **Use FVM (Flutter Version: 3.41.5)**
@@ -101,11 +101,15 @@ Translation files are located in `lib/l10n/arb/`.
dart run build_runner build --delete-conflicting-outputs
```
6. **Set up Go environment (Go Version: 1.25.7)**
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
cd go_backend
mkdir -p ../android/app/libs
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
cd go_backend
mkdir -p ../android/app/libs
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
cd ..
```
@@ -172,17 +176,18 @@ flutter analyze
### State Management
We use **Riverpod** for state management. Follow these patterns:
We use **Riverpod** for state management, with hand-written `Notifier`s
(no `riverpod_annotation` code generation). Follow this pattern:
```dart
// Use code generation with riverpod_annotation
@riverpod
class MyNotifier extends _$MyNotifier {
class MyNotifier extends Notifier<MyState> {
@override
MyState build() => MyState();
// Methods to update state
}
final myProvider = NotifierProvider<MyNotifier, MyState>(MyNotifier.new);
```
### Localization
@@ -238,7 +243,7 @@ chore(deps): update flutter_riverpod to 3.1.0
1. **Update your fork**
```bash
git fetch upstream
git rebase upstream/dev
git rebase upstream/main
```
2. **Create a feature branch**
@@ -254,7 +259,7 @@ chore(deps): update flutter_riverpod to 3.1.0
```
5. **Create a Pull Request**
- Target the `dev` branch
- Target the `main` branch
- Fill in the PR template
- Link related issues
@@ -272,11 +277,13 @@ chore(deps): update flutter_riverpod to 3.1.0
- [ ] 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/zarzet/SpotiFLAC-Mobile/discussions)
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
- Open a [Discussion](https://github.com/spotiflacapp/SpotiFLAC-Mobile/discussions)
- Check existing [Issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
Thank you for contributing! 💚
+1 -1
View File
@@ -52,7 +52,7 @@ Extensions let the community add new music sources and features without waiting
### Developing Extensions
> [!NOTE]
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
> Want to build your own extension? The [Extension Development Guide](https://spotiflac.zarz.moe/docs) has everything you need.
---
+14 -7
View File
@@ -17,7 +17,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "com.zarz.spotiflac"
compileSdk = flutter.compileSdkVersion
compileSdk = 37
ndkVersion = flutter.ndkVersion
buildFeatures {
@@ -26,13 +26,13 @@ android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
}
}
@@ -50,7 +50,7 @@ android {
defaultConfig {
applicationId = "com.zarz.spotiflac"
minSdk = flutter.minSdkVersion
targetSdk = 36
targetSdk = 37
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
@@ -62,6 +62,8 @@ android {
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
ndk {
debugSymbolLevel = "FULL"
}
@@ -121,8 +123,13 @@ dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.activity:activity-ktx:1.13.0")
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
// NativeDownloadFinalizer imports FFmpegKit APIs directly. The Flutter
// plugin owns the runtime AAR; compileOnly avoids packaging it twice here.
compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1")
testImplementation("junit:junit:4.13.2")
}
+31
View File
@@ -3,6 +3,7 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
@@ -21,6 +22,9 @@
android:label="SpotiFLAC Mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:fullBackupContent="@xml/backup_rules_legacy"
android:dataExtractionRules="@xml/backup_rules"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
@@ -100,6 +104,12 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="spotify-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="session-grant" />
</intent-filter>
</activity>
<!-- Download Service -->
@@ -108,6 +118,23 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- flutter_local_notifications receivers -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
@@ -124,6 +151,10 @@
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- FileProvider for APK installation -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -8,6 +8,10 @@ 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
@@ -38,7 +42,10 @@ class DownloadService : Service() {
companion object {
private const val CHANNEL_ID = "download_channel"
private const val ALERT_CHANNEL_ID = "download_alerts_v1"
private const val NOTIFICATION_ID = 1001
private const val DOWNLOAD_RESULT_NOTIFICATION_ID = 1
private const val VERIFICATION_REQUIRED_NOTIFICATION_ID = 4
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
private const val WAKELOCK_RENEW_MS = 30 * 60 * 1000L
@@ -153,7 +160,15 @@ class DownloadService : Service() {
context.startService(intent)
}
fun getNativeWorkerSnapshot(context: Context): String {
// Header of the last written state snapshot (all fields except the
// per-item payload). Lets steady-state polls skip re-reading and
// re-parsing the full state file — which grows with every completed
// item (results embed history rows and lyrics) — once the caller has
// already consumed that items payload.
@Volatile private var lastStateHeaderJson: String? = null
@Volatile private var lastStateHeaderSerial = 0L
fun getNativeWorkerSnapshot(context: Context, sinceStateSerial: Long = 0L): String {
synchronized(NATIVE_WORKER_STATE_FILE_LOCK) {
val stateFile = File(context.filesDir, NATIVE_WORKER_STATE_FILE)
if (!stateFile.exists()) {
@@ -165,12 +180,35 @@ class DownloadService : Service() {
.put("completed", 0)
.put("failed", 0)
.put("skipped", 0)
.put("state_serial", 0L)
.put("items", JSONArray())
.toString()
}
val state = AtomicFile(stateFile).openRead().bufferedReader(Charsets.UTF_8).use {
it.readText()
}.let { JSONObject(it) }
val headerJson = lastStateHeaderJson
val headerSerial = lastStateHeaderSerial
val state: JSONObject
if (sinceStateSerial > 0 &&
headerSerial in 1..sinceStateSerial &&
headerJson != null
) {
// Caller already consumed this items payload; serve the
// cached compact header instead of re-parsing the file.
state = JSONObject(headerJson)
} else {
state = AtomicFile(stateFile).openRead().bufferedReader(Charsets.UTF_8).use {
it.readText()
}.let { JSONObject(it) }
val stateSerial = state.optLong("snapshot_serial", 0L)
state.put("state_serial", stateSerial)
if (sinceStateSerial > 0 && stateSerial in 1..sinceStateSerial) {
state.remove("items")
state.remove("item_ids")
state.remove("last_result")
state.remove("settings_json")
}
}
val progressFile = File(context.filesDir, NATIVE_WORKER_PROGRESS_FILE)
if (progressFile.exists()) {
try {
@@ -262,7 +300,18 @@ class DownloadService : Service() {
private var latestCommittedStateSnapshotSerial = 0L
private var latestCommittedProgressSnapshotSerial = 0L
@Volatile private var nativeWorkerPaused = false
@Volatile private var nativeWorkerNetworkPaused = false
@Volatile private var nativeWorkerVerificationPaused = false
@Volatile private var nativeWorkerCancelRequested = false
private var nativeWorkerDownloadNetworkMode = "any"
private var nativeWorkerNetworkCallback: ConnectivityManager.NetworkCallback? = null
private val nativeWorkerWifiNetworks = mutableSetOf<Network>()
// Bumped every time a new native queue replaces the current one. A worker
// coroutine that observes a different generation than its own must stop
// without touching the snapshot or the service lifecycle: cancel() alone
// cannot interrupt the blocking gomobile call it may be sitting in, and
// the shared pause/cancel flags get reset for the new run.
@Volatile private var nativeWorkerGeneration = 0L
override fun onCreate() {
super.onCreate()
@@ -313,26 +362,7 @@ class DownloadService : Service() {
}
ACTION_PAUSE_NATIVE_QUEUE -> {
nativeWorkerPaused = true
var itemIdToCancel = ""
synchronized(nativeWorkerItems) {
val activeItem = nativeWorkerItems.firstOrNull {
it.status == "downloading" || it.status == "finalizing"
} ?: nativeWorkerItems.firstOrNull {
it.itemId == nativeWorkerCurrentItemId && it.status == "queued"
}
activeItem?.let {
it.status = "queued"
itemIdToCancel = it.itemId
}
}
if (itemIdToCancel.isBlank()) itemIdToCancel = nativeWorkerCurrentItemId
if (itemIdToCancel.isNotBlank()) {
try {
Gobackend.cancelDownload(itemIdToCancel)
} catch (_: Exception) {
}
}
NativeDownloadFinalizer.cancelActiveWork()
cancelActiveNativeItemForPause()
writeNativeWorkerSnapshotAsync(
isRunning = nativeWorkerJob?.isActive == true,
isPaused = true,
@@ -343,16 +373,19 @@ class DownloadService : Service() {
}
ACTION_RESUME_NATIVE_QUEUE -> {
nativeWorkerPaused = false
val stillPaused = isNativeWorkerPaused()
writeNativeWorkerSnapshotAsync(
isRunning = nativeWorkerJob?.isActive == true,
isPaused = false,
isPaused = stillPaused,
currentItemId = "",
message = "Resumed",
message = if (stillPaused) nativeWorkerPauseMessage() else "Resumed",
includeItems = true
)
}
ACTION_CANCEL_NATIVE_QUEUE -> {
nativeWorkerCancelRequested = true
nativeWorkerVerificationPaused = false
cancelNativeVerificationNotification()
synchronized(nativeWorkerItems) {
for (item in nativeWorkerItems) {
if (item.status == "queued" ||
@@ -437,7 +470,7 @@ class DownloadService : Service() {
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
val progressChannel = NotificationChannel(
CHANNEL_ID,
"Download Service",
NotificationManager.IMPORTANCE_LOW
@@ -445,8 +478,17 @@ class DownloadService : Service() {
description = "Shows download progress"
setShowBadge(false)
}
val alertChannel = NotificationChannel(
ALERT_CHANNEL_ID,
"Download Alerts",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Important download status and actions that need attention"
enableVibration(true)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
manager.createNotificationChannel(progressChannel)
manager.createNotificationChannel(alertChannel)
}
}
@@ -469,24 +511,51 @@ class DownloadService : Service() {
private fun startNativeWorker(requestsJson: String, settingsJson: String) {
flushNativeAlbumReplayGainJournalIfComplete()
nativeWorkerRunId = parseNativeWorkerRunId(settingsJson)
val requestedRunId = parseNativeWorkerRunId(settingsJson)
val requests = try {
parseNativeDownloadRequests(requestsJson)
} catch (e: Exception) {
writeNativeWorkerSnapshot(
isRunning = false,
isPaused = false,
currentItemId = "",
message = "Invalid native queue payload: ${e.message}",
settingsJson = settingsJson,
includeItems = true
)
stopForegroundService(cancelNativeWorker = false)
if (nativeWorkerJob?.isActive != true) {
nativeWorkerRunId = requestedRunId
writeNativeWorkerSnapshot(
isRunning = false,
isPaused = false,
currentItemId = "",
message = "Invalid native queue payload: ${e.message}",
settingsJson = settingsJson,
includeItems = true
)
stopForegroundService(cancelNativeWorker = false)
}
return
}
nativeWorkerRunId = requestedRunId
cancelNativeVerificationNotification()
// Abort the previous run's in-flight work before the shared flags are
// reset for the new run: the coroutine cancel below cannot interrupt a
// blocking gomobile download by itself.
synchronized(nativeWorkerItems) {
for (item in nativeWorkerItems) {
if (item.status == "preparing" ||
item.status == "downloading" ||
item.status == "finalizing"
) {
try {
Gobackend.cancelDownload(item.itemId)
} catch (_: Exception) {
}
}
}
}
NativeDownloadFinalizer.cancelActiveWork()
nativeWorkerGeneration++
val generation = nativeWorkerGeneration
nativeWorkerJob?.cancel(CancellationException("Native queue replaced"))
nativeWorkerPaused = false
nativeWorkerNetworkPaused = false
nativeWorkerVerificationPaused = false
nativeWorkerCancelRequested = false
unregisterNativeWorkerNetworkCallback()
queueCount = requests.size
synchronized(nativeReplayGainEntries) {
nativeReplayGainEntries.clear()
@@ -519,6 +588,7 @@ class DownloadService : Service() {
}
)
}
configureNativeWorkerNetworkPolicy(settingsJson)
writeNativeReplayGainJournal()
currentStatus = "preparing"
currentTrackName = requests.firstOrNull()?.trackName ?: ""
@@ -528,15 +598,15 @@ class DownloadService : Service() {
startForegroundService()
writeNativeWorkerSnapshot(
isRunning = true,
isPaused = false,
isPaused = isNativeWorkerPaused(),
currentItemId = "",
message = "Starting",
message = if (isNativeWorkerPaused()) nativeWorkerPauseMessage() else "Starting",
settingsJson = settingsJson,
includeItems = true
)
nativeWorkerJob = serviceScope.launch {
runNativeWorker(requests, settingsJson)
runNativeWorker(requests, settingsJson, generation)
}
}
@@ -548,6 +618,183 @@ class DownloadService : Service() {
}
}
private fun isNativeWorkerPaused(): Boolean =
nativeWorkerPaused ||
nativeWorkerNetworkPaused ||
nativeWorkerVerificationPaused
private fun nativeWorkerPauseMessage(): String = when {
nativeWorkerVerificationPaused -> "Verification required"
nativeWorkerNetworkPaused -> "Waiting for Wi-Fi"
else -> "Paused"
}
private fun cancelActiveNativeItemForPause() {
var itemIdToCancel = ""
synchronized(nativeWorkerItems) {
val activeItem = nativeWorkerItems.firstOrNull {
it.status == "downloading" || it.status == "finalizing"
} ?: nativeWorkerItems.firstOrNull {
it.itemId == nativeWorkerCurrentItemId && it.status == "queued"
}
activeItem?.let {
it.status = "queued"
it.progress = 0.0
it.bytesReceived = 0L
it.bytesTotal = 0L
itemIdToCancel = it.itemId
}
}
if (itemIdToCancel.isBlank()) itemIdToCancel = nativeWorkerCurrentItemId
if (itemIdToCancel.isNotBlank()) {
try {
Gobackend.cancelDownload(itemIdToCancel)
} catch (_: Exception) {
}
}
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> {
val array = JSONArray(requestsJson)
val requests = ArrayList<NativeDownloadRequest>(array.length())
@@ -602,25 +849,31 @@ class DownloadService : Service() {
}
}
private suspend fun runNativeWorker(requests: List<NativeDownloadRequest>, settingsJson: String) {
var completed = 0
var failed = 0
private suspend fun runNativeWorker(
requests: List<NativeDownloadRequest>,
settingsJson: String,
generation: Long
) {
val rateLimitAttempts = mutableMapOf<String, Int>()
try {
var requestIndex = 0
while (requestIndex < requests.size) {
val request = requests[requestIndex]
while (nativeWorkerPaused && !nativeWorkerCancelRequested) {
while (isNativeWorkerPaused() &&
!nativeWorkerCancelRequested &&
generation == nativeWorkerGeneration
) {
writeNativeWorkerSnapshot(
isRunning = true,
isPaused = true,
currentItemId = request.itemId,
message = "Paused",
message = nativeWorkerPauseMessage(),
settingsJson = settingsJson,
includeItems = true
)
delay(500)
}
if (nativeWorkerCancelRequested) {
if (nativeWorkerCancelRequested || generation != nativeWorkerGeneration) {
break
}
@@ -653,15 +906,29 @@ class DownloadService : Service() {
try {
Gobackend.initItemProgress(request.itemId)
progressJob = serviceScope.launch {
// The snapshot write is an AtomicFile open+fsync+
// rename; skip ticks where progress hasn't moved.
var lastSignature: String? = null
while (true) {
updateNativeWorkerItemProgress(request.itemId)
writeNativeWorkerSnapshot(
isRunning = true,
isPaused = false,
currentItemId = request.itemId,
message = "Downloading",
settingsJson = settingsJson
)
val signature = synchronized(nativeWorkerItems) {
nativeWorkerItems
.firstOrNull { it.itemId == request.itemId }
?.let {
"${it.status}:${it.bytesReceived}:" +
"${it.bytesTotal}:${it.progress}"
}
}
if (signature != lastSignature) {
lastSignature = signature
writeNativeWorkerSnapshot(
isRunning = true,
isPaused = false,
currentItemId = request.itemId,
message = "Downloading",
settingsJson = settingsJson
)
}
delay(1000)
}
}
@@ -670,6 +937,11 @@ class DownloadService : Service() {
}
progressJob.cancel()
progressJob = null
if (generation != nativeWorkerGeneration) {
// Superseded while blocked in the download call; the
// new run owns the shared state now.
break
}
var result = JSONObject(response)
if (result.optBoolean("success", false)) {
currentStatus = "finalizing"
@@ -694,8 +966,8 @@ class DownloadService : Service() {
settingsJson
) {
nativeWorkerCancelRequested ||
nativeWorkerPaused ||
nativeWorkerJob?.isActive == false
isNativeWorkerPaused() ||
generation != nativeWorkerGeneration
}
}
if (result.optBoolean("success", false)) {
@@ -704,7 +976,6 @@ class DownloadService : Service() {
nativeReplayGainEntries.add(JSONObject(replayGain.toString()))
}
}
completed++
updateNativeWorkerItem(request.itemId) {
it.status = "completed"
it.progress = 1.0
@@ -715,7 +986,32 @@ class DownloadService : Service() {
writeNativeAlbumReplayGainIfComplete()
} else {
val errorType = result.optString("error_type")
if (errorType == "cancelled" && nativeWorkerPaused && !nativeWorkerCancelRequested) {
val errorMessage = result.optString("error")
if (errorType == "cancelled" &&
!isNativeWorkerPaused() &&
!nativeWorkerCancelRequested &&
generation == nativeWorkerGeneration
) {
// A pause from Dart cancels the in-flight Go
// download directly but delivers the pause flag
// via a startService intent through the main
// looper; the download can unwind first. Give the
// flag a moment to settle before classifying this
// cancellation as a permanent skip.
var waitedMs = 0L
while (waitedMs < 1500 &&
!isNativeWorkerPaused() &&
!nativeWorkerCancelRequested &&
generation == nativeWorkerGeneration
) {
delay(100)
waitedMs += 100
}
}
if (errorType == "cancelled" &&
isNativeWorkerPaused() &&
!nativeWorkerCancelRequested
) {
updateNativeWorkerItem(request.itemId) {
it.status = "queued"
it.progress = 0.0
@@ -733,15 +1029,73 @@ class DownloadService : Service() {
includeItems = true
)
retryCurrentRequest = true
} else if (NativeWorkerPolicy.shouldRetryRateLimit(
errorType = errorType,
errorMessage = errorMessage,
attempts = rateLimitAttempts[request.itemId] ?: 0,
)
) {
rateLimitAttempts[request.itemId] =
(rateLimitAttempts[request.itemId] ?: 0) + 1
val delaySeconds = NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = result
.optInt("retry_after_seconds", 0)
.takeIf { it > 0 },
errorMessage = errorMessage,
)
currentStatus = "rate_limited"
updateNativeWorkerItem(request.itemId) {
it.status = "queued"
it.progress = 0.0
it.bytesReceived = 0L
it.bytesTotal = 0L
it.error = "Rate limited, retrying in ${delaySeconds}s"
it.resultJson = null
}
writeNativeWorkerSnapshot(
isRunning = true,
isPaused = isNativeWorkerPaused(),
currentItemId = request.itemId,
message = "Rate limited, retrying in ${delaySeconds}s",
settingsJson = settingsJson,
includeItems = true,
)
updateNotification(0L, 0L)
delay(delaySeconds * 1000L)
retryCurrentRequest = true
} else if (NativeWorkerPolicy.isVerificationRequired(
errorType = errorType,
errorMessage = errorMessage,
)
) {
nativeWorkerVerificationPaused = true
currentStatus = "verification_required"
updateNativeWorkerItem(request.itemId) {
it.status = "failed"
it.error = errorMessage
it.resultJson = result
}
writeNativeReplayGainJournal()
writeNativeWorkerSnapshot(
isRunning = true,
isPaused = true,
currentItemId = request.itemId,
message = "Verification required",
lastResult = result,
settingsJson = settingsJson,
includeItems = true,
)
scheduleNativeVerificationNotification(generation)
updateNotification(0L, 0L)
retryCurrentRequest = true
} else {
failed++
updateNativeWorkerItem(request.itemId) {
it.status = if (errorType == "cancelled") {
"skipped"
} else {
"failed"
}
it.error = result.optString("error")
it.error = errorMessage
it.resultJson = result
}
writeNativeReplayGainJournal()
@@ -767,7 +1121,6 @@ class DownloadService : Service() {
}
throw e
} catch (e: Exception) {
failed++
updateNativeWorkerItem(request.itemId) {
it.status = "failed"
it.error = e.message ?: "Native download failed"
@@ -797,19 +1150,31 @@ class DownloadService : Service() {
}
}
} finally {
if (!nativeWorkerCancelRequested) {
flushNativeAlbumReplayGainJournalIfComplete()
if (generation == nativeWorkerGeneration) {
if (!nativeWorkerCancelRequested) {
flushNativeAlbumReplayGainJournalIfComplete()
}
val counts = nativeWorkerCounts()
val shouldNotifyCompletion =
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = nativeWorkerCancelRequested,
completed = counts.completed,
failed = counts.failed,
)
currentStatus = "finalizing"
writeNativeWorkerSnapshot(
isRunning = false,
isPaused = false,
currentItemId = "",
message = if (nativeWorkerCancelRequested) "Cancelled" else "Finished",
settingsJson = settingsJson,
includeItems = true
)
stopForegroundService(cancelNativeWorker = false)
if (shouldNotifyCompletion) {
showNativeQueueComplete(counts)
}
}
currentStatus = "finalizing"
writeNativeWorkerSnapshot(
isRunning = false,
isPaused = false,
currentItemId = "",
message = if (nativeWorkerCancelRequested) "Cancelled" else "Finished",
settingsJson = settingsJson,
includeItems = true
)
stopForegroundService(cancelNativeWorker = false)
}
}
@@ -1065,8 +1430,13 @@ class DownloadService : Service() {
.put("message", message)
.put("updated_at", System.currentTimeMillis())
.put("snapshot_serial", snapshotSerial)
.put("item_ids", nativeWorkerItemIds())
.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 {
@@ -1096,6 +1466,10 @@ class DownloadService : Service() {
stream = null
if (includeItems) {
latestCommittedStateSnapshotSerial = snapshotSerial
if (headerCandidate != null) {
lastStateHeaderJson = headerCandidate
lastStateHeaderSerial = snapshotSerial
}
} else {
latestCommittedProgressSnapshotSerial = snapshotSerial
}
@@ -1327,6 +1701,9 @@ class DownloadService : Service() {
NativeDownloadFinalizer.cancelActiveWork()
nativeWorkerJob?.cancel(CancellationException("Download service stopped"))
nativeWorkerPaused = false
nativeWorkerNetworkPaused = false
nativeWorkerVerificationPaused = false
cancelNativeVerificationNotification()
}
if (cancelNativeWorker && hasNativeWorkerState()) {
writeNativeWorkerSnapshot(
@@ -1337,6 +1714,9 @@ class DownloadService : Service() {
includeItems = true
)
}
unregisterNativeWorkerNetworkCallback()
nativeWorkerDownloadNetworkMode = "any"
nativeWorkerNetworkPaused = false
nativeWorkerJob = null
isRunning = false
releaseWakeLock()
@@ -1376,7 +1756,13 @@ class DownloadService : Service() {
"Downloading..."
}
val text = if (currentStatus == "finalizing") {
val text = if (currentStatus == "verification_required") {
"Open the app to complete verification"
} else if (currentStatus == "rate_limited") {
"Rate limited, retrying shortly..."
} else if (currentStatus == "waiting_wifi") {
"Waiting for Wi-Fi..."
} else if (currentStatus == "finalizing") {
if (currentArtistName.isNotEmpty()) currentArtistName else "Embedding metadata..."
} else if (currentStatus == "preparing" && total <= 0) {
"Preparing download..."
@@ -1414,8 +1800,102 @@ class DownloadService : Service() {
return builder.build()
}
private fun showNativeQueueComplete(counts: NativeWorkerCounts) {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val title = if (counts.failed > 0) {
"Downloads Finished (${counts.completed} done, ${counts.failed} failed)"
} else {
"All Downloads Complete"
}
val body = if (counts.failed > 0) {
"${counts.completed} downloaded, ${counts.failed} failed"
} else {
"${counts.completed} tracks downloaded successfully"
}
val builder = NotificationCompat.Builder(this, ALERT_CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_STATUS)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
builder.setDefaults(Notification.DEFAULT_SOUND or Notification.DEFAULT_VIBRATE)
}
try {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(DOWNLOAD_RESULT_NOTIFICATION_ID, builder.build())
} catch (e: SecurityException) {
android.util.Log.w(
"DownloadService",
"Completion notification permission denied: ${e.message}",
)
}
}
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() {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = NotificationCompat.Builder(this, ALERT_CHANNEL_ID)
.setContentTitle("Verification required")
.setContentText("Open the app to complete verification and resume downloads")
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_ERROR)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
builder.setDefaults(Notification.DEFAULT_SOUND or Notification.DEFAULT_VIBRATE)
}
try {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(VERIFICATION_REQUIRED_NOTIFICATION_ID, builder.build())
} catch (e: SecurityException) {
android.util.Log.w(
"DownloadService",
"Verification notification permission denied: ${e.message}",
)
}
}
private fun cancelNativeVerificationNotification() {
val manager = getSystemService(NotificationManager::class.java)
manager.cancel(VERIFICATION_REQUIRED_NOTIFICATION_ID)
}
override fun onDestroy() {
unregisterNativeWorkerNetworkCallback()
nativeWorkerCancelRequested = true
NativeDownloadFinalizer.cancelActiveWork()
nativeWorkerJob?.cancel(CancellationException("Download service destroyed"))
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,8 @@ 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 gobackend.Gobackend
import org.json.JSONObject
import java.io.File
@@ -24,13 +26,14 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.pow
import kotlin.math.roundToInt
object NativeDownloadFinalizer {
private const val TAG = "NativeFinalizer"
const val NATIVE_WORKER_CONTRACT_VERSION = 1
// Native finalizer owns background-safe history writes while Flutter may be suspended.
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
private const val HISTORY_SCHEMA_VERSION = 9
const val HISTORY_SCHEMA_VERSION = 10
private val activeFFmpegSessionIds = mutableSetOf<Long>()
private val nativeFFmpegSessionIds = mutableSetOf<Long>()
private val activeFFmpegSessionLock = Any()
@@ -82,6 +85,8 @@ object NativeDownloadFinalizer {
"spotify_id_norm",
"isrc_norm",
"match_key",
"album_key",
"search_text",
)
private val androidStoragePathAliases = listOf(
"/storage/emulated/0",
@@ -185,6 +190,10 @@ object NativeDownloadFinalizer {
),
)
// Once the output has been published to its final destination the audio
// is complete; failures past that point are bookkeeping and must never
// trigger the destructive cleanup below.
var outputPublished = result.optBoolean("already_exists", false)
try {
var qualityMetadataRefreshed = false
if (!result.optBoolean("already_exists", false)) {
@@ -198,30 +207,66 @@ object NativeDownloadFinalizer {
checkCancelled(shouldCancel)
finalizeMetadata(context, effectiveInput, state)
checkCancelled(shouldCancel)
writeExternalLrc(context, effectiveInput, state)
checkCancelled(shouldCancel)
runPostProcessing(context, effectiveInput, state, shouldCancel)
checkCancelled(shouldCancel)
val replayGain = writeReplayGain(context, effectiveInput, state, shouldCancel)
if (replayGain != null) result.put("replaygain", replayGain)
checkCancelled(shouldCancel)
if (isDeferredSafPublish(effectiveInput)) {
try {
refreshFinalAudioQualityMetadata(context, result, state)
qualityMetadataRefreshed = true
} catch (e: Exception) {
android.util.Log.w(TAG, "Quality metadata refresh failed (non-fatal): ${e.message}")
}
qualityMetadataRefreshed = true
try {
finalizeQualityVariantFilename(context, effectiveInput, state)
} catch (e: Exception) {
android.util.Log.w(TAG, "Quality variant rename failed (non-fatal): ${e.message}")
}
checkCancelled(shouldCancel)
try {
writeExternalLrc(context, effectiveInput, state)
} catch (e: Exception) {
android.util.Log.w(TAG, "External LRC write failed (non-fatal): ${e.message}")
}
checkCancelled(shouldCancel)
if (isDeferredSafPublish(effectiveInput)) {
publishDeferredSafOutput(context, effectiveInput, state)
} else {
promoteStagedSafOutputIfNeeded(context, effectiveInput, state)
}
outputPublished = true
}
checkCancelled(shouldCancel)
if (!qualityMetadataRefreshed) {
refreshFinalAudioQualityMetadata(context, result, state)
try {
refreshFinalAudioQualityMetadata(context, result, state)
} catch (e: Exception) {
android.util.Log.w(TAG, "Quality metadata refresh failed (non-fatal): ${e.message}")
}
}
val saveDownloadHistory = parseObject(settingsJson)
.optBoolean("save_download_history", true)
val history = if (saveDownloadHistory) {
buildHistoryRow(effectiveInput, state).also { upsertHistory(context, it) }
val preserveQualityVariant = input.request
.optBoolean("allow_quality_variant", false)
val history = if (
saveDownloadHistory &&
!(preserveQualityVariant && result.optBoolean("already_exists", false))
) {
try {
buildHistoryRow(effectiveInput, state).also {
upsertHistory(
context,
it,
deduplicateTrack = !preserveQualityVariant,
)
}
} catch (e: Exception) {
// History is bookkeeping; never fail (and never delete) a
// finished download because the insert failed.
android.util.Log.w(TAG, "History write failed (non-fatal): ${e.message}")
null
}
} else {
null
}
@@ -233,13 +278,17 @@ object NativeDownloadFinalizer {
result.put("history_written", history != null)
if (history != null) result.put("history_item", historyToJson(history))
} catch (e: CancellationException) {
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
if (!outputPublished) {
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
}
result.put("success", false)
result.put("error", "Native finalization cancelled")
result.put("error_type", "cancelled")
result.put("native_finalized", false)
} catch (e: Exception) {
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
if (!outputPublished) {
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
}
result.put("success", false)
result.put("error", "Native finalization failed: ${e.message}")
result.put("error_type", "unknown")
@@ -334,7 +383,6 @@ object NativeDownloadFinalizer {
}
private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) {
// Kept as a narrow hook for future richer progress snapshots.
}
private fun cleanupFailedFinalizationOutput(
@@ -422,16 +470,20 @@ object NativeDownloadFinalizer {
try {
for (candidate in decryptionKeyCandidates(key)) {
checkCancelled(shouldCancel)
val attempts = mutableListOf<Pair<String, Boolean>>()
attempts.add(outputPath to (preferredExt == ".flac"))
val attempts = mutableListOf<Triple<String, Boolean, Boolean>>()
attempts.add(Triple(outputPath, preferredExt == ".flac", false))
if (preferredExt == ".flac") {
attempts.add(buildOutputPath(localInput, ".m4a") to false)
attempts.add(Triple(buildOutputPath(localInput, ".m4a"), false, false))
}
if (preferredExt == ".flac" || preferredExt == ".m4a") {
attempts.add(buildOutputPath(localInput, ".mp4") to false)
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, false))
}
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4):
// keeps the .mp4 filename but stores the codec params.
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, true))
for ((candidateOutput, mapAudioOnly) in attempts) {
for ((candidateOutput, mapAudioOnly, forceMov) in attempts) {
val stagedOutput = stagedConversionPath(candidateOutput)
try {
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
// Force the flac muxer when the target extension is
@@ -439,21 +491,27 @@ object NativeDownloadFinalizer {
// stream layout, producing FLAC-in-MP4 under a .flac
// filename which downstream native FLAC tag writers
// cannot read.
val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else ""
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
val muxerOverride = when {
forceMov -> "-f mov "
candidateOutput.lowercase(Locale.ROOT).endsWith(".flac") -> "-f flac "
else -> ""
}
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(stagedOutput)} -y"
val result = runFFmpeg(command, shouldCancel)
lastOutput = result.second
if (result.first && File(candidateOutput).exists()) {
if (result.first && File(stagedOutput).exists() &&
promoteStagedConversion(stagedOutput, candidateOutput)
) {
successPath = candidateOutput
outputPath = candidateOutput
break
}
File(candidateOutput).delete()
File(stagedOutput).delete()
} catch (e: CancellationException) {
File(candidateOutput).delete()
File(stagedOutput).delete()
throw e
} catch (e: Exception) {
File(candidateOutput).delete()
File(stagedOutput).delete()
throw e
}
}
@@ -502,24 +560,31 @@ object NativeDownloadFinalizer {
val localInput = materializeForFFmpeg(context, input, state)
val deleteLocalInput = state.filePath.startsWith("content://")
val output = buildOutputPath(localInput, ext)
val stagedOutput = stagedConversionPath(output)
var adoptedOutput = false
try {
val command = if (format == "opus") {
"-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(output)} -y"
"-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(stagedOutput)} -y"
} else if (format == "aac") {
"-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(output)} -y"
"-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(stagedOutput)} -y"
} else {
"-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(output)} -y"
"-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(stagedOutput)} -y"
}
val result = runFFmpeg(command, shouldCancel)
if (!result.first || !File(output).exists()) {
if (!result.first || !File(stagedOutput).exists()) {
throw IllegalStateException("HIGH conversion failed: ${result.second}")
}
if (!promoteStagedConversion(stagedOutput, output)) {
throw IllegalStateException("failed to publish HIGH conversion output")
}
embedBasicMetadata(context, output, input, metadataFormat)
replaceStatePath(context, input, state, output, deleteOld = true)
adoptedOutput = true
} finally {
if (!adoptedOutput) File(output).delete()
if (!adoptedOutput) {
File(stagedOutput).delete()
File(output).delete()
}
if (deleteLocalInput) File(localInput).delete()
}
state.quality = "$displayFormat ${bitrate.removeSuffix("k")}kbps"
@@ -545,12 +610,19 @@ object NativeDownloadFinalizer {
val localInput = materializeForFFmpeg(context, input, state)
val deleteLocalInput = state.filePath.startsWith("content://")
val output = buildOutputPath(localInput, ".flac")
val stagedOutput = stagedConversionPath(output)
var adoptedOutput = false
try {
val codec = probePrimaryAudioCodec(localInput, shouldCancel)
val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput)
if (!isLosslessAudioCodec(codec)) {
Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}")
// The preserved stream is not FLAC but still carries the
// requested .flac name. Rename to the real container so the
// metadata/ReplayGain writers pick the right format — an
// MP4 stream under a .flac name fails "fLaC head incorrect"
// on every subsequent write and the file is never repaired.
adoptPreservedContainerExtension(state, localInput, codec)
return
}
if (isAlreadyNativeFlac) {
@@ -558,7 +630,11 @@ object NativeDownloadFinalizer {
val nativeFlacOutput = if (localInput.lowercase(Locale.ROOT).endsWith(".flac")) {
localInput
} else {
File(localInput).copyTo(File(output), overwrite = true).absolutePath
File(localInput).copyTo(File(stagedOutput), overwrite = true)
if (!promoteStagedConversion(stagedOutput, output)) {
throw IllegalStateException("failed to publish native FLAC output")
}
output
}
embedBasicMetadata(context, nativeFlacOutput, input, "flac")
replaceStatePath(context, input, state, nativeFlacOutput, deleteOld = true)
@@ -566,21 +642,84 @@ object NativeDownloadFinalizer {
return
}
val result = runFFmpeg(
"-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(output)} -y",
"-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(stagedOutput)} -y",
shouldCancel,
)
if (!result.first || !File(output).exists()) {
if (!result.first || !File(stagedOutput).exists()) {
throw IllegalStateException("container conversion failed: ${result.second}")
}
if (!promoteStagedConversion(stagedOutput, output)) {
throw IllegalStateException("failed to publish container conversion output")
}
embedBasicMetadata(context, output, input, "flac")
replaceStatePath(context, input, state, output, deleteOld = true)
adoptedOutput = true
} finally {
if (!adoptedOutput) File(output).delete()
if (!adoptedOutput) {
File(stagedOutput).delete()
File(output).delete()
}
if (deleteLocalInput) File(localInput).delete()
}
}
/// Renames a preserved lossy/unknown stream away from its requested .flac
/// name to match its actual container (mirrors the Dart pipeline's
/// post-download rename). Local files only: legacy content:// outputs are
/// left untouched. No-op when the container cannot be identified.
private fun adoptPreservedContainerExtension(
state: FinalizeState,
localInput: String,
codec: String,
) {
if (state.filePath.startsWith("content://")) return
val currentFile = File(state.filePath)
if (!currentFile.exists()) return
if (!currentFile.name.lowercase(Locale.ROOT).endsWith(".flac")) return
val newExt = when {
codec == "aac" && isMP4ContainerFile(localInput) -> ".m4a"
codec == "mp3" -> ".mp3"
codec == "opus" -> ".opus"
isMP4ContainerFile(localInput) -> ".m4a"
else -> return
}
val renamed = File(
currentFile.parentFile,
currentFile.name.dropLast(".flac".length) + newExt,
)
if (renamed.exists() && !renamed.delete()) {
Log.w(TAG, "Cannot adopt container extension; ${renamed.name} already exists")
return
}
if (!currentFile.renameTo(renamed)) {
Log.w(TAG, "Failed to rename preserved container to ${renamed.name}")
return
}
Log.i(TAG, "Preserved container renamed: ${currentFile.name} -> ${renamed.name}")
state.filePath = renamed.absolutePath
if (state.fileName.isNotBlank()) {
state.fileName = renamed.name
}
state.audioCodec = normalizeAudioCodec(codec)
}
private fun 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
}
}
private fun finalizeMetadata(context: Context, input: FinalizeInput, state: FinalizeState) {
if (!input.request.optBoolean("embed_metadata", false)) return
if (!state.filePath.startsWith("content://")) {
@@ -668,6 +807,7 @@ object NativeDownloadFinalizer {
val uri = Uri.parse(path)
context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
File(tempPath).inputStream().use { input -> input.copyTo(output) }
SafDownloadHandler.syncOutputStream(output)
} ?: throw IllegalStateException("failed to write ReplayGain back to SAF")
} finally {
File(tempPath).delete()
@@ -788,6 +928,129 @@ object NativeDownloadFinalizer {
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
}
private fun qualityVariantFilenameLabel(state: FinalizeState): String? {
val measuredQuality = state.quality
if (isLossyAudioCodec(state.audioCodec)) {
val bitrate = state.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 bitDepth = state.bitDepth
var sampleRate = state.sampleRate
if (bitDepth == null || sampleRate == null) {
val match = Regex(
"\\b(\\d+)\\s*(?:-|\\s)?bit\\s*[/_-]\\s*(\\d+(?:\\.\\d+)?)\\s*k?hz\\b",
RegexOption.IGNORE_CASE,
).find(measuredQuality)
bitDepth = bitDepth ?: match?.groupValues?.getOrNull(1)?.toIntOrNull()
sampleRate = sampleRate ?: match?.groupValues?.getOrNull(2)?.toDoubleOrNull()?.let { rate ->
if (rate < 1000) (rate * 1000).roundToInt() else rate.roundToInt()
}
}
if (bitDepth == null || bitDepth <= 0 || sampleRate == null || sampleRate <= 0) return null
val khz = sampleRate / 1000.0
val precision = if (sampleRate % 1000 == 0) 0 else 1
val sampleRateLabel = "%.${precision}f".format(Locale.US, khz)
return "${bitDepth}bit-${sampleRateLabel}kHz"
}
private fun finalizeQualityVariantFilename(
context: Context,
input: FinalizeInput,
state: 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 preferredName = applyQualityVariantFilenameLabel(
fileName = state.fileName,
stagingLabel = stagingLabel,
qualityLabel = qualityLabel,
)
if (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 writeResult = SafDownloadHandler.writeFileToSafUnique(
context = context,
treeUriStr = input.request.optString("saf_tree_uri", ""),
relativeDir = input.request.optString("saf_relative_dir", ""),
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 target = uniqueLocalFile(source.parentFile, preferredName)
if (!source.renameTo(target)) {
Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}")
return
}
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)
}
}
private 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"
}
private fun 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
}
private fun audioFormatForCodec(codec: String?): String? {
return when (normalizeAudioCodec(codec)) {
"flac" -> "FLAC"
@@ -1094,39 +1357,55 @@ object NativeDownloadFinalizer {
val shouldEmbedLyrics = shouldResolveLyrics &&
lyrics.isNotBlank() &&
lyrics != "[instrumental:true]"
if (format == "flac") {
val coverFile = downloadCoverForMetadata(context, input)
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 (coverFile != null) fields.put("cover_path", coverFile.absolutePath)
if (shouldEmbedLyrics) {
fields.put("lyrics", lyrics)
fields.put("unsyncedlyrics", lyrics)
}
try {
Gobackend.editFileMetadata(path, fields.toString())
// 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 {
coverFile?.delete()
nativeCover?.delete()
}
return
if (handledNatively) return
}
val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" }
val inputFile = File(path)
val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged$ext")
// ".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
@@ -1159,20 +1438,34 @@ object NativeDownloadFinalizer {
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
var adoptedTemp = false
var originalDeleted = false
try {
val command = if (isM4a && coverFile != null) {
fun buildEmbedCommand(forceMov: Boolean): String {
return if (isM4a && coverFile != null) {
"-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " +
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
"-disposition:v:0 attached_pic " +
"-metadata:s:v ${q("title=Album cover")} " +
"-metadata:s:v ${q("comment=Cover (front)")} " +
"$metadataArgs -f mp4 ${q(temp.absolutePath)} -y"
"$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
} else {
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags${q(temp.absolutePath)} -y"
val movFlag = if (forceMov) "-f mov " else ""
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y"
}
}
try {
var result = runFFmpeg(buildEmbedCommand(false))
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4).
if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) {
temp.delete()
result = runFFmpeg(buildEmbedCommand(true))
}
val result = runFFmpeg(command)
if (result.first && temp.exists()) {
if (inputFile.delete()) {
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)
}
@@ -1185,6 +1478,18 @@ object NativeDownloadFinalizer {
}
}
/**
* 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.
*/
private fun fsyncQuietly(file: File) {
try {
RandomAccessFile(file, "rw").use { it.fd.sync() }
} catch (_: Exception) {
}
}
private fun createMetadataBlockPicture(coverFile: File): String? {
return try {
if (!coverFile.exists() || coverFile.length() <= 0L) return null
@@ -1357,7 +1662,6 @@ object NativeDownloadFinalizer {
val handled = mutableSetOf<String>()
val pump = Thread {
while (running.get()) {
if (shouldCancel()) return@Thread
try {
val raw = Gobackend.getAllPendingFFmpegCommandsJSON()
val commands = org.json.JSONArray(raw)
@@ -1369,20 +1673,39 @@ object NativeDownloadFinalizer {
continue
}
handled.add(id)
val result = runFFmpeg(commandLine, shouldCancel)
Gobackend.setFFmpegCommandResultByID(
id,
result.first,
result.second,
if (result.first) "" else result.second,
)
// 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) {
return@Thread
// 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.
}
}
}
@@ -1396,6 +1719,28 @@ object NativeDownloadFinalizer {
}
}
/**
* 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.
*/
private fun 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
}
private fun 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)
}
private fun buildOutputPath(inputPath: String, extension: String): String {
val ext = normalizeExt(extension).ifBlank { ".tmp" }
val file = File(inputPath)
@@ -1407,7 +1752,8 @@ object NativeDownloadFinalizer {
private fun desiredFileName(input: FinalizeInput, state: FinalizeState, extension: String): String {
val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } }
val rawName = input.request.optString("saf_file_name", "")
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")
@@ -1557,24 +1903,46 @@ object NativeDownloadFinalizer {
val relativeDir = input.result.optString("saf_relative_dir", "")
.ifBlank { input.request.optString("saf_relative_dir", "") }
val mimeType = mimeTypeForExt(outputFile.extension)
val newUri = SafDownloadHandler.writeFileToSaf(
context = context,
treeUriStr = treeUri,
relativeDir = relativeDir,
fileName = finalName,
mimeType = mimeType,
srcPath = outputFile.absolutePath,
) ?: throw IllegalStateException("failed to publish deferred SAF output")
val preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false)
val uniqueWrite = if (preserveQualityVariant) {
SafDownloadHandler.writeFileToSafUnique(
context = context,
treeUriStr = treeUri,
relativeDir = relativeDir,
fileName = finalName,
mimeType = mimeType,
srcPath = outputFile.absolutePath,
preservedSuffix = qualityVariantFilenameLabel(state).orEmpty(),
)
} else {
null
}
val newUri = uniqueWrite?.uri ?: if (!preserveQualityVariant) {
SafDownloadHandler.writeFileToSaf(
context = context,
treeUriStr = treeUri,
relativeDir = relativeDir,
fileName = finalName,
mimeType = mimeType,
srcPath = outputFile.absolutePath,
)
} else {
null
} ?: throw IllegalStateException("failed to publish deferred SAF output")
val publishedName = uniqueWrite?.fileName ?: finalName
Log.i(TAG, "Published deferred SAF output once: file=$finalName bytes=${outputFile.length()}")
Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}")
outputFile.delete()
state.filePath = newUri
state.fileName = finalName
state.fileName = publishedName
input.result.put("file_path", newUri)
input.result.put("file_name", finalName)
input.result.put("file_name", publishedName)
input.result.optJSONObject("replaygain")?.let { replayGain ->
replayGain.put("file_path", newUri)
replayGain.put("file_name", finalName)
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)
@@ -1716,7 +2084,11 @@ object NativeDownloadFinalizer {
return values
}
private fun upsertHistory(context: Context, values: ContentValues) {
private fun upsertHistory(
context: Context,
values: ContentValues,
deduplicateTrack: Boolean = true,
) {
val dbFile = File(File(context.applicationInfo.dataDir, "app_flutter"), "history.db")
dbFile.parentFile?.mkdirs()
val db = SQLiteDatabase.openDatabase(
@@ -1769,7 +2141,12 @@ object NativeDownloadFinalizer {
genre TEXT,
composer TEXT,
label TEXT,
copyright TEXT
copyright TEXT,
spotify_id_norm TEXT,
isrc_norm TEXT,
match_key TEXT,
album_key TEXT,
search_text TEXT
)
""".trimIndent()
)
@@ -1786,6 +2163,8 @@ object NativeDownloadFinalizer {
ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT")
ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT")
ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT")
ensureHistoryColumn(db, "album_key", "ALTER TABLE history ADD COLUMN album_key TEXT")
ensureHistoryColumn(db, "search_text", "ALTER TABLE history ADD COLUMN search_text TEXT")
ensureHistoryPathKeyTable(db)
if (needsBackfill) {
backfillNormalizedHistoryColumns(db)
@@ -1800,8 +2179,9 @@ object NativeDownloadFinalizer {
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_isrc_norm ON history(isrc_norm)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_album_key ON history(album_key)")
if (db.version < HISTORY_SCHEMA_VERSION) db.version = HISTORY_SCHEMA_VERSION
deleteDuplicateHistoryRows(db, values)
if (deduplicateTrack) deleteDuplicateHistoryRows(db, values)
db.insertWithOnConflict("history", null, values, SQLiteDatabase.CONFLICT_REPLACE)
replaceHistoryPathKeys(db, values.getAsString("id"), values.getAsString("file_path"))
db.setTransactionSuccessful()
@@ -1926,8 +2306,8 @@ object NativeDownloadFinalizer {
private fun backfillNormalizedHistoryColumns(db: SQLiteDatabase) {
db.query(
"history",
arrayOf("id", "spotify_id", "isrc", "track_name", "artist_name"),
"spotify_id_norm IS NULL OR isrc_norm IS NULL OR match_key IS NULL",
arrayOf("id", "spotify_id", "isrc", "track_name", "artist_name", "album_name", "album_artist"),
"spotify_id_norm IS NULL OR isrc_norm IS NULL OR match_key IS NULL OR album_key IS NULL OR search_text IS NULL",
null,
null,
null,
@@ -1938,6 +2318,8 @@ object NativeDownloadFinalizer {
val isrcIndex = cursor.getColumnIndex("isrc")
val trackIndex = cursor.getColumnIndex("track_name")
val artistIndex = cursor.getColumnIndex("artist_name")
val albumIndex = cursor.getColumnIndex("album_name")
val albumArtistIndex = cursor.getColumnIndex("album_artist")
while (cursor.moveToNext()) {
if (idIndex < 0) continue
val values = ContentValues()
@@ -1945,9 +2327,18 @@ object NativeDownloadFinalizer {
val isrc = cursor.getNullableString(isrcIndex)
val trackName = cursor.getNullableString(trackIndex)
val artistName = cursor.getNullableString(artistIndex)
val albumName = cursor.getNullableString(albumIndex)
val albumArtist = cursor.getNullableString(albumArtistIndex)
values.put("spotify_id_norm", normalizeSpotifyId(spotifyId))
values.put("isrc_norm", normalizeIsrc(isrc))
values.put("match_key", matchKeyFor(trackName, artistName))
putAlbumSearchHistoryColumns(
values,
trackName = trackName,
artistName = artistName,
albumName = albumName,
albumArtist = albumArtist,
)
db.update("history", values, "id = ?", arrayOf(cursor.getString(idIndex)))
}
}
@@ -1984,6 +2375,35 @@ object NativeDownloadFinalizer {
"match_key",
matchKeyFor(values.getAsString("track_name"), values.getAsString("artist_name")),
)
putAlbumSearchHistoryColumns(
values,
trackName = values.getAsString("track_name"),
artistName = values.getAsString("artist_name"),
albumName = values.getAsString("album_name"),
albumArtist = values.getAsString("album_artist"),
)
}
private fun putAlbumSearchHistoryColumns(
values: ContentValues,
trackName: String?,
artistName: String?,
albumName: String?,
albumArtist: String?,
) {
val track = normalizeLookupText(trackName)
val artist = normalizeLookupText(artistName)
val album = normalizeLookupText(albumName)
val resolvedAlbumArtist = normalizeLookupText(
albumArtist?.takeIf { it.trim().isNotEmpty() } ?: artistName,
)
values.put("album_key", "$album|$resolvedAlbumArtist")
values.put(
"search_text",
listOf(track, artist, album, resolvedAlbumArtist)
.filter { it.isNotEmpty() }
.joinToString(" "),
)
}
private fun normalizeLookupText(value: String?): String =
@@ -2210,22 +2630,5 @@ object NativeDownloadFinalizer {
return if (trimmed.equals("null", ignoreCase = true)) "" else trimmed
}
private fun normalizeExt(ext: String?): String {
val trimmed = ext?.trim().orEmpty()
if (trimmed.isEmpty()) return ""
return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}"
}
private fun mimeTypeForExt(ext: String?): String {
return when (normalizeExt(ext)) {
".m4a", ".mp4" -> "audio/mp4"
".mp3" -> "audio/mpeg"
".opus", ".ogg" -> "audio/ogg"
".flac" -> "audio/flac"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
}
private fun q(value: String): String = "\"${value.replace("\"", "\\\"")}\""
}
@@ -0,0 +1,71 @@
package com.zarz.spotiflac
internal object NativeWorkerPolicy {
const val MAX_RATE_LIMIT_RETRIES = 1
private const val DEFAULT_RATE_LIMIT_DELAY_SECONDS = 30
private const val MIN_RATE_LIMIT_DELAY_SECONDS = 5
private const val MAX_RATE_LIMIT_DELAY_SECONDS = 300
private val retryAfterPattern = Regex(
"""retry[- ]?after(?: seconds)?[:= ]+(\d+)""",
RegexOption.IGNORE_CASE,
)
fun shouldRetryRateLimit(
errorType: String?,
errorMessage: String?,
attempts: Int,
): Boolean {
if (attempts >= MAX_RATE_LIMIT_RETRIES) return false
if (errorType.equals("rate_limit", ignoreCase = true)) return true
val message = errorMessage.orEmpty()
return message.contains("429") ||
message.contains("rate limit", ignoreCase = true) ||
message.contains("too many requests", ignoreCase = true)
}
fun rateLimitDelaySeconds(
retryAfterSeconds: Int?,
errorMessage: String?,
): Int {
val parsedFromMessage = retryAfterPattern
.find(errorMessage.orEmpty())
?.groupValues
?.getOrNull(1)
?.toIntOrNull()
return (retryAfterSeconds?.takeIf { it > 0 }
?: parsedFromMessage
?: DEFAULT_RATE_LIMIT_DELAY_SECONDS)
.coerceIn(
MIN_RATE_LIMIT_DELAY_SECONDS,
MAX_RATE_LIMIT_DELAY_SECONDS,
)
}
fun isVerificationRequired(
errorType: String?,
errorMessage: String?,
): Boolean {
if (errorType.equals("verification_required", ignoreCase = true)) {
return true
}
val message = errorMessage.orEmpty()
return message.contains("verification required", ignoreCase = true) ||
message.contains("challenge required", ignoreCase = true)
}
fun requiresWifi(downloadNetworkMode: String?): Boolean =
downloadNetworkMode.equals("wifi_only", ignoreCase = true)
fun shouldPauseForNetwork(
downloadNetworkMode: String?,
hasWifi: Boolean,
): Boolean = requiresWifi(downloadNetworkMode) && !hasWifi
fun shouldNotifyQueueComplete(
cancelRequested: Boolean,
completed: Int,
failed: Int,
): Boolean = !cancelRequested && completed + failed > 0
}
@@ -6,6 +6,8 @@ import android.util.Log
import androidx.documentfile.provider.DocumentFile
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.util.Locale
/**
@@ -17,6 +19,39 @@ object SafDownloadHandler {
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
// Serializes the exists-check/create/write/publish sequence per target
// file so concurrent downloads that sanitize to the same display name
// cannot interleave writes into one document. Different names keep
// downloading in parallel; the second same-name caller blocks, then hits
// the exists check and reports already_exists.
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
data class UniqueWriteResult(val uri: String, val fileName: String)
private fun <T> withSafNameLock(
treeUriStr: String,
relativeDir: String,
fileName: String,
block: () -> T
): T {
val key = "$treeUriStr|$relativeDir|${fileName.lowercase(Locale.ROOT)}"
val lock = safNameLocks.computeIfAbsent(key) { Any() }
return synchronized(lock) { block() }
}
/**
* Flushes and fsyncs a SAF output stream so the copied bytes are durable
* before the staged document is renamed to its final name; without this a
* power loss right after the rename can leave a truncated "complete" file.
*/
fun syncOutputStream(output: OutputStream) {
try {
output.flush()
(output as? FileOutputStream)?.fd?.sync()
} catch (_: Exception) {
}
}
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
val req = JSONObject(requestJson)
val storageMode = req.optString("storage_mode", "")
@@ -25,13 +60,34 @@ object SafDownloadHandler {
return downloader(requestJson)
}
val treeUri = Uri.parse(treeUriStr)
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt)
return withSafNameLock(treeUriStr, relativeDir, fileName) {
handleSafLocked(context, req, downloader, treeUriStr, relativeDir, outputExt, fileName)
}
}
private fun handleSafLocked(
context: Context,
req: JSONObject,
downloader: (String) -> String,
treeUriStr: String,
relativeDir: String,
outputExt: String,
fileName: String
): String {
val treeUri = Uri.parse(treeUriStr)
val mimeType = mimeTypeForExt(outputExt)
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
// Downloads are always written under a staged ".partial" name so a
// killed process can never leave a half-written file under the final
// name (which the exists check would then accept as complete forever).
// Callers that set stage_saf_output own the promotion to the final
// name (the native finalizer); for everyone else — the foreground
// Dart queue — this handler promotes right after a successful write.
val finalizerPromotesStaged = req.optBoolean("stage_saf_output", false) && !deferSafPublish
val useStagedOutput = !deferSafPublish
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
@@ -39,9 +95,7 @@ object SafDownloadHandler {
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
if (useStagedOutput || deferSafPublish) {
deleteStaleStagedFiles(existingDir, fileName, outputExt)
}
deleteStaleStagedFiles(existingDir, fileName, outputExt)
val obj = JSONObject()
obj.put("success", true)
obj.put("message", "File already exists")
@@ -90,6 +144,11 @@ object SafDownloadHandler {
}
}
// Remove any stale partial from a previous killed attempt before
// creating the staged document: reusing it would let a shorter new
// write leave the old tail bytes in place (fd truncation is
// best-effort on some providers).
deleteStaleStagedFiles(targetDir, fileName, outputExt)
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
?: return errorJson("Failed to create SAF file")
@@ -105,6 +164,7 @@ object SafDownloadHandler {
val response = downloader(req.toString())
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
var finalFileName = fileName
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
@@ -138,11 +198,13 @@ object SafDownloadHandler {
document.delete()
document = replacement
}
finalFileName = actualFileName
}
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
syncOutputStream(output)
} ?: throw IllegalStateException("failed to open SAF output stream")
srcFile.delete()
} catch (e: Exception) {
@@ -156,9 +218,19 @@ object SafDownloadHandler {
}
respObj.put("file_path", document.uri.toString())
respObj.put("file_name", document.name ?: fileName)
if (useStagedOutput) {
if (finalizerPromotesStaged) {
respObj.put("saf_staged_output", true)
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
} else if (useStagedOutput) {
// Legacy caller (foreground Dart queue): publish here by
// renaming the staged file to its final name.
val published = replaceFinalDocument(targetDir, document, finalFileName)
if (published == null) {
document.delete()
return errorJson("Failed to publish SAF download")
}
respObj.put("file_path", published.uri.toString())
respObj.put("file_name", published.name ?: finalFileName)
}
} else {
document.delete()
@@ -177,6 +249,43 @@ object SafDownloadHandler {
}
}
/**
* Swaps [document] into place under [finalName] without a window where the
* previous file is gone but the new one is not yet in place. Any existing
* file is renamed aside first and only deleted once the staged file holds
* the final name; a failed swap restores it. Returns the published
* document, or null when the swap failed (the caller owns [document]).
*/
private fun replaceFinalDocument(
targetDir: DocumentFile,
document: DocumentFile,
finalName: String
): DocumentFile? {
val existingFinal = targetDir.findFile(finalName)
var aside: DocumentFile? = null
if (existingFinal != null && existingFinal.uri != document.uri) {
val asideName = buildReplacedSafFileName(finalName)
try {
targetDir.findFile(asideName)?.delete()
} catch (_: Exception) {
}
if (!existingFinal.renameTo(asideName)) {
return null
}
aside = targetDir.findFile(asideName) ?: existingFinal
}
if (!document.renameTo(finalName)) {
aside?.renameTo(finalName)
return null
}
aside?.delete()
return targetDir.findFile(finalName) ?: document
}
private fun buildReplacedSafFileName(fileName: String): String {
return "${sanitizeFilename(fileName)}.replaced"
}
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
return try {
val uri = Uri.parse(uriStr)
@@ -206,12 +315,124 @@ object SafDownloadHandler {
fileName: String,
mimeType: String,
srcPath: String
): String? {
val finalName = sanitizeFilename(fileName)
return withSafNameLock(treeUriStr, sanitizeRelativeDir(relativeDir), finalName) {
writeFileToSafLocked(context, treeUriStr, relativeDir, finalName, srcPath)
}
}
fun writeFileToSafUnique(
context: Context,
treeUriStr: String,
relativeDir: String,
fileName: String,
mimeType: String,
srcPath: String,
preservedSuffix: String = "",
): UniqueWriteResult? {
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
val preferredName = sanitizeFilenamePreservingSuffix(fileName, preservedSuffix)
return withSafNameLock(treeUriStr, safeRelativeDir, preferredName) {
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) ?: return@withSafNameLock null
val availableName = findAvailableFileName(
targetDir,
preferredName,
preservedSuffix,
)
val uri = writeFileToSafLocked(
context,
treeUriStr,
safeRelativeDir,
availableName,
srcPath,
) ?: return@withSafNameLock null
UniqueWriteResult(uri = uri, fileName = availableName)
}
}
private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
val sanitized = sanitizeFilename(fileName)
val trimmedSuffix = suffix.trim()
if (trimmedSuffix.isEmpty() || sanitized.contains(trimmedSuffix)) return sanitized
val dotIndex = fileName.lastIndexOf('.')
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
val rawStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
val rawPrefix = rawStem.replace(trimmedSuffix, "").trim(' ', '_', '-')
val safeSuffix = sanitizeFilename(trimmedSuffix)
val reserved = " - $safeSuffix$extension"
val prefixBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reserved.toByteArray(Charsets.UTF_8).size)
.coerceAtLeast(1)
val safePrefix = truncateUtf8Bytes(sanitizeFilename(rawPrefix), prefixBytes)
.trim()
.trim('.', ' ', '_', '-')
.ifBlank { "track" }
return "$safePrefix$reserved"
}
private fun findAvailableFileName(
parent: DocumentFile,
preferredName: String,
preservedSuffix: String,
): String {
if (parent.findFile(preferredName) == null) return preferredName
for (counter in 2..9999) {
val candidate = appendFilenameCounter(
preferredName,
counter.toLong(),
preservedSuffix,
)
if (parent.findFile(candidate) == null) return candidate
}
return appendFilenameCounter(
preferredName,
System.currentTimeMillis(),
preservedSuffix,
)
}
private fun appendFilenameCounter(
fileName: String,
counter: Long,
preservedSuffix: String,
): String {
val dotIndex = fileName.lastIndexOf('.')
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
val originalStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
val safePreservedSuffix = preservedSuffix.trim()
val hasPreservedSuffix = safePreservedSuffix.isNotEmpty() && originalStem.contains(safePreservedSuffix)
val stem = if (hasPreservedSuffix) {
originalStem.replace(safePreservedSuffix, "").trim(' ', '_', '-')
} else {
originalStem
}
val suffix = if (hasPreservedSuffix) {
" - $safePreservedSuffix ($counter)"
} else {
" ($counter)"
}
val reservedBytes = extension.toByteArray(Charsets.UTF_8).size +
suffix.toByteArray(Charsets.UTF_8).size
val maxStemBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reservedBytes).coerceAtLeast(1)
val safeStem = truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').ifBlank { "track" }
return "$safeStem$suffix$extension"
}
private fun writeFileToSafLocked(
context: Context,
treeUriStr: String,
relativeDir: String,
finalName: String,
srcPath: String
): String? {
var stagedDocument: DocumentFile? = null
return try {
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
val finalName = sanitizeFilename(fileName)
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
val stagedName = buildStagedSafFileName(finalName)
deleteStaleStagedFiles(targetDir, finalName, ext)
@@ -228,18 +449,16 @@ object SafDownloadHandler {
File(srcPath).inputStream().use { input ->
input.copyTo(output)
}
syncOutputStream(output)
}
val existingFinal = targetDir.findFile(finalName)
if (existingFinal != null && existingFinal.uri != document.uri) {
existingFinal.delete()
}
if (!document.renameTo(finalName)) {
val published = replaceFinalDocument(targetDir, document, finalName)
if (published == null) {
document.delete()
return null
}
stagedDocument = null
targetDir.findFile(finalName)?.uri?.toString() ?: document.uri.toString()
published.uri.toString()
} catch (e: Exception) {
stagedDocument?.delete()
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
@@ -255,21 +474,20 @@ object SafDownloadHandler {
}
}
private fun normalizeExt(ext: String?): String {
if (ext.isNullOrBlank()) return ""
return if (ext.startsWith(".")) {
ext.lowercase(Locale.ROOT)
} else {
".${ext.lowercase(Locale.ROOT)}"
}
internal fun normalizeExt(ext: String?): String {
val trimmed = ext?.trim().orEmpty()
if (trimmed.isEmpty()) return ""
return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}"
}
private fun mimeTypeForExt(ext: String?): String {
internal fun mimeTypeForExt(ext: String?): String {
return when (normalizeExt(ext)) {
".m4a", ".mp4" -> "audio/mp4"
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".opus", ".ogg" -> "audio/ogg"
".flac" -> "audio/flac"
".wav" -> "audio/wav"
".aiff", ".aif", ".aifc" -> "audio/aiff"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
@@ -313,7 +531,8 @@ object SafDownloadHandler {
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
val stagedNames = linkedSetOf(
buildStagedSafFileName(fileName),
buildLegacyStagedSafFileName(fileName, outputExt)
buildLegacyStagedSafFileName(fileName, outputExt),
buildReplacedSafFileName(fileName)
)
for (stagedName in stagedNames) {
try {
@@ -323,7 +542,7 @@ object SafDownloadHandler {
}
}
private fun sanitizeFilename(name: String): String {
internal fun sanitizeFilename(name: String): String {
var sanitized = name
.replace("/", " ")
.replace(Regex("[\\\\:*?\"<>|]"), " ")
@@ -382,7 +601,7 @@ object SafDownloadHandler {
return builder.toString()
}
private fun sanitizeRelativeDir(relativeDir: String): String {
internal fun sanitizeRelativeDir(relativeDir: String): String {
if (relativeDir.isBlank()) return ""
return relativeDir
.split("/")
@@ -391,7 +610,7 @@ object SafDownloadHandler {
.joinToString("/")
}
private fun ensureDocumentDir(
internal fun ensureDocumentDir(
context: Context,
treeUri: Uri,
relativeDir: String
@@ -423,7 +642,7 @@ object SafDownloadHandler {
}
}
private fun findDocumentDir(
internal fun findDocumentDir(
context: Context,
treeUri: Uri,
relativeDir: String
@@ -441,7 +660,7 @@ object SafDownloadHandler {
return current
}
private fun createOrReuseDocumentFile(
internal fun createOrReuseDocumentFile(
parent: DocumentFile,
mimeType: String,
fileName: String
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<!--
SpotiFLAC Mobile provides an explicit, schema-aware Backup & Restore
feature. Platform backup must not copy device-bound SAF grants, paths,
queues, extension credentials, or signed sessions to another install.
-->
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- Android 11 and lower use this legacy rule format. -->
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</full-backup-content>
@@ -0,0 +1,152 @@
package com.zarz.spotiflac
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeWorkerPolicyTest {
@Test
fun rateLimitRetriesExactlyOnce() {
assertTrue(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "rate_limit",
errorMessage = "Too many requests",
attempts = 0,
),
)
assertFalse(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "rate_limit",
errorMessage = "Too many requests",
attempts = 1,
),
)
}
@Test
fun rateLimitDetectionFallsBackToMessage() {
assertTrue(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "network",
errorMessage = "HTTP 429: retry later",
attempts = 0,
),
)
assertFalse(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "network",
errorMessage = "Connection reset",
attempts = 0,
),
)
}
@Test
fun retryDelayUsesServerHintAndSafeBounds() {
assertEquals(
45,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = 45,
errorMessage = null,
),
)
assertEquals(
12,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = null,
errorMessage = "Retry-After seconds: 12",
),
)
assertEquals(
5,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = 1,
errorMessage = null,
),
)
assertEquals(
300,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = 900,
errorMessage = null,
),
)
}
@Test
fun wifiOnlyPausesWithoutWifi() {
assertTrue(
NativeWorkerPolicy.shouldPauseForNetwork(
downloadNetworkMode = "wifi_only",
hasWifi = false,
),
)
assertFalse(
NativeWorkerPolicy.shouldPauseForNetwork(
downloadNetworkMode = "wifi_only",
hasWifi = true,
),
)
assertFalse(
NativeWorkerPolicy.shouldPauseForNetwork(
downloadNetworkMode = "any",
hasWifi = false,
),
)
}
@Test
fun verificationDetectionUsesTypeAndMessageFallback() {
assertTrue(
NativeWorkerPolicy.isVerificationRequired(
errorType = "verification_required",
errorMessage = null,
),
)
assertTrue(
NativeWorkerPolicy.isVerificationRequired(
errorType = "unknown",
errorMessage = "Challenge required before download",
),
)
assertFalse(
NativeWorkerPolicy.isVerificationRequired(
errorType = "network",
errorMessage = "Connection reset",
),
)
}
@Test
fun completionAlertSkipsCancelledOrEmptyRuns() {
assertTrue(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = false,
completed = 1,
failed = 0,
),
)
assertTrue(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = false,
completed = 0,
failed = 1,
),
)
assertFalse(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = true,
completed = 1,
failed = 0,
),
)
assertFalse(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = false,
completed = 0,
failed = 0,
),
)
}
}
+3 -3
View File
@@ -11,8 +11,8 @@ subprojects {
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}
// Enable multidex for all subprojects
@@ -27,7 +27,7 @@ subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-all.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
id("com.android.application") version "9.3.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.10" apply false
}
include(":app")
+4 -4
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.5.6",
"versionDate": "2026-06-01",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.6/SpotiFLAC-v4.5.6-ios-unsigned.ipa",
"version": "4.7.1",
"versionDate": "2026-07-01",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 34059797
"size": 37455821
}
]
}
+408
View File
@@ -0,0 +1,408 @@
package gobackend
import (
"encoding/binary"
"fmt"
"io"
"os"
)
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
type mp4Box struct {
offset int64
size int64
hdr int64
typ string
}
func (b mp4Box) body() int64 { return b.offset + b.hdr }
func (b mp4Box) end() int64 { return b.offset + b.size }
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
n := int64(len(data))
if pos < 0 || pos+8 > n {
return mp4Box{}, false
}
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
typ := string(data[pos+4 : pos+8])
hdr := int64(8)
switch size {
case 1:
if pos+16 > n {
return mp4Box{}, false
}
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
hdr = 16
case 0:
size = n - pos
}
if size < hdr || pos+size > n {
return mp4Box{}, false
}
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
}
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
pos := start
for pos+8 <= end {
b, ok := readMP4Box(data, pos)
if !ok {
return mp4Box{}, false
}
if b.typ == typ {
return b, true
}
pos = b.end()
}
return mp4Box{}, false
}
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
pos := start
for pos+8 <= end {
b, ok := readMP4Box(data, pos)
if !ok {
return
}
if b.typ == typ && !fn(b) {
return
}
pos = b.end()
}
}
// findBoxBySignature scans [start,end) for a box of the given type, matching the
// 4-byte type tag and validating the preceding size field. Used to locate dac4
// which may be nested inside an encrypted (enca) sample entry.
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
if len(typ) != 4 {
return mp4Box{}, false
}
for i := start; i+8 <= end; i++ {
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
return b, true
}
}
}
return mp4Box{}, false
}
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
// entry header (from the box body start) before child boxes begin. ok is false
// for malformed/truncated entries whose declared header is not fully present.
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
base := entry.body()
if base+10 > entry.end() {
return 0, false
}
version := binary.BigEndian.Uint16(data[base+8 : base+10])
hdrLen = 8 + 20
switch version {
case 1:
hdrLen += 16
case 2:
hdrLen += 36
}
if base+hdrLen > entry.end() {
return 0, false
}
return hdrLen, true
}
type ac4Location struct {
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
entry mp4Box // the ac-4 sample entry
}
func locateAC4Entry(data []byte) (ac4Location, bool) {
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
if !ok {
return ac4Location{}, false
}
var found ac4Location
var ok2 bool
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
if !ok {
return true
}
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
if !ok {
return true
}
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
if !ok {
return true
}
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
if !ok {
return true
}
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
if !ok {
return true
}
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
ok2 = true
return false
})
return found, ok2
}
func growBoxSize(data []byte, b mp4Box, delta int64) {
if b.hdr == 16 {
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
} else {
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
}
}
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
// inserted into moov.
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
if !ok {
return true
}
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
if !ok {
return true
}
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
if !ok {
return true
}
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
base := stco.body() + 4
if base+4 <= stco.end() {
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
p := base + 4
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
if v >= insertPos {
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
}
p += 4
}
}
}
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
base := co64.body() + 4
if base+4 <= co64.end() {
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
p := base + 4
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
if v >= insertPos {
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
}
p += 8
}
}
}
return true
})
}
// normalizeQuickTimeBrandsInBuf rewrites a "qt " ftyp brand to isom/mp42 in
// place. Works on any buffer whose top level contains the ftyp box (whole file
// or the ftyp box alone). Returns whether anything changed.
func normalizeQuickTimeBrandsInBuf(data []byte) bool {
ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp")
if !ok {
return false
}
changed := false
if ftyp.body()+4 <= int64(len(data)) && string(data[ftyp.body():ftyp.body()+4]) != "mp42" {
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
changed = true
}
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
if string(data[p:p+4]) == "qt " {
copy(data[p:p+4], []byte("isom"))
changed = true
}
}
return changed
}
// normalizeQuickTimeAudioEntry rewrites a version-1 QuickTime sound sample
// entry into a plain version-0 AudioSampleEntry, dropping the 16-byte v1
// extension. base is the buffer's absolute file offset (0 for a whole-file
// buffer, the moov offset for a moov-only buffer).
func normalizeQuickTimeAudioEntry(data []byte, base int64) []byte {
loc, ok := locateAC4Entry(data)
if !ok {
return data
}
entry := loc.entry
verPos := entry.body() + 8
if verPos+2 > entry.end() {
return data
}
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
return data // already v0 (or v2, left untouched)
}
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
extStart := entry.body() + 8 + 20
extEnd := extStart + 16
if extEnd > entry.end() {
return data
}
delta := int64(-16)
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
shiftChunkOffsets(data, loc.chain[0], base+extStart, delta)
for _, b := range loc.chain {
growBoxSize(data, b, delta)
}
growBoxSize(data, entry, delta)
out := make([]byte, 0, len(data)-16)
out = append(out, data[:extStart]...)
out = append(out, data[extEnd:]...)
return out
}
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
// Windows Media Foundation (and other strict parsers) reject the QuickTime
// flavor for AC-4 even when dac4 is present.
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
normalizeQuickTimeBrandsInBuf(data)
return normalizeQuickTimeAudioEntry(data, 0)
}
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
// moov still carries it). No-op when the file has no AC-4 track. Only the ftyp
// and moov boxes are held in memory; the audio bulk is streamed.
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
f, err := os.Open(decryptedPath)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
f.Close()
return err
}
// A non-MP4 decrypt output (e.g. a raw FLAC stream) is not an AC-4 file;
// bail out before the box parser reports it as a corrupt MP4. A real
// ISO-BMFF box type is four printable ASCII bytes.
var head [8]byte
if _, err := f.ReadAt(head[:], 0); err != nil {
f.Close()
if err == io.EOF || err == io.ErrUnexpectedEOF {
return nil
}
return err
}
for _, c := range head[4:8] {
if c < 0x20 || c > 0x7e {
f.Close()
return nil
}
}
moovBuf, moovOffset, moovFound, err := loadTopLevelMP4Box(f, info.Size(), "moov")
if err != nil || !moovFound {
f.Close()
return err // parse/read failure, or no moov: nothing to do
}
ftypBuf, ftypOffset, ftypFound, err := loadTopLevelMP4Box(f, info.Size(), "ftyp")
f.Close()
if err != nil {
return err
}
moovLen := int64(len(moovBuf))
if _, ok := locateAC4Entry(moovBuf); !ok {
return nil // not an AC-4 file; nothing to do
}
ftypChanged := ftypFound && normalizeQuickTimeBrandsInBuf(ftypBuf)
dst := normalizeQuickTimeAudioEntry(moovBuf, moovOffset)
loc, ok := locateAC4Entry(dst)
if !ok {
return nil
}
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
if !ok {
return fmt.Errorf("malformed ac-4 sample entry")
}
childStart := loc.entry.body() + hdrLen
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
// Already has dac4; still persist any normalization changes.
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, dst, moovOffset, moovLen)
}
srcF, err := os.Open(sourcePath)
if err != nil {
return err
}
srcInfo, err := srcF.Stat()
if err != nil {
srcF.Close()
return err
}
srcMoovBuf, _, srcFound, err := loadTopLevelMP4Box(srcF, srcInfo.Size(), "moov")
srcF.Close()
if err != nil {
return err
}
if !srcFound {
return fmt.Errorf("source has no moov")
}
srcMoov, ok := readMP4Box(srcMoovBuf, 0)
if !ok {
return fmt.Errorf("source has no moov")
}
dac4Box, ok := findBoxBySignature(srcMoovBuf, srcMoov.body(), srcMoov.end(), "dac4")
if !ok {
return fmt.Errorf("dac4 not found in source")
}
dac4 := append([]byte{}, srcMoovBuf[dac4Box.offset:dac4Box.end()]...)
insertPos := childStart
delta := int64(len(dac4))
shiftChunkOffsets(dst, loc.chain[0], moovOffset+insertPos, delta)
for _, b := range loc.chain {
growBoxSize(dst, b, delta)
}
growBoxSize(dst, loc.entry, delta)
out := make([]byte, 0, len(dst)+len(dac4))
out = append(out, dst[:insertPos]...)
out = append(out, dac4...)
out = append(out, dst[insertPos:]...)
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, out, moovOffset, moovLen)
}
// writeAC4Sections streams the edited moov (and, when changed, ftyp) back into
// the file.
func writeAC4Sections(path string, ftypChanged bool, ftypBuf []byte, ftypOffset int64, moovBuf []byte, moovOffset, origMoovLen int64) error {
sections := []fileSection{
{start: moovOffset, end: moovOffset + origMoovLen, data: moovBuf},
}
if ftypChanged {
sections = append(sections, fileSection{
start: ftypOffset,
end: ftypOffset + int64(len(ftypBuf)),
data: ftypBuf,
})
}
return replaceFileSectionsStreaming(path, sections)
}
+76
View File
@@ -0,0 +1,76 @@
package gobackend
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func mp4TestBox(typ string, body []byte) []byte {
out := make([]byte, 8+len(body))
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
copy(out[4:8], typ)
copy(out[8:], body)
return out
}
func mp4TestAC4Tree(entryBody []byte) []byte {
entry := mp4TestBox("ac-4", entryBody)
stsdBody := append([]byte{
0, 0, 0, 0, // version/flags
0, 0, 0, 1, // entry_count
}, entry...)
stsd := mp4TestBox("stsd", stsdBody)
stbl := mp4TestBox("stbl", stsd)
minf := mp4TestBox("minf", stbl)
mdia := mp4TestBox("mdia", minf)
trak := mp4TestBox("trak", mdia)
moov := mp4TestBox("moov", trak)
return moov
}
func shortAC4SampleEntryBody(version uint16) []byte {
body := make([]byte, 10)
binary.BigEndian.PutUint16(body[8:10], version)
return body
}
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
defer func() {
if r := recover(); r != nil {
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
}
}()
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
if !bytes.Equal(got, input) {
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
}
}
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
dir := t.TempDir()
decryptedPath := filepath.Join(dir, "decrypted.mp4")
sourcePath := filepath.Join(dir, "source.mp4")
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
t.Fatal(err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
}
}()
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
t.Fatal("expected malformed AC-4 sample entry error")
}
}
+201
View File
@@ -0,0 +1,201 @@
package gobackend
import (
"encoding/binary"
"encoding/json"
"os"
"strconv"
"strings"
)
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
// fields are strings because they arrive as a JSON-encoded map of strings.
type ac4Metadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumArtist string `json:"albumArtist"`
Date string `json:"date"`
Genre string `json:"genre"`
Composer string `json:"composer"`
TrackNumber string `json:"trackNumber"`
TotalTracks string `json:"totalTracks"`
DiscNumber string `json:"discNumber"`
TotalDiscs string `json:"totalDiscs"`
ISRC string `json:"isrc"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Lyrics string `json:"lyrics"`
}
func atoiSafe(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0
}
return n
}
func itunesTextTag(atomType, value string) []byte {
data := make([]byte, 8+len(value))
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
copy(data[8:], []byte(value))
return buildM4AAtom(atomType, buildM4AAtom("data", data))
}
func itunesNumberPairTag(atomType string, number, total int) []byte {
payload := make([]byte, 8)
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
data := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
copy(data[8:], payload)
return buildM4AAtom(atomType, buildM4AAtom("data", data))
}
func itunesCoverTag(image []byte) []byte {
typeCode := uint32(13) // JPEG
if len(image) >= 8 &&
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
typeCode = 14 // PNG
}
data := make([]byte, 8+len(image))
binary.BigEndian.PutUint32(data[0:4], typeCode)
copy(data[8:], image)
return buildM4AAtom("covr", buildM4AAtom("data", data))
}
func itunesMetadataHandler() []byte {
payload := make([]byte, 0, 25)
payload = append(payload, 0, 0, 0, 0) // version + flags
payload = append(payload, 0, 0, 0, 0) // pre_defined
payload = append(payload, []byte("mdir")...) // handler type
payload = append(payload, []byte("appl")...) // reserved[0]
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
payload = append(payload, 0) // empty name
return buildM4AAtom("hdlr", payload)
}
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
ilst := make([]byte, 0, 256)
add := func(atomType, value string) {
if strings.TrimSpace(value) != "" {
ilst = append(ilst, itunesTextTag(atomType, value)...)
}
}
add("\xa9nam", md.Title)
add("\xa9ART", md.Artist)
add("\xa9alb", md.Album)
add("aART", md.AlbumArtist)
add("\xa9day", md.Date)
add("\xa9gen", md.Genre)
add("\xa9wrt", md.Composer)
if tn := atoiSafe(md.TrackNumber); tn > 0 {
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
}
if dn := atoiSafe(md.DiscNumber); dn > 0 {
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
}
if strings.TrimSpace(md.ISRC) != "" {
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
}
if strings.TrimSpace(md.Label) != "" {
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
}
if strings.TrimSpace(md.Copyright) != "" {
add("cprt", md.Copyright)
}
if strings.TrimSpace(md.Lyrics) != "" {
add("\xa9lyr", md.Lyrics)
}
if len(cover) > 0 {
ilst = append(ilst, itunesCoverTag(cover)...)
}
ilstBox := buildM4AAtom("ilst", ilst)
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
metaPayload = append(metaPayload, ilstBox...)
meta := buildM4AAtom("meta", metaPayload)
return buildM4AAtom("udta", meta)
}
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
// the moov of an MP4 buffer and returns the rewritten bytes. base is the
// buffer's absolute file offset (0 for a whole-file buffer) so stco/co64
// shifts compare against the absolute positions the entries hold.
func writeMP4iTunesMetadata(data []byte, base int64, md ac4Metadata, cover []byte) []byte {
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
if !ok {
return data
}
newUdta := buildITunesUdta(md, cover)
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
delta := int64(len(newUdta)) - udta.size
shiftChunkOffsets(data, moov, base+udta.offset, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:udta.offset]...)
out = append(out, newUdta...)
out = append(out, data[udta.end():]...)
return out
}
delta := int64(len(newUdta))
insertPos := moov.end()
shiftChunkOffsets(data, moov, base+insertPos, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:insertPos]...)
out = append(out, newUdta...)
out = append(out, data[insertPos:]...)
return out
}
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
// true when the file was an AC-4 track and metadata was written; false when the
// file is not AC-4 (the caller should fall back to its normal metadata path).
// Only the moov box is held in memory; the audio bulk is streamed.
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
f, err := os.Open(decryptedPath)
if err != nil {
return false, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return false, err
}
moovBuf, moovOffset, found, err := loadTopLevelMP4Box(f, info.Size(), "moov")
f.Close()
if err != nil {
return false, err
}
if !found {
return false, nil
}
moovLen := int64(len(moovBuf))
if _, ok := locateAC4Entry(moovBuf); !ok {
return false, nil
}
var md ac4Metadata
if strings.TrimSpace(metadataJSON) != "" {
_ = json.Unmarshal([]byte(metadataJSON), &md)
}
var cover []byte
if strings.TrimSpace(coverPath) != "" {
if b, err := os.ReadFile(coverPath); err == nil {
cover = b
}
}
out := writeMP4iTunesMetadata(moovBuf, moovOffset, md, cover)
if err := replaceFileSectionsStreaming(decryptedPath, []fileSection{
{start: moovOffset, end: moovOffset + moovLen, data: out},
}); err != nil {
return false, err
}
return true, nil
}
+3 -5
View File
@@ -16,9 +16,8 @@ const (
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
apeTagFlagReadOnly = 1 << 0
// Item flags: bits 1-2 encode content type
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
apeItemFlagBinary = 1 << 1 // 01: binary data
apeItemFlagLink = 2 << 1 // 10: external link
// (00: UTF-8 text, 01: binary data, 10: external link)
apeItemFlagBinary = 1 << 1
)
// APETagItem represents a single key-value item in an APEv2 tag.
@@ -314,7 +313,6 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
footerFlags := uint32(1 << 31)
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
// Final layout: header + items + footer
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
result = append(result, header...)
result = append(result, itemsData...)
@@ -564,7 +562,7 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
return nil, fmt.Errorf("no APEv2 tag found")
}
func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) {
func parseAPETagFromFooter(r io.ReaderAt, _, footerOffset int64, footer []byte) (*APETag, error) {
version := binary.LittleEndian.Uint32(footer[8:12])
tagSize := binary.LittleEndian.Uint32(footer[12:16])
itemCount := binary.LittleEndian.Uint32(footer[16:20])
+83 -144
View File
@@ -2,6 +2,7 @@ package gobackend
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
@@ -185,7 +186,7 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
case "TCR":
metadata.Copyright = value
case "ULT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXX":
@@ -306,11 +307,11 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
case "TCOP":
metadata.Copyright = value
case "COMM":
if v := extractCommentFrame(frameData); v != "" {
if v := extractLangTextFrame(frameData); v != "" {
metadata.Comment = v
}
case "USLT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXXX":
@@ -390,7 +391,9 @@ func extractTextFrame(data []byte) string {
}
}
func extractCommentFrame(data []byte) string {
// extractLangTextFrame decodes ID3 frames with an encoding byte, 3-byte
// language code, and null-terminated descriptor before the text (COMM, USLT).
func extractLangTextFrame(data []byte) string {
if len(data) < 5 {
return ""
}
@@ -425,42 +428,6 @@ func extractCommentFrame(data []byte) string {
return extractTextFrame(framed)
}
func extractLyricsFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
rest := data[4:]
var text []byte
switch encoding {
case 1, 2:
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:]
break
}
}
default:
idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:]
} else {
text = rest
}
}
if len(text) == 0 {
return ""
}
framed := make([]byte, 1+len(text))
framed[0] = encoding
copy(framed[1:], text)
return extractTextFrame(framed)
}
func extractUserTextFrame(data []byte) (string, string) {
if len(data) < 2 {
return "", ""
@@ -581,11 +548,6 @@ func cleanGenre(genre string) string {
return genre
}
func parseTrackNumber(s string) int {
num, _ := parseIndexPair(s)
return num
}
func parseIndexPair(s string) (int, int) {
s = strings.TrimSpace(s)
if s == "" {
@@ -1078,6 +1040,21 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
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
}
}
}
}
@@ -1089,6 +1066,17 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
}
}
// 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 {
@@ -1460,13 +1448,20 @@ func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
key := "METADATA_BLOCK_PICTURE="
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
b64Data := comment[len(key):]
decoded := make([]byte, base64StdDecodeLen(len(b64Data)))
n, err := base64StdDecode(decoded, b64Data)
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
}
decoded = decoded[:n]
imageData, mimeType := parseFLACPictureBlock(decoded)
if len(imageData) > 0 {
@@ -1520,71 +1515,6 @@ func parseFLACPictureBlock(data []byte) ([]byte, string) {
return imageData, mimeType
}
func base64StdDecodeLen(n int) int {
return n * 6 / 8
}
func base64StdDecode(dst, src []byte) (int, error) {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
decodeMap := make([]byte, 256)
for i := range decodeMap {
decodeMap[i] = 0xFF
}
for i := 0; i < len(alphabet); i++ {
decodeMap[alphabet[i]] = byte(i)
}
si, di := 0, 0
for si < len(src) {
for si < len(src) && (src[si] == '\n' || src[si] == '\r' || src[si] == ' ' || src[si] == '\t') {
si++
}
if si >= len(src) {
break
}
var vals [4]byte
var valCount int
for valCount < 4 && si < len(src) {
c := src[si]
si++
if c == '=' {
vals[valCount] = 0
valCount++
} else if c == '\n' || c == '\r' || c == ' ' || c == '\t' {
continue
} else if decodeMap[c] != 0xFF {
vals[valCount] = decodeMap[c]
valCount++
}
}
if valCount < 2 {
break
}
if di < len(dst) {
dst[di] = vals[0]<<2 | vals[1]>>4
di++
}
if valCount >= 3 && di < len(dst) {
dst[di] = vals[1]<<4 | vals[2]>>2
di++
}
if valCount >= 4 && di < len(dst) {
dst[di] = vals[2]<<6 | vals[3]
di++
}
}
return di, nil
}
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
return extractAnyCoverArtWithHint(filePath, "")
}
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == "" {
@@ -1632,14 +1562,6 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
}
}
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
}
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
}
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
@@ -1653,39 +1575,56 @@ func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
return cacheKey
}
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
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, nil
return jpgPath
}
if _, err := os.Stat(pngPath); err == nil {
return pngPath, 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
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", fmt.Errorf("failed to create cache dir: %w", err)
}
var cachePath string
if strings.Contains(mimeType, "png") {
cachePath = pngPath
} else {
cachePath = jpgPath
}
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover: %w", err)
}
return cachePath, nil
return saveLibraryCoverDataToCache(cacheDir, cacheKey, imageData, mimeType)
}
+13 -23
View File
@@ -85,9 +85,6 @@ func TestAudioMetadataID3ParsingBranches(t *testing.T) {
if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 {
t.Fatalf("parseIndexPair = %d/%d", n, total)
}
if got := parseTrackNumber("9/11"); got != 9 {
t.Fatalf("parseTrackNumber = %d", got)
}
if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) {
t.Fatalf("removeUnsync = %#v", got)
}
@@ -164,12 +161,6 @@ func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
if commentMIME != "image/png" || !bytes.Equal(commentImage, png) {
t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage)
}
decoded := make([]byte, base64StdDecodeLen(len("SGV sbG8="))+4)
n, err := base64StdDecode(decoded, []byte("SGV sbG8="))
if err != nil || strings.TrimRight(string(decoded[:n]), "\x00") != "Hello" {
t.Fatalf("base64 decode = %q/%v", decoded[:n], err)
}
if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus {
t.Fatal("expected opus stream")
}
@@ -430,9 +421,6 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 {
t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err)
}
if image, mime, err := extractAnyCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
t.Fatalf("extractAnyCoverArt = %s/%#v/%v", mime, image, err)
}
extractedCoverPath := filepath.Join(dir, "extracted.png")
if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil {
t.Fatalf("ExtractCoverToFile = %v", err)
@@ -444,17 +432,6 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
if err != nil || cachePath == "" {
t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err)
}
cacheDir := filepath.Join(dir, "cache")
if path, err := SaveCoverToCache(coverPath, cacheDir); err != nil || !strings.HasSuffix(path, ".png") {
t.Fatalf("SaveCoverToCache = %q/%v", path, err)
}
if path, err := SaveCoverToCacheWithHint(coverPath, "cover.opus", cacheDir); err != nil || path == "" {
t.Fatalf("SaveCoverToCacheWithHint = %q/%v", path, err)
}
hitPath, err := SaveCoverToCache(coverPath, cacheDir)
if err != nil || hitPath == "" {
t.Fatalf("SaveCoverToCache cache hit = %q/%v", hitPath, err)
}
if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil {
t.Fatal("expected missing cover cache error")
}
@@ -515,3 +492,16 @@ func buildTestFLACPictureBlock(image []byte, mime string) []byte {
picture.Write(image)
return picture.Bytes()
}
func TestR128ToReplayGainDb(t *testing.T) {
// -1280/256 = -5 dB on the R128 (-23 LUFS) scale -> 0 dB ReplayGain (-18).
if db, ok := r128ToReplayGainDb("-1280"); !ok || db != "0.00 dB" {
t.Fatalf("got %q ok=%v", db, ok)
}
if db, ok := r128ToReplayGainDb(" -2944 "); !ok || db != "-6.50 dB" {
t.Fatalf("got %q ok=%v", db, ok)
}
if _, ok := r128ToReplayGainDb("abc"); ok {
t.Fatal("expected failure for non-numeric input")
}
}
+40
View File
@@ -59,6 +59,18 @@ func initDownloadCancel(itemID string) context.Context {
return ctx
}
func downloadCancelContext(itemID string) context.Context {
if itemID == "" {
return context.Background()
}
cancelMu.Lock()
defer cancelMu.Unlock()
if entry, ok := cancelMap[itemID]; ok && entry.ctx != nil {
return entry.ctx
}
return context.Background()
}
func cancelDownload(itemID string) {
if itemID == "" {
return
@@ -91,6 +103,22 @@ func isDownloadCancelled(itemID string) bool {
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) {
if itemID == "" {
return
}
cancelMu.Lock()
if entry, ok := cancelMap[itemID]; ok && entry.refs <= 0 {
delete(cancelMap, itemID)
}
cancelMu.Unlock()
}
func clearDownloadCancel(itemID string) {
if itemID == "" {
return
@@ -137,6 +165,18 @@ func initExtensionRequestCancel(requestID string) context.Context {
return ctx
}
func extensionRequestCancelContext(requestID string) context.Context {
if 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) {
if requestID == "" {
return
+111 -14
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"regexp"
"strings"
"sync"
"time"
)
const (
@@ -52,6 +54,115 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
GoLog("[Cover] Final URL: %s", downloadURL)
data, err := fetchCoverCached(downloadURL)
if err != nil {
return nil, err
}
// Cached bytes are shared across goroutines and must never be mutated;
// hand callers their own copy.
return append([]byte(nil), data...), nil
}
const (
coverCacheMaxBytes = 24 * 1024 * 1024
coverCacheTTL = 15 * time.Minute
)
type coverCacheEntry struct {
data []byte
expiresAt time.Time
}
type coverInflightCall struct {
wg sync.WaitGroup
data []byte
err error
}
var (
coverMu sync.Mutex
coverCache = map[string]*coverCacheEntry{}
coverCacheBytes int
coverInflight = map[string]*coverInflightCall{}
coverFetch = fetchCoverBytes
)
func clearCoverMemoryCache() {
coverMu.Lock()
coverCache = map[string]*coverCacheEntry{}
coverCacheBytes = 0
coverMu.Unlock()
}
// fetchCoverCached returns cover bytes for a final URL, collapsing concurrent
// requests for the same URL into a single fetch (singleflight) and caching
// results in memory for the duration of an album batch. The returned slice is
// shared; callers must copy before mutating.
func fetchCoverCached(downloadURL string) ([]byte, error) {
coverMu.Lock()
if e, ok := coverCache[downloadURL]; ok {
if time.Now().Before(e.expiresAt) {
data := e.data
coverMu.Unlock()
return data, nil
}
delete(coverCache, downloadURL)
coverCacheBytes -= len(e.data)
}
if call, ok := coverInflight[downloadURL]; ok {
coverMu.Unlock()
call.wg.Wait()
return call.data, call.err
}
call := &coverInflightCall{}
// Default error so a panicking fetch never strands waiters with a
// (nil, nil) "success"; overwritten on normal completion.
call.err = fmt.Errorf("cover fetch aborted")
call.wg.Add(1)
coverInflight[downloadURL] = call
coverMu.Unlock()
defer func() {
call.wg.Done()
coverMu.Lock()
delete(coverInflight, downloadURL)
coverMu.Unlock()
}()
data, err := coverFetch(downloadURL)
call.data, call.err = data, err
if err == nil {
coverCachePut(downloadURL, data)
}
return data, err
}
func coverCachePut(downloadURL string, data []byte) {
if len(data) == 0 || len(data) > coverCacheMaxBytes {
return
}
coverMu.Lock()
defer coverMu.Unlock()
if e, ok := coverCache[downloadURL]; ok {
coverCacheBytes -= len(e.data)
}
coverCache[downloadURL] = &coverCacheEntry{data: data, expiresAt: time.Now().Add(coverCacheTTL)}
coverCacheBytes += len(data)
for coverCacheBytes > coverCacheMaxBytes && len(coverCache) > 1 {
var oldestKey string
var oldest time.Time
first := true
for k, e := range coverCache {
if first || e.expiresAt.Before(oldest) {
oldest, oldestKey, first = e.expiresAt, k, false
}
}
coverCacheBytes -= len(coverCache[oldestKey].data)
delete(coverCache, oldestKey)
}
}
func fetchCoverBytes(downloadURL string) ([]byte, error) {
client := NewHTTPClientWithTimeout(DefaultTimeout)
req, err := http.NewRequest("GET", downloadURL, nil)
@@ -143,17 +254,3 @@ func upgradeQobuzCover(coverURL string) string {
}
return upgraded
}
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
return ""
}
result := convertSmallToMedium(imageURL)
if maxQuality {
result = upgradeToMaxQuality(result)
}
return result
}
+96
View File
@@ -0,0 +1,96 @@
package gobackend
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func resetCoverCache() {
coverMu.Lock()
coverCache = map[string]*coverCacheEntry{}
coverInflight = map[string]*coverInflightCall{}
coverCacheBytes = 0
coverMu.Unlock()
}
func TestFetchCoverCachedSingleflight(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
var calls int32
entered := make(chan struct{})
release := make(chan struct{})
coverFetch = func(string) ([]byte, error) {
if atomic.AddInt32(&calls, 1) == 1 {
close(entered)
}
<-release
return []byte("coverbytes"), nil
}
const url = "https://cdn.example/cover_max.jpg"
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if _, err := fetchCoverCached(url); err != nil {
t.Errorf("leader fetch error: %v", err)
}
}()
<-entered // leader has registered inflight and is blocked in coverFetch
wg.Add(1)
go func() {
defer wg.Done()
if _, err := fetchCoverCached(url); err != nil {
t.Errorf("follower fetch error: %v", err)
}
}()
close(release)
wg.Wait()
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected 1 fetch for concurrent requests, got %d", got)
}
}
func TestFetchCoverCachedTTLExpiry(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
var calls int32
coverFetch = func(string) ([]byte, error) {
atomic.AddInt32(&calls, 1)
return []byte("data"), nil
}
const url = "https://cdn.example/ttl.jpg"
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("first fetch error: %v", err)
}
// second call served from cache
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("second fetch error: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected cache hit, got %d fetches", got)
}
// expire the entry and confirm a refetch
coverMu.Lock()
coverCache[url].expiresAt = time.Now().Add(-time.Minute)
coverMu.Unlock()
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("third fetch error: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("expected refetch after TTL expiry, got %d fetches", got)
}
}
+2 -1
View File
@@ -73,7 +73,8 @@ function track(id) {
genre: "Pop",
composer: "Composer",
audioQuality: "FLAC 24-bit",
audioModes: "DOLBY_ATMOS"
audioModes: "DOLBY_ATMOS",
explicit: true
};
}
+2 -2
View File
@@ -241,7 +241,7 @@ func searchCollectionCandidates(provider *extensionProviderWrapper, itemType str
}
if filter != "" {
tracks, err := provider.CustomSearch(query, map[string]interface{}{
tracks, err := provider.CustomSearch(query, map[string]any{
"filter": filter,
"limit": 10,
})
@@ -409,7 +409,7 @@ func templateShareURL(ext *loadedExtension, itemType string, id string) string {
return ""
}
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]any)
if !ok {
return ""
}
+4 -4
View File
@@ -5,8 +5,8 @@ import "testing"
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
Capabilities: map[string]any{
"shareUrlTemplates": map[string]any{
"album": "https://music.apple.com/us/album/{id}",
},
},
@@ -33,8 +33,8 @@ func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
Capabilities: map[string]any{
"shareUrlTemplates": map[string]any{
"artist": "https://music.youtube.com/browse/{id}",
},
},
+97 -1
View File
@@ -117,6 +117,101 @@ func TestCueParserEndToEnd(t *testing.T) {
}
}
func writeTestFlacWithISRC(t *testing.T, path, isrc string) {
t.Helper()
le32 := func(v int) []byte {
return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)}
}
vendor := "test-vendor"
comments := [][]byte{
[]byte("TITLE=Song"),
[]byte("ISRC=" + isrc),
}
var vorbis []byte
vorbis = append(vorbis, le32(len(vendor))...)
vorbis = append(vorbis, vendor...)
vorbis = append(vorbis, le32(len(comments))...)
for _, comment := range comments {
vorbis = append(vorbis, le32(len(comment))...)
vorbis = append(vorbis, comment...)
}
blockHeader := func(last bool, blockType byte, length int) []byte {
first := blockType
if last {
first |= 0x80
}
return []byte{first, byte(length >> 16), byte(length >> 8), byte(length)}
}
var data []byte
data = append(data, "fLaC"...)
data = append(data, blockHeader(false, 0, 34)...) // STREAMINFO
data = append(data, make([]byte, 34)...)
picture := make([]byte, 4096) // stands in for embedded cover art
data = append(data, blockHeader(false, 6, len(picture))...)
data = append(data, picture...)
data = append(data, blockHeader(true, 4, len(vorbis))...)
data = append(data, vorbis...)
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
}
func TestISRCIndexIncrementalRebuild(t *testing.T) {
dir := t.TempDir()
trackA := filepath.Join(dir, "a.flac")
trackB := filepath.Join(dir, "b.flac")
writeTestFlacWithISRC(t, trackA, "USAA00000001")
writeTestFlacWithISRC(t, trackB, "USBB00000002")
defer InvalidateISRCCache(dir)
if got := readFlacISRC(trackA); got != "USAA00000001" {
t.Fatalf("readFlacISRC = %q", got)
}
if got := readFlacISRC(filepath.Join(dir, "missing.flac")); got != "" {
t.Fatalf("expected empty ISRC for missing file, got %q", got)
}
idx := buildISRCIndex(dir)
if path, ok := idx.lookup("usaa00000001"); !ok || path != trackA {
t.Fatalf("lookup A = %q/%v", path, ok)
}
if path, ok := idx.lookup("USBB00000002"); !ok || path != trackB {
t.Fatalf("lookup B = %q/%v", path, ok)
}
// Change one file's tag (and mtime); an incremental rebuild must pick up
// the change while adopting the untouched file from the cache.
writeTestFlacWithISRC(t, trackB, "USBB00000099")
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(trackB, future, future); err != nil {
t.Fatal(err)
}
rebuilt := buildISRCIndex(dir)
if _, ok := rebuilt.lookup("USBB00000002"); ok {
t.Fatal("expected stale ISRC to disappear after rebuild")
}
if path, ok := rebuilt.lookup("USBB00000099"); !ok || path != trackB {
t.Fatalf("rebuilt lookup = %q/%v", path, ok)
}
if path, ok := rebuilt.lookup("USAA00000001"); !ok || path != trackA {
t.Fatalf("cached entry lost on rebuild: %q/%v", path, ok)
}
// Add() keeps the index fresh so the TTL never forces a rebuild while
// downloads are actively maintaining it.
rebuilt.buildTime.Store(time.Now().Add(-isrcIndexTTL - time.Minute).UnixNano())
if rebuilt.isFresh() {
t.Fatal("expected stale index")
}
rebuilt.Add("USCC00000003", trackA)
if !rebuilt.isFresh() {
t.Fatal("expected Add to refresh the index timestamp")
}
}
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "song.flac")
@@ -124,7 +219,8 @@ func TestDuplicateIndexAndParallelExistence(t *testing.T) {
t.Fatal(err)
}
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir, buildTime: time.Now()}
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir}
idx.buildTime.Store(time.Now().UnixNano())
idx.Add("usrc17607839", filePath)
if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath {
t.Fatalf("lookup = %q/%v", got, ok)
+57 -25
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -162,17 +163,26 @@ func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
}
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
Duration int `json:"duration"`
TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
Link string `json:"link"`
ReleaseDate string `json:"release_date"`
Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"`
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 {
@@ -244,6 +254,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
ISRC: track.ISRC,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
Explicit: deezerTrackIsExplicit(track),
}
}
@@ -669,6 +680,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
AlbumType: albumType,
Explicit: deezerTrackIsExplicit(track),
})
}
@@ -783,7 +795,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
// not include this field. Albums whose track count is already known (non-zero)
// are skipped.
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
// Find albums that need track counts
type indexedID struct {
idx int
albumID string
@@ -979,6 +990,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
Explicit: deezerTrackIsExplicit(track),
})
}
@@ -1251,12 +1263,16 @@ func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc strin
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
}
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst any) error {
var lastErr error
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if attempt > 0 {
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
var apiErr *deezerAPIError
if errors.As(lastErr, &apiErr) && apiErr.RetryAfter > 0 {
delay = apiErr.RetryAfter
}
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay)
}
@@ -1267,16 +1283,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
}
lastErr = err
errStr := err.Error()
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429")
if !isRetryable {
if !isDeezerRetryableError(err) {
return err
}
@@ -1286,13 +1293,38 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
}
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
type deezerAPIError struct {
StatusCode int
Body string
RetryAfter time.Duration
}
func (e *deezerAPIError) Error() string {
return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body)
}
func isDeezerRetryableError(err error) bool {
if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
var apiErr *deezerAPIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
}
return false
}
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
// Without an explicit language Deezer localizes artist/genre names by
// the caller's IP geolocation (issue #480: Arabic metadata on an
// English device). Follow the app's display language instead.
req.Header.Set("Accept-Language", metadataAcceptLanguage())
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -1306,7 +1338,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: getRetryAfterDuration(resp)}
}
return json.Unmarshal(body, dst)
+237
View File
@@ -0,0 +1,237 @@
package gobackend
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
"golang.org/x/net/dns/dnsmessage"
)
// DNS-over-HTTPS fallback for DNS-level ISP blocking. The OS resolver stays
// the primary path; only when it fails with a DNS error (NXDOMAIN, SERVFAIL,
// refused, resolver timeout) is the host re-resolved over DoH to hardcoded
// resolver IPs and dialed directly. TLS verification still runs against the
// original hostname, so a bad answer cannot silently redirect traffic.
var dohUpstreams = []string{
"https://1.1.1.1/dns-query",
"https://8.8.8.8/dns-query",
}
// Upstream URLs are literal IPs, so this client never needs DNS itself.
var dohClient = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
MaxIdleConnsPerHost: 1,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ForceAttemptHTTP2: true,
TLSClientConfig: newTLSCompatibilityConfig(false),
},
Timeout: 10 * time.Second,
}
const (
dohCacheMaxEntries = 256
dohCacheMinTTL = time.Minute
dohCacheMaxTTL = 30 * time.Minute
dohCacheErrorTTL = 30 * time.Second
)
type dohCacheEntry struct {
ips []net.IP
expiresAt time.Time
}
var (
dohMu sync.Mutex
dohCache = map[string]dohCacheEntry{}
)
// dialWithDoHFallback dials addr normally and, when the failure is a DNS
// error, retries with DoH-resolved addresses. Non-DNS failures pass through
// untouched.
func dialWithDoHFallback(ctx context.Context, dialer *net.Dialer, network, addr string) (net.Conn, error) {
conn, err := dialer.DialContext(ctx, network, addr)
if err == nil {
return conn, nil
}
var dnsErr *net.DNSError
if !errors.As(err, &dnsErr) {
return nil, err
}
host, port, splitErr := net.SplitHostPort(addr)
if splitErr != nil || net.ParseIP(host) != nil {
return nil, err
}
ips, dohErr := dohResolve(ctx, host)
if dohErr != nil {
// Surface the OS resolver's error, not the fallback's.
return nil, err
}
GoLog("[DoH] OS resolver failed for %s (%v), dialing DoH answer\n", host, err)
lastErr := err
for _, ip := range ips {
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
}
// dohResolve resolves host over DoH, IPv4 first. Failures are negative-cached
// briefly so a burst of dials does not hammer the resolvers.
func dohResolve(ctx context.Context, host string) ([]net.IP, error) {
if ips, ok := dohCachedIPs(host); ok {
if len(ips) == 0 {
return nil, fmt.Errorf("doh: cached failure for %s", host)
}
return ips, nil
}
var lastErr error
for _, upstream := range dohUpstreams {
ips, ttl, err := dohQuery(ctx, upstream, host, dnsmessage.TypeA)
if err == nil && len(ips) == 0 {
ips, ttl, err = dohQuery(ctx, upstream, host, dnsmessage.TypeAAAA)
}
if err != nil {
lastErr = err
continue
}
ips = filterDialableIPs(ips)
if len(ips) == 0 {
break
}
dohCachePut(host, ips, min(max(ttl, dohCacheMinTTL), dohCacheMaxTTL))
return ips, nil
}
dohCachePut(host, nil, dohCacheErrorTTL)
if lastErr == nil {
lastErr = fmt.Errorf("doh: no address for %s", host)
}
return nil, lastErr
}
func dohQuery(ctx context.Context, upstream, host string, qtype dnsmessage.Type) ([]net.IP, time.Duration, error) {
name, err := dnsmessage.NewName(host + ".")
if err != nil {
return nil, 0, fmt.Errorf("doh: invalid host %q: %w", host, err)
}
msg := dnsmessage.Message{
Header: dnsmessage.Header{RecursionDesired: true},
Questions: []dnsmessage.Question{{
Name: name,
Type: qtype,
Class: dnsmessage.ClassINET,
}},
}
packed, err := msg.Pack()
if err != nil {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstream, bytes.NewReader(packed))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/dns-message")
req.Header.Set("Accept", "application/dns-message")
resp, err := dohClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("doh: %s answered HTTP %d", upstream, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil, 0, err
}
var reply dnsmessage.Message
if err := reply.Unpack(body); err != nil {
return nil, 0, err
}
if reply.RCode != dnsmessage.RCodeSuccess {
return nil, 0, fmt.Errorf("doh: rcode %v for %s", reply.RCode, host)
}
var ips []net.IP
ttl := dohCacheMaxTTL
for _, ans := range reply.Answers {
var ip net.IP
switch r := ans.Body.(type) {
case *dnsmessage.AResource:
ip = net.IP(r.A[:])
case *dnsmessage.AAAAResource:
ip = net.IP(r.AAAA[:])
default:
continue
}
ips = append(ips, ip)
if t := time.Duration(ans.Header.TTL) * time.Second; t < ttl {
ttl = t
}
}
return ips, ttl, nil
}
// filterDialableIPs drops private/loopback/link-local answers unless the user
// opted into private-network access — a DoH answer must not bypass the SSRF
// guard the OS-resolver path enforces.
func filterDialableIPs(ips []net.IP) []net.IP {
if IsPrivateNetworkAllowed() {
return ips
}
kept := ips[:0]
for _, ip := range ips {
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
continue
}
kept = append(kept, ip)
}
return kept
}
func dohCachedIPs(host string) ([]net.IP, bool) {
dohMu.Lock()
defer dohMu.Unlock()
e, ok := dohCache[host]
if !ok || time.Now().After(e.expiresAt) {
return nil, false
}
return e.ips, true
}
func dohCachePut(host string, ips []net.IP, ttl time.Duration) {
dohMu.Lock()
defer dohMu.Unlock()
if len(dohCache) >= dohCacheMaxEntries {
now := time.Now()
for k, e := range dohCache {
if now.After(e.expiresAt) {
delete(dohCache, k)
}
}
for k := range dohCache {
if len(dohCache) < dohCacheMaxEntries {
break
}
delete(dohCache, k)
}
}
dohCache[host] = dohCacheEntry{ips: ips, expiresAt: time.Now().Add(ttl)}
}
+33
View File
@@ -0,0 +1,33 @@
package gobackend
import (
"path/filepath"
"strings"
"sync"
)
// downloadPathLocks serializes writes per final output path so concurrent
// downloads that resolve to the same file cannot interleave bytes into one
// output or race the staged-promote rename. Keys are normalized case-folded
// cleaned paths; entries live for the process lifetime (bounded by the number
// of distinct output files in a session).
var downloadPathLocks sync.Map
// lockDownloadOutputPath locks the given final output path and returns the
// unlock function. Different paths keep downloading in parallel; a second
// download of the same path blocks until the first finishes.
func lockDownloadOutputPath(path string) func() {
key := strings.ToLower(filepath.Clean(path))
value, _ := downloadPathLocks.LoadOrStore(key, &sync.Mutex{})
mu := value.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
// stagedDownloadPath returns the sibling name downloads are streamed into
// before being promoted to the final path with an atomic rename. The suffix
// keeps the staged file invisible to extension-based duplicate checks, which
// match on the final audio extension.
func stagedDownloadPath(finalPath string) string {
return finalPath + ".partial"
}
+129
View File
@@ -0,0 +1,129 @@
package gobackend
import (
"strings"
"sync"
"time"
)
const (
downloadPreparationCacheTTL = 5 * time.Minute
downloadPreparationCacheMax = 128
)
type preparedDownloadRequestEntry struct {
key string
request DownloadRequest
metadataPrepared bool
createdAt time.Time
}
var (
preparedDownloadRequests = make(map[string]preparedDownloadRequestEntry)
preparedDownloadRequestsMu sync.Mutex
)
func downloadPreparationKey(req DownloadRequest) string {
return strings.Join([]string{
strings.TrimSpace(req.ItemID),
strings.ToLower(strings.TrimSpace(req.Service)),
strings.ToLower(strings.TrimSpace(req.Source)),
strings.TrimSpace(req.SpotifyID),
strings.TrimSpace(req.TidalID),
strings.TrimSpace(req.QobuzID),
strings.TrimSpace(req.DeezerID),
strings.ToLower(strings.TrimSpace(req.TrackName)),
strings.ToLower(strings.TrimSpace(req.ArtistName)),
}, "\n")
}
func prunePreparedDownloadRequestsLocked(now time.Time) {
for itemID, entry := range preparedDownloadRequests {
if now.Sub(entry.createdAt) >= downloadPreparationCacheTTL {
delete(preparedDownloadRequests, itemID)
}
}
for len(preparedDownloadRequests) >= downloadPreparationCacheMax {
var oldestID string
var oldestAt time.Time
for itemID, entry := range preparedDownloadRequests {
if oldestID == "" || entry.createdAt.Before(oldestAt) {
oldestID = itemID
oldestAt = entry.createdAt
}
}
if oldestID == "" {
break
}
delete(preparedDownloadRequests, oldestID)
}
}
func cacheDownloadRequestForVerification(key string, req DownloadRequest, metadataPrepared bool) {
itemID := strings.TrimSpace(req.ItemID)
if itemID == "" || strings.TrimSpace(key) == "" {
return
}
preparedDownloadRequestsMu.Lock()
defer preparedDownloadRequestsMu.Unlock()
now := time.Now()
prunePreparedDownloadRequestsLocked(now)
preparedDownloadRequests[itemID] = preparedDownloadRequestEntry{
key: key,
request: req,
metadataPrepared: metadataPrepared,
createdAt: now,
}
}
func cachePreparedDownloadRequest(key string, req DownloadRequest) {
cacheDownloadRequestForVerification(key, req, true)
}
func cacheUnpreparedDownloadRequest(key string, req DownloadRequest) {
cacheDownloadRequestForVerification(key, req, false)
}
func takePreparedDownloadRequest(key string, fresh DownloadRequest) (DownloadRequest, bool, bool) {
itemID := strings.TrimSpace(fresh.ItemID)
if itemID == "" || strings.TrimSpace(key) == "" {
return fresh, false, false
}
preparedDownloadRequestsMu.Lock()
defer preparedDownloadRequestsMu.Unlock()
now := time.Now()
prunePreparedDownloadRequestsLocked(now)
entry, ok := preparedDownloadRequests[itemID]
if !ok {
return fresh, false, false
}
delete(preparedDownloadRequests, itemID)
if entry.key != key {
return fresh, false, false
}
prepared := entry.request
fresh.ISRC = prepared.ISRC
fresh.SpotifyID = prepared.SpotifyID
fresh.TrackName = prepared.TrackName
fresh.ArtistName = prepared.ArtistName
fresh.AlbumName = prepared.AlbumName
fresh.AlbumArtist = prepared.AlbumArtist
fresh.CoverURL = prepared.CoverURL
fresh.TrackNumber = prepared.TrackNumber
fresh.DiscNumber = prepared.DiscNumber
fresh.TotalTracks = prepared.TotalTracks
fresh.TotalDiscs = prepared.TotalDiscs
fresh.ReleaseDate = prepared.ReleaseDate
fresh.DurationMS = prepared.DurationMS
fresh.Genre = prepared.Genre
fresh.Label = prepared.Label
fresh.Copyright = prepared.Copyright
fresh.Composer = prepared.Composer
fresh.TidalID = prepared.TidalID
fresh.QobuzID = prepared.QobuzID
fresh.DeezerID = prepared.DeezerID
return fresh, entry.metadataPrepared, true
}
@@ -0,0 +1,104 @@
package gobackend
import (
"testing"
"time"
)
func resetPreparedDownloadRequestCacheForTest() {
preparedDownloadRequestsMu.Lock()
preparedDownloadRequests = make(map[string]preparedDownloadRequestEntry)
preparedDownloadRequestsMu.Unlock()
}
func TestPreparedDownloadRequestCache(t *testing.T) {
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
resetPreparedDownloadRequestCacheForTest()
fresh := DownloadRequest{
ItemID: "item-1",
Service: "provider-a",
Source: "source-a",
SpotifyID: "spotify-1",
TrackName: "Track",
ArtistName: "Artist",
OutputDir: "/new/output",
OutputPath: "/new/output/current.flac",
OutputFD: 42,
Quality: "lossless",
EmbedMetadata: true,
}
key := downloadPreparationKey(fresh)
prepared := fresh
prepared.ISRC = "USRC17607839"
prepared.AlbumName = "Resolved Album"
prepared.AlbumArtist = "Resolved Album Artist"
prepared.DeezerID = "deezer-1"
prepared.Genre = "Pop"
prepared.OutputDir = "/stale/output"
prepared.OutputPath = "/stale/output/old.flac"
prepared.OutputFD = 7
prepared.Quality = "stale-quality"
prepared.EmbedMetadata = false
cachePreparedDownloadRequest(key, prepared)
got, metadataPrepared, ok := takePreparedDownloadRequest(key, fresh)
if !ok {
t.Fatal("expected prepared request cache hit")
}
if !metadataPrepared {
t.Fatal("prepared request should be marked as metadata-prepared")
}
if got.ISRC != prepared.ISRC || got.AlbumName != prepared.AlbumName || got.DeezerID != prepared.DeezerID || got.Genre != prepared.Genre {
t.Fatalf("prepared metadata was not restored: %#v", got)
}
if got.OutputDir != fresh.OutputDir || got.OutputPath != fresh.OutputPath || got.OutputFD != fresh.OutputFD || got.Quality != fresh.Quality || got.EmbedMetadata != fresh.EmbedMetadata {
t.Fatalf("fresh output/settings fields were overwritten: %#v", got)
}
if _, _, ok := takePreparedDownloadRequest(key, fresh); ok {
t.Fatal("prepared request should be consumed after one retry")
}
cacheUnpreparedDownloadRequest(key, fresh)
_, metadataPrepared, ok = takePreparedDownloadRequest(key, fresh)
if !ok || metadataPrepared {
t.Fatalf("unprepared verification request = hit:%v metadataPrepared:%v", ok, metadataPrepared)
}
}
func TestPreparedDownloadRequestCacheRejectsChangedTrackAndExpiry(t *testing.T) {
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
resetPreparedDownloadRequestCacheForTest()
req := DownloadRequest{
ItemID: "item-2",
Service: "provider-a",
SpotifyID: "spotify-2",
TrackName: "Track",
ArtistName: "Artist",
}
key := downloadPreparationKey(req)
cachePreparedDownloadRequest(key, req)
changed := req
changed.SpotifyID = "spotify-other"
if _, _, ok := takePreparedDownloadRequest(downloadPreparationKey(changed), changed); ok {
t.Fatal("changed track must not reuse another track's prepared metadata")
}
preparedDownloadRequestsMu.Lock()
_, staleEntryExists := preparedDownloadRequests[req.ItemID]
preparedDownloadRequestsMu.Unlock()
if staleEntryExists {
t.Fatal("mismatched prepared request should be discarded")
}
cachePreparedDownloadRequest(key, req)
preparedDownloadRequestsMu.Lock()
entry := preparedDownloadRequests[req.ItemID]
entry.createdAt = time.Now().Add(-downloadPreparationCacheTTL)
preparedDownloadRequests[req.ItemID] = entry
preparedDownloadRequestsMu.Unlock()
if _, _, ok := takePreparedDownloadRequest(key, req); ok {
t.Fatal("expired prepared request must not be reused")
}
}
+201 -42
View File
@@ -1,19 +1,31 @@
package gobackend
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// isrcFileEntry caches the parse result for one file so index rebuilds only
// re-read files whose size or mtime changed.
type isrcFileEntry struct {
size int64
modTime int64 // UnixNano
isrc string // uppercase; empty when the file carries no ISRC tag
}
type ISRCIndex struct {
index map[string]string // ISRC (uppercase) -> file path
index map[string]string // ISRC (uppercase) -> file path
files map[string]isrcFileEntry // file path -> cached parse result
outputDir string
buildTime time.Time
buildTime atomic.Int64 // UnixNano of the last build or write
mu sync.RWMutex
}
@@ -22,14 +34,20 @@ var (
isrcIndexCacheMu sync.RWMutex
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
isrcIndexTTL = 5 * time.Minute
isrcIndexBuildWorkers = 4
)
func (idx *ISRCIndex) isFresh() bool {
return time.Since(time.Unix(0, idx.buildTime.Load())) < isrcIndexTTL
}
func GetISRCIndex(outputDir string) *ISRCIndex {
isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
if exists && idx.isFresh() {
return idx
}
@@ -42,7 +60,7 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
if exists && idx.isFresh() {
return idx
}
@@ -52,16 +70,37 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{
index: make(map[string]string),
files: make(map[string]isrcFileEntry),
outputDir: outputDir,
buildTime: time.Now(),
}
idx.buildTime.Store(time.Now().UnixNano())
if outputDir == "" {
return idx
}
// Reuse the previous build's per-file cache: files whose size and mtime
// are unchanged are adopted without touching their content, so a rebuild
// is normally just a stat walk.
prevFiles := map[string]isrcFileEntry{}
isrcIndexCacheMu.RLock()
if prev, ok := isrcIndexCache[outputDir]; ok {
prev.mu.RLock()
for path, entry := range prev.files {
prevFiles[path] = entry
}
prev.mu.RUnlock()
}
isrcIndexCacheMu.RUnlock()
startTime := time.Now()
fileCount := 0
type parseTask struct {
path string
size int64
modTime int64
}
var toParse []parseTask
reused := 0
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
@@ -73,18 +112,56 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return nil
}
metadata, err := ReadMetadata(path)
if err != nil || metadata.ISRC == "" {
size := info.Size()
modTime := info.ModTime().UnixNano()
if entry, ok := prevFiles[path]; ok && entry.size == size && entry.modTime == modTime {
idx.files[path] = entry
if entry.isrc != "" {
idx.index[entry.isrc] = path
}
reused++
return nil
}
idx.index[strings.ToUpper(metadata.ISRC)] = path
fileCount++
toParse = append(toParse, parseTask{path: path, size: size, modTime: modTime})
return nil
})
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
if len(toParse) > 0 {
// New/changed files: read only their Vorbis comment block, in
// parallel. Embedded cover art (megabytes per file) is never loaded.
isrcs := make([]string, len(toParse))
workerCount := isrcIndexBuildWorkers
if len(toParse) < workerCount {
workerCount = len(toParse)
}
tasks := make(chan int)
var wg sync.WaitGroup
for w := 0; w < workerCount; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range tasks {
isrcs[i] = strings.ToUpper(readFlacISRC(toParse[i].path))
}
}()
}
for i := range toParse {
tasks <- i
}
close(tasks)
wg.Wait()
for i, task := range toParse {
entry := isrcFileEntry{size: task.size, modTime: task.modTime, isrc: isrcs[i]}
idx.files[task.path] = entry
if entry.isrc != "" {
idx.index[entry.isrc] = task.path
}
}
}
fmt.Printf("[ISRCIndex] Built index for %s: %d files (%d parsed, %d cached) in %v\n",
outputDir, len(idx.files), len(toParse), reused, time.Since(startTime).Round(time.Millisecond))
isrcIndexCacheMu.Lock()
isrcIndexCache[outputDir] = idx
@@ -93,6 +170,78 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return idx
}
// readFlacISRC extracts the ISRC Vorbis comment from a FLAC file by walking
// the metadata block headers and reading only the VORBIS_COMMENT payload;
// picture and padding blocks are seeked past, never loaded. Returns "" when
// the file is not FLAC or carries no ISRC tag.
func readFlacISRC(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
magic := make([]byte, 4)
if _, err := io.ReadFull(f, magic); err != nil || string(magic) != "fLaC" {
return ""
}
header := make([]byte, 4)
for {
if _, err := io.ReadFull(f, header); err != nil {
return ""
}
last := header[0]&0x80 != 0
blockType := header[0] & 0x7F
length := int64(header[1])<<16 | int64(header[2])<<8 | int64(header[3])
if blockType == 4 { // VORBIS_COMMENT
if length > 16<<20 {
return ""
}
payload := make([]byte, length)
if _, err := io.ReadFull(f, payload); err != nil {
return ""
}
return vorbisCommentISRC(payload)
}
if last {
return ""
}
if _, err := f.Seek(length, io.SeekCurrent); err != nil {
return ""
}
}
}
func vorbisCommentISRC(payload []byte) string {
if len(payload) < 8 {
return ""
}
offset := int(binary.LittleEndian.Uint32(payload[0:4])) + 4
if offset < 4 || offset+4 > len(payload) {
return ""
}
count := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
offset += 4
for i := 0; i < count; i++ {
if offset+4 > len(payload) {
return ""
}
commentLen := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
offset += 4
if commentLen < 0 || offset+commentLen > len(payload) {
return ""
}
comment := payload[offset : offset+commentLen]
offset += commentLen
eq := strings.IndexByte(string(comment), '=')
if eq > 0 && strings.EqualFold(string(comment[:eq]), "ISRC") {
return strings.TrimSpace(string(comment[eq+1:]))
}
}
return ""
}
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
if isrc == "" {
return "", false
@@ -126,10 +275,30 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
upper := strings.ToUpper(isrc)
var entry *isrcFileEntry
if info, err := os.Stat(filePath); err == nil {
entry = &isrcFileEntry{
size: info.Size(),
modTime: info.ModTime().UnixNano(),
isrc: upper,
}
}
idx.index[strings.ToUpper(isrc)] = filePath
idx.mu.Lock()
idx.index[upper] = filePath
if entry != nil {
if idx.files == nil {
idx.files = make(map[string]isrcFileEntry)
}
idx.files[filePath] = *entry
}
idx.mu.Unlock()
// The index is write-maintained after every successful download;
// refreshing the timestamp keeps the TTL from forcing a full rebuild in
// the middle of the exact workload the index exists to serve.
idx.buildTime.Store(time.Now().UnixNano())
}
func InvalidateISRCCache(outputDir string) {
@@ -193,35 +362,25 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
isrcIdx := GetISRCIndex(outputDir)
var wg sync.WaitGroup
// A lookup is a single map read. Holding one read lock for the batch avoids
// one goroutine and one lock/unlock pair per track, which was slower and
// could create thousands of goroutines for large playlists.
isrcIdx.mu.RLock()
for i, track := range tracks {
wg.Add(1)
go func(resultIdx int, t struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
}) {
defer wg.Done()
result := FileExistenceResult{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
Exists: false,
result := FileExistenceResult{
ISRC: track.ISRC,
TrackName: track.TrackName,
ArtistName: track.ArtistName,
}
if track.ISRC != "" {
if filePath, exists := isrcIdx.index[strings.ToUpper(track.ISRC)]; exists {
result.Exists = true
result.FilePath = filePath
}
if t.ISRC != "" {
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
result.Exists = true
result.FilePath = filePath
}
}
results[resultIdx] = result
}(i, track)
}
results[i] = result
}
wg.Wait()
isrcIdx.mu.RUnlock()
resultJSON, err := json.Marshal(results)
if err != nil {
+52 -3919
View File
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
package gobackend
import (
"context"
"fmt"
"strings"
"time"
)
// GetTrackCacheSize and ClearTrackIDCache back the Settings cache screen. The
// track-ID cache is currently a no-op, so these report an empty cache and clear
// nothing, but the gomobile export contract is kept for the Dart/Kotlin callers.
func GetTrackCacheSize() int {
return 0
}
func ClearTrackIDCache() {
}
func 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) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := GetDeezerClient()
var data any
var err error
switch resourceType {
case "track":
data, err = client.GetTrack(ctx, resourceID)
case "album":
data, err = client.GetAlbum(ctx, resourceID)
case "artist":
data, err = client.GetArtist(ctx, resourceID)
case "playlist":
data, err = client.GetPlaylist(ctx, resourceID)
default:
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
}
if err != nil {
return "", err
}
return marshalJSONString(data)
}
func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := GetDeezerClient()
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
if err != nil {
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
return "", err
}
result := buildDeezerExtendedMetadataResult(metadata)
return marshalJSONString(result)
}
func SearchDeezerByISRC(isrc string) (string, error) {
return SearchDeezerByISRCForItemID(isrc, "")
}
func SearchDeezerByISRCForItemID(isrc string, itemID string) (string, error) {
parentCtx := context.Background()
if itemID != "" {
parentCtx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
if isDownloadCancelled(itemID) {
return "", ErrDownloadCancelled
}
}
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
client := GetDeezerClient()
track, err := client.SearchByISRC(ctx, isrc)
if err != nil {
if isDownloadCancelled(itemID) {
return "", ErrDownloadCancelled
}
return "", err
}
if isDownloadCancelled(itemID) {
return "", ErrDownloadCancelled
}
result := buildDeezerISRCSearchResult(track)
return marshalJSONString(result)
}
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
if metadata == nil {
return map[string]string{
"genre": "",
"label": "",
"copyright": "",
}
}
return map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
"copyright": metadata.Copyright,
}
}
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]any {
if track == nil {
return map[string]any{}
}
result := map[string]any{
"spotify_id": track.SpotifyID,
"artists": track.Artists,
"name": track.Name,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.Images,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
"disc_number": track.DiscNumber,
"total_discs": track.TotalDiscs,
"external_urls": track.ExternalURL,
"isrc": track.ISRC,
"album_id": track.AlbumID,
"artist_id": track.ArtistID,
"album_type": track.AlbumType,
"composer": track.Composer,
}
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
result["id"] = deezerID
result["track_id"] = deezerID
result["success"] = true
}
return result
}
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
songlink := NewSongLinkClient()
deezerClient := GetDeezerClient()
if resourceType == "track" {
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
}
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
}
return marshalJSONString(trackResp)
}
if resourceType == "album" {
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer album: %w", err)
}
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
}
return marshalJSONString(albumResp)
}
return "", fmt.Errorf("spotify to Deezer conversion only supported for tracks and albums: please search by name for %s", resourceType)
}
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetSpotifyIDFromDeezer(deezerTrackID)
}
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetTidalURLFromDeezer(deezerTrackID)
}
+493
View File
@@ -0,0 +1,493 @@
package gobackend
import (
"context"
"encoding/json"
"strings"
"time"
)
type DownloadRequest struct {
ContractVersion int `json:"contract_version,omitempty"`
ISRC string `json:"isrc"`
Service string `json:"service"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
CoverURL string `json:"cover_url"`
OutputDir string `json:"output_dir"`
OutputPath string `json:"output_path,omitempty"`
OutputFD int `json:"output_fd,omitempty"`
OutputExt string `json:"output_ext,omitempty"`
FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"`
EmbedMetadata bool `json:"embed_metadata"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
EmbedReplayGain bool `json:"embed_replaygain,omitempty"`
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
TidalHighFormat string `json:"tidal_high_format,omitempty"`
TrackNumber int `json:"track_number"`
PlaylistPosition int `json:"playlist_position,omitempty"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"`
TotalDiscs int `json:"total_discs,omitempty"`
ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"`
DurationMS int `json:"duration_ms"`
Source string `json:"source"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
LyricsMode string `json:"lyrics_mode,omitempty"`
UseExtensions bool `json:"use_extensions,omitempty"`
UseFallback bool `json:"use_fallback,omitempty"`
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
AllowQualityVariant bool `json:"allow_quality_variant,omitempty"`
QualityVariant string `json:"quality_variant,omitempty"`
SongLinkRegion string `json:"songlink_region,omitempty"`
}
type DownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ActualExtension string `json:"actual_extension,omitempty"`
ActualContainer string `json:"actual_container,omitempty"`
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
Service string `json:"service,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ISRC string `json:"isrc,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
}
type DownloadResult struct {
FilePath string
BitDepth int
SampleRate int
AudioCodec string
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
TotalTracks int
DiscNumber int
TotalDiscs int
ISRC string
CoverURL string
Genre string
Label string
Copyright string
Composer string
LyricsLRC string
DecryptionKey string
Decryption *DownloadDecryptionInfo
ActualExtension string
ActualContainer string
RequiresContainerConversion bool
}
func buildDownloadSuccessResponse(
req DownloadRequest,
result DownloadResult,
service string,
message string,
filePath string,
alreadyExists bool,
) DownloadResponse {
title := result.Title
if title == "" {
title = req.TrackName
}
artist := result.Artist
if artist == "" {
artist = req.ArtistName
}
// Preserve requested release metadata when available so mixed-provider
// fallback downloads from the same source album do not get split into
// different albums just because Tidal/Qobuz report variant titles/dates.
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
req,
result.Album,
result.ReleaseDate,
result.TrackNumber,
result.DiscNumber,
)
isrc := result.ISRC
if isrc == "" {
isrc = req.ISRC
}
genre := result.Genre
if genre == "" {
genre = req.Genre
}
label := result.Label
if label == "" {
label = req.Label
}
copyright := result.Copyright
if copyright == "" {
copyright = req.Copyright
}
composer := result.Composer
if composer == "" {
composer = req.Composer
}
coverURL := strings.TrimSpace(result.CoverURL)
if coverURL == "" {
coverURL = strings.TrimSpace(req.CoverURL)
}
return DownloadResponse{
Success: true,
Message: message,
FilePath: filePath,
AlreadyExists: alreadyExists,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
AudioCodec: result.AudioCodec,
ActualExtension: result.ActualExtension,
ActualContainer: result.ActualContainer,
RequiresContainerConversion: result.RequiresContainerConversion,
Service: service,
Title: title,
Artist: artist,
Album: album,
AlbumArtist: req.AlbumArtist,
ReleaseDate: releaseDate,
TrackNumber: trackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: discNumber,
TotalDiscs: req.TotalDiscs,
ISRC: isrc,
CoverURL: coverURL,
Genre: genre,
Label: label,
Copyright: copyright,
Composer: composer,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
}
}
func shouldSkipQualityProbe(filePath string) bool {
path := strings.TrimSpace(filePath)
if path == "" {
return true
}
if strings.HasPrefix(path, "/proc/self/fd/") {
return true
}
// Content URI and other non-filesystem schemes cannot be read directly by os.Open.
if strings.Contains(path, "://") {
return true
}
return false
}
func enrichResultQualityFromFile(result *DownloadResult) {
if result == nil {
return
}
path := strings.TrimSpace(result.FilePath)
if shouldSkipQualityProbe(path) {
if strings.HasPrefix(path, "/proc/self/fd/") {
LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path)
}
return
}
quality, qErr := GetAudioQuality(path)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.AudioCodec = quality.Codec
if quality.Codec != "" {
GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
return
}
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
}
func applyExtendedMetadataFields(
genre *string,
label *string,
copyright *string,
extMeta *AlbumExtendedMetadata,
) {
if extMeta == nil {
return
}
if genre != nil && *genre == "" && extMeta.Genre != "" {
*genre = extMeta.Genre
}
if label != nil && *label == "" && extMeta.Label != "" {
*label = extMeta.Label
}
if copyright != nil && *copyright == "" && extMeta.Copyright != "" {
*copyright = extMeta.Copyright
}
}
func enrichExtraMetadataByISRC(
logPrefix string,
isrc string,
genre *string,
label *string,
copyright *string,
) {
normalizedISRC := strings.TrimSpace(isrc)
if normalizedISRC == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
extMeta, err := fetchDeezerExtendedMetadataByISRC(ctx, normalizedISRC)
if err != nil {
GoLog("[%s] Failed to get extended metadata from Deezer: %v\n", logPrefix, err)
}
applyExtendedMetadataFields(genre, label, copyright, extMeta)
if genre != nil && *genre == "" {
musicBrainzGenre, err := fetchMusicBrainzGenreByISRC(normalizedISRC)
if err != nil {
GoLog("[%s] Failed to get genre from MusicBrainz: %v\n", logPrefix, err)
} else if musicBrainzGenre != "" {
*genre = musicBrainzGenre
GoLog("[%s] Genre fallback from MusicBrainz: %s\n", logPrefix, *genre)
}
}
currentGenre := ""
currentLabel := ""
currentCopyright := ""
if genre != nil {
currentGenre = *genre
}
if label != nil {
currentLabel = *label
}
if copyright != nil {
currentCopyright = *copyright
}
if currentGenre != "" || currentLabel != "" || currentCopyright != "" {
GoLog("[%s] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", logPrefix, currentGenre, currentLabel, currentCopyright)
}
}
func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req == nil {
return
}
if req.ISRC == "" {
return
}
if strings.TrimSpace(req.AlbumArtist) == "" {
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
if err != nil {
GoLog("[DownloadWithFallback] Failed to get album artist from MusicBrainz: %v\n", err)
} else if strings.TrimSpace(albumArtist) != "" {
req.AlbumArtist = strings.TrimSpace(albumArtist)
GoLog("[DownloadWithFallback] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
}
}
if req.Genre == "" || req.Label == "" || req.Copyright == "" {
enrichExtraMetadataByISRC(
"DownloadWithFallback",
req.ISRC,
&req.Genre,
&req.Label,
&req.Copyright,
)
}
}
func applySongLinkRegionFromRequest(req *DownloadRequest) {
if req == nil {
return
}
SetSongLinkRegion(req.SongLinkRegion)
}
// DownloadByStrategy routes all download requests through extension providers.
func DownloadByStrategy(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
normalizedBytes, err := json.Marshal(req)
if err != nil {
return errorResponse("Invalid request: " + err.Error())
}
normalizedJSON := string(normalizedBytes)
if req.UseExtensions {
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
if err != nil {
return errorResponse(err.Error())
}
return resp, nil
}
return errorResponse("Extension providers are disabled; built-in download providers have been retired")
}
func GetDownloadProgress() string {
progress := getProgress()
jsonBytes, _ := json.Marshal(progress)
return string(jsonBytes)
}
func GetAllDownloadProgress() string {
return GetMultiProgress()
}
func GetAllDownloadProgressDelta(sinceSeq int64) string {
return GetMultiProgressDelta(sinceSeq)
}
func InitItemProgress(itemID string) {
StartItemProgress(itemID)
}
func FinishItemProgress(itemID string) {
CompleteItemProgress(itemID)
}
func ClearItemProgress(itemID string) {
RemoveItemProgress(itemID)
}
func CancelDownload(itemID string) {
cancelDownload(itemID)
}
// ResetDownloadCancel drops a pre-registered cancellation flag for an item
// with no active download, so a user-initiated retry does not consume a stale
// cancel and abort instantly. Entries with live references are left alone.
func ResetDownloadCancel(itemID string) {
resetDownloadCancel(itemID)
}
func CleanupConnections() {
CloseIdleConnections()
}
func errorResponse(msg string) (string, error) {
errorType := classifyDownloadErrorType(msg)
resp := DownloadResponse{
Success: false,
Error: msg,
ErrorType: errorType,
}
s, _ := marshalJSONString(resp)
return s, nil
}
func classifyDownloadErrorType(msg string) string {
lowerMsg := strings.ToLower(msg)
if strings.Contains(lowerMsg, "isp blocking") ||
strings.Contains(lowerMsg, "try using vpn") ||
strings.Contains(lowerMsg, "change dns") {
return "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
return "cancelled"
} else if strings.Contains(lowerMsg, "verify_required") ||
strings.Contains(lowerMsg, "verification_required") ||
strings.Contains(lowerMsg, "verification required") ||
strings.Contains(lowerMsg, "needs verification") ||
strings.Contains(lowerMsg, "session is not authenticated") ||
strings.Contains(lowerMsg, "signed session is not authenticated") ||
strings.Contains(lowerMsg, "signed session expired") ||
strings.Contains(lowerMsg, "unauthorized") ||
strings.Contains(lowerMsg, "precondition required") ||
messageHasHTTPStatusCode(lowerMsg, "401") ||
messageHasHTTPStatusCode(lowerMsg, "428") {
return "verification_required"
} else if strings.Contains(lowerMsg, "rate limit") ||
messageHasHTTPStatusCode(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") {
return "rate_limit"
} else if strings.Contains(lowerMsg, "permission") ||
strings.Contains(lowerMsg, "operation not permitted") ||
strings.Contains(lowerMsg, "access denied") ||
strings.Contains(lowerMsg, "failed to create file") ||
strings.Contains(lowerMsg, "failed to create directory") {
return "permission"
} else if strings.Contains(lowerMsg, "not found") ||
strings.Contains(lowerMsg, "not available") ||
strings.Contains(lowerMsg, "no results") ||
strings.Contains(lowerMsg, "track not found") ||
strings.Contains(lowerMsg, "all services failed") {
return "not_found"
} else if strings.Contains(lowerMsg, "network") ||
strings.Contains(lowerMsg, "connection") ||
strings.Contains(lowerMsg, "timeout") ||
strings.Contains(lowerMsg, "dial") {
return "network"
}
return "unknown"
}
func messageHasHTTPStatusCode(lowerMsg, code string) bool {
return strings.Contains(lowerMsg, "http "+code) ||
strings.Contains(lowerMsg, "http status "+code) ||
strings.Contains(lowerMsg, "status "+code) ||
strings.Contains(lowerMsg, code+" for ") ||
strings.Contains(lowerMsg, code+":") ||
strings.Contains(lowerMsg, code+";")
}
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
package gobackend
func SetLibraryCoverCacheDirJSON(cacheDir string) {
SetLibraryCoverCacheDir(cacheDir)
}
func ScanLibraryFolderJSON(folderPath string) (string, error) {
return ScanLibraryFolder(folderPath)
}
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
}
func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) {
return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath)
}
func GetLibraryScanProgressJSON() string {
return GetLibraryScanProgress()
}
func CancelLibraryScanJSON() {
CancelLibraryScan()
}
func ReadAudioMetadataJSON(filePath string) (string, error) {
return ReadAudioMetadata(filePath)
}
func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) {
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey)
}
+191
View File
@@ -0,0 +1,191 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"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) {
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
return lyrics, nil
}
return "", nil
}
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
if lyricsData.Instrumental {
return "[instrumental:true]", nil
}
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil
}
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
source := extractLyricsSourceFromLRC(lyrics)
if source == "" {
source = "Embedded"
}
result := map[string]any{
"lyrics": lyrics,
"source": source,
"sync_type": "EMBEDDED",
"instrumental": false,
}
return marshalJSONString(result)
}
result := map[string]any{
"lyrics": "",
"source": "",
"sync_type": "",
"instrumental": false,
}
return marshalJSONString(result)
}
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
lrcContent := ""
if lyricsData.Instrumental {
lrcContent = "[instrumental:true]"
} else {
lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName)
}
result := map[string]any{
"lyrics": lrcContent,
"source": lyricsData.Source,
"sync_type": lyricsData.SyncType,
"instrumental": lyricsData.Instrumental,
}
return marshalJSONString(result)
}
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
err := EmbedLyrics(filePath, lyrics)
if err != nil {
return errorResponse("Failed to embed lyrics: " + err.Error())
}
resp := map[string]any{
"success": true,
"message": "Lyrics embedded successfully",
}
s, _ := marshalJSONString(resp)
return s, nil
}
func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string, audioFilePath string) error {
// If the audio file already has embedded lyrics or a sidecar .lrc,
// use those directly instead of making redundant network requests.
if audioFilePath != "" {
existing, err := ExtractLyrics(audioFilePath)
if err == nil && strings.TrimSpace(existing) != "" {
if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil {
return fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC from embedded/sidecar to: %s\n", outputPath)
return nil
}
}
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return fmt.Errorf("lyrics not found: %w", err)
}
if lyrics.Instrumental {
return fmt.Errorf("track is instrumental, no lyrics available")
}
lrcContent := convertToLRCWithMetadata(lyrics, trackName, artistName)
if lrcContent == "" {
return fmt.Errorf("failed to generate LRC content")
}
if err := os.WriteFile(outputPath, []byte(lrcContent), 0644); err != nil {
return fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC to: %s (%d lines)\n", outputPath, len(lyrics.Lines))
return nil
}
func SetLyricsProvidersJSON(providersJSON string) error {
var providers []string
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
return err
}
SetLyricsProviderOrder(providers)
return nil
}
func GetLyricsProvidersJSON() (string, error) {
providers := GetLyricsProviderOrder()
return marshalJSONString(providers)
}
func GetAvailableLyricsProvidersJSON() (string, error) {
providers := GetAvailableLyricsProviders()
return marshalJSONString(providers)
}
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
opts := GetLyricsFetchOptions()
if strings.TrimSpace(optionsJSON) != "" {
if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil {
return err
}
}
SetLyricsFetchOptions(opts)
return nil
}
func GetLyricsFetchOptionsJSON() (string, error) {
opts := GetLyricsFetchOptions()
return marshalJSONString(opts)
}
+572
View File
@@ -0,0 +1,572 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func applyAudioMetadataToResult(result map[string]any, meta *AudioMetadata) {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["total_tracks"] = meta.TotalTracks
result["disc_number"] = meta.DiscNumber
result["total_discs"] = meta.TotalDiscs
result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre
result["label"] = meta.Label
result["copyright"] = meta.Copyright
result["composer"] = meta.Composer
result["comment"] = meta.Comment
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
}
func successMethodJSON(method string) (string, error) {
return marshalJSONString(map[string]any{"success": true, "method": method})
}
func ReadFileMetadata(filePath string) (string, error) {
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac")
isMp3 := strings.HasSuffix(lower, ".mp3")
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
isApe := strings.HasSuffix(lower, ".ape")
isWv := strings.HasSuffix(lower, ".wv")
isMpc := strings.HasSuffix(lower, ".mpc")
isWav := strings.HasSuffix(lower, ".wav")
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
result := map[string]any{
"title": "",
"artist": "",
"album": "",
"album_artist": "",
"date": "",
"track_number": 0,
"total_tracks": 0,
"disc_number": 0,
"total_discs": 0,
"isrc": "",
"lyrics": "",
"genre": "",
"label": "",
"copyright": "",
"composer": "",
"comment": "",
"duration": 0,
"format": "",
"audio_codec": "",
}
if isFlac {
result["format"] = "flac"
result["audio_codec"] = "flac"
metadata, err := ReadMetadata(filePath)
if err != nil {
// File may have wrong extension (e.g. opus saved as .flac).
// Try Ogg/Opus parser as fallback before giving up.
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
oggMeta, oggErr := ReadOggVorbisComments(filePath)
if oggErr == nil && oggMeta != nil {
result["title"] = oggMeta.Title
result["artist"] = oggMeta.Artist
result["album"] = oggMeta.Album
result["album_artist"] = oggMeta.AlbumArtist
result["date"] = oggMeta.Date
if oggMeta.Date == "" {
result["date"] = oggMeta.Year
}
result["track_number"] = oggMeta.TrackNumber
result["total_tracks"] = oggMeta.TotalTracks
result["disc_number"] = oggMeta.DiscNumber
result["total_discs"] = oggMeta.TotalDiscs
result["isrc"] = oggMeta.ISRC
result["lyrics"] = oggMeta.Lyrics
result["genre"] = oggMeta.Genre
result["composer"] = oggMeta.Composer
result["comment"] = oggMeta.Comment
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
if quality.Bitrate > 0 {
result["bitrate"] = quality.Bitrate / 1000
}
}
result["format"] = "opus"
result["audio_codec"] = "opus"
} else {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
} else {
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["total_tracks"] = metadata.TotalTracks
result["disc_number"] = metadata.DiscNumber
result["total_discs"] = metadata.TotalDiscs
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
result["replaygain_track_gain"] = metadata.ReplayGainTrackGain
result["replaygain_track_peak"] = metadata.ReplayGainTrackPeak
result["replaygain_album_gain"] = metadata.ReplayGainAlbumGain
result["replaygain_album_peak"] = metadata.ReplayGainAlbumPeak
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.Codec != "" {
result["audio_codec"] = quality.Codec
}
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
}
} else if isM4A {
result["format"] = "m4a"
meta, err := ReadM4ATags(filePath)
if err == nil && meta != nil {
applyAudioMetadataToResult(result, meta)
}
quality, qualityErr := GetM4AQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
result["audio_codec"] = quality.Codec
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
result["format"] = format
}
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
result["bitrate"] = quality.Bitrate
}
}
} else if isMp3 {
result["format"] = "mp3"
result["audio_codec"] = "mp3"
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
applyAudioMetadataToResult(result, meta)
}
quality, qualityErr := GetMP3Quality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
if quality.Bitrate > 0 {
result["bitrate"] = quality.Bitrate / 1000
}
}
} else if isOgg {
result["format"] = "opus"
result["audio_codec"] = "opus"
meta, err := ReadOggVorbisComments(filePath)
if err == nil && meta != nil {
applyAudioMetadataToResult(result, meta)
}
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
if quality.Bitrate > 0 {
result["bitrate"] = quality.Bitrate / 1000
}
}
} else if isApe || isWv || isMpc {
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
result["audio_codec"] = result["format"]
apeTag, apeErr := ReadAPETags(filePath)
if apeErr == nil && apeTag != nil {
meta := APETagToAudioMetadata(apeTag)
if meta != nil {
applyAudioMetadataToResult(result, meta)
}
}
} else if isWav || isAiff {
var meta *AudioMetadata
var quality *WAVQuality
var qualityErr error
if isAiff {
result["format"] = "aiff"
result["audio_codec"] = "pcm"
meta, _ = ReadAIFFTags(filePath)
quality, qualityErr = GetAIFFQuality(filePath)
} else {
result["format"] = "wav"
result["audio_codec"] = "pcm"
meta, _ = ReadWAVTags(filePath)
quality, qualityErr = GetWAVQuality(filePath)
}
if meta != nil {
applyAudioMetadataToResult(result, meta)
}
if qualityErr == nil && quality != nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("unsupported file format: %s", filePath)
}
return marshalJSONString(result)
}
// ParseCueSheet is called from Dart to get track listing and timing data for CUE splitting.
// audioDir, if non-empty, overrides the directory used for resolving the
// referenced audio file (useful for SAF temp file scenarios).
func ParseCueSheet(cuePath string, audioDir string) (string, error) {
return ParseCueFileJSON(cuePath, audioDir)
}
// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of
// LibraryScanResult entries (one per track). This is the SAF-friendly variant:
// - audioDir overrides where the referenced audio file is resolved
// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI)
// - fileModTime is stamped on every result (pass 0 to stat cuePath instead)
func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
if err != nil {
return "[]", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
}
return string(jsonBytes), nil
}
func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
results, err := ScanCueFileForLibraryExtWithCoverCacheKey(
cuePath,
audioDir,
virtualPathPrefix,
fileModTime,
coverCacheKey,
scanTime,
)
if err != nil {
return "[]", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
}
return string(jsonBytes), nil
}
// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes
// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be
// written natively after the FFmpeg metadata pass for the values to persist.
// Only keys present in the JSON are touched; an empty value clears the tag.
func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) {
var fields map[string]string
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
return "", fmt.Errorf("invalid metadata JSON: %w", err)
}
if err := EditM4AFreeformText(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A freeform tags: %w", err)
}
return successMethodJSON("native_m4a_freeform")
}
// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO
// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when
// the file is not AC-4.
func EnsureAC4Config(filePath, sourcePath string) (string, error) {
if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil {
return "", fmt.Errorf("failed to finalize AC-4 container: %w", err)
}
return `{"success":true}`, nil
}
// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON
// "handled" field reports whether the file was AC-4 (true) so the caller can
// skip the FFmpeg metadata pass that would re-wrap it as QuickTime.
func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) {
handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath)
if err != nil {
return "", fmt.Errorf("failed to write AC-4 metadata: %w", err)
}
resp := map[string]any{"success": true, "handled": handled}
s, _ := marshalJSONString(resp)
return s, nil
}
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
var fields map[string]string
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
return "", fmt.Errorf("invalid metadata JSON: %w", err)
}
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
isWavFile := strings.HasSuffix(lower, ".wav")
isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
coverPath := strings.TrimSpace(fields["cover_path"])
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
if err := EditM4AReplayGain(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
}
return successMethodJSON("native_m4a_replaygain")
}
if isFlac {
// A .flac name does not guarantee FLAC content: providers sometimes
// deliver an MP4/M4A stream that ends up under the requested name.
// The FLAC writer would fail "fLaC head incorrect" on every attempt,
// so detect the mismatch up front and say what is actually wrong.
if isMP4ContainerFile(filePath) {
return "", fmt.Errorf(
"failed to write FLAC metadata: file is an MP4/M4A stream under a .flac name; rename it to .m4a",
)
}
if err := EditFlacFields(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
}
return successMethodJSON("native")
}
// WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively.
if isWavFile {
if err := WriteWAVTags(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write WAV metadata: %w", err)
}
return successMethodJSON("native_wav")
}
if isAiffFile {
if err := WriteAIFFTags(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
}
return successMethodJSON("native_aiff")
}
if isApeFile {
meta := audioMetadataFromEditFields(fields)
newItems := AudioMetadataToAPEItems(meta)
// If a cover image was provided, embed it as a binary APE item.
// APEv2 cover format: "cover.jpg\0<binary image data>", flagged binary.
if coverPath != "" {
coverData, coverErr := os.ReadFile(coverPath)
if coverErr == nil && len(coverData) > 0 {
// The value is "filename\0" + raw bytes. We store the
// description as the Value field, but since the item is
// flagged binary, the writer serializes it verbatim.
desc := "cover.jpg\x00"
binaryValue := desc + string(coverData)
newItems = append(newItems, APETagItem{
Key: "Cover Art (Front)",
Value: binaryValue,
Flags: apeItemFlagBinary,
})
}
}
// Build the set of APE keys that the edit explicitly controls.
// Even if the value is empty (user cleared the field), the old
// value must be removed during merge.
overrideKeys := apeKeysFromFields(fields)
if coverPath != "" {
overrideKeys["COVER ART (FRONT)"] = struct{}{}
}
// Read existing tags so we can merge rather than replace.
// This preserves cover art and custom items not in the edit set.
existingTag, _ := ReadAPETags(filePath)
var finalItems []APETagItem
if existingTag != nil && len(existingTag.Items) > 0 {
finalItems = MergeAPEItems(existingTag.Items, newItems, overrideKeys)
} else {
finalItems = newItems
}
tag := &APETag{
Version: apeTagVersion2,
Items: finalItems,
}
if err := WriteAPETags(filePath, tag); err != nil {
return "", fmt.Errorf("failed to write APE tags: %w", err)
}
return successMethodJSON("native_ape")
}
// MP3, Ogg/Opus, and M4A have native editors that preserve foreign
// tags and skip the ffmpeg remux. Any failure falls back to the ffmpeg
// response so callers keep the old behavior for exotic files.
isMp3 := strings.HasSuffix(lower, ".mp3")
isOggFile := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
if isMp3 {
if err := EditMP3Fields(filePath, fields); err != nil {
GoLog("[Metadata] Native MP3 edit failed, falling back to ffmpeg: %v\n", err)
} else {
return successMethodJSON("native_mp3")
}
}
if isOggFile {
if err := EditOggFields(filePath, fields); err != nil {
GoLog("[Metadata] Native Ogg edit failed, falling back to ffmpeg: %v\n", err)
} else {
return successMethodJSON("native_ogg")
}
}
if isM4AFile || isMP4ContainerFile(filePath) {
if err := EditM4AFields(filePath, fields); err != nil {
GoLog("[Metadata] Native M4A edit failed, falling back to ffmpeg: %v\n", err)
} else {
return successMethodJSON("native_m4a")
}
}
resp := map[string]any{
"success": true,
"method": "ffmpeg",
"fields": fields,
}
s, _ := marshalJSONString(resp)
return s, nil
}
func isMP4ContainerFile(filePath string) bool {
f, err := os.Open(filePath)
if err != nil {
return false
}
defer f.Close()
header := make([]byte, 12)
n, err := f.Read(header)
if err != nil || n < 8 {
return false
}
return string(header[4:8]) == "ftyp"
}
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
allowed := map[string]struct{}{
"replaygain_track_gain": {},
"replaygain_track_peak": {},
"replaygain_album_gain": {},
"replaygain_album_peak": {},
}
hasReplayGain := false
for key, value := range fields {
if strings.TrimSpace(value) == "" {
continue
}
if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok {
hasReplayGain = true
continue
}
return false
}
return hasReplayGain
}
// RewriteSplitArtistTagsExport rewrites ARTIST and ALBUMARTIST Vorbis
// comments in a FLAC file as multiple separate entries (one per artist).
// Call this after FFmpeg metadata embedding to fix split artist tags,
// since FFmpeg deduplicates -metadata keys and only keeps the last value.
func RewriteSplitArtistTagsExport(filePath, artist, albumArtist string) (string, error) {
err := RewriteSplitArtistTags(filePath, artist, albumArtist)
if err != nil {
return errorResponse("Failed to rewrite artist tags: " + err.Error())
}
resp := map[string]any{
"success": true,
"message": "Split artist tags written successfully",
}
s, _ := marshalJSONString(resp)
return s, nil
}
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
data, err := downloadCoverToMemory(coverURL, maxQuality)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Downloaded cover to: %s (%d KB)\n", outputPath, len(data)/1024)
return nil
}
func ExtractCoverToFile(audioPath string, outputPath string) error {
lower := strings.ToLower(audioPath)
var coverData []byte
var err error
if strings.HasSuffix(lower, ".flac") {
coverData, err = ExtractCoverArt(audioPath)
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
coverData, err = extractCoverFromM4A(audioPath)
} else if strings.HasSuffix(lower, ".mp3") {
coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
coverData, _, err = extractOggCoverArt(audioPath)
} else {
return fmt.Errorf("unsupported audio format for cover extraction")
}
if err != nil {
return fmt.Errorf("failed to extract cover: %w", err)
}
if err := os.WriteFile(outputPath, coverData, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Extracted cover art to: %s (%d KB)\n", outputPath, len(coverData)/1024)
return nil
}
+202
View File
@@ -0,0 +1,202 @@
package gobackend
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
type musicBrainzTag struct {
Count int `json:"count"`
Name string `json:"name"`
}
type musicBrainzRecordingResponse struct {
Recordings []struct {
Tags []musicBrainzTag `json:"tags"`
} `json:"recordings"`
}
type musicBrainzArtistCredit struct {
Name string `json:"name"`
JoinPhrase string `json:"joinphrase"`
}
type musicBrainzRelease struct {
Title string `json:"title"`
ArtistCredit []musicBrainzArtistCredit `json:"artist-credit"`
}
type musicBrainzAlbumArtistResponse struct {
Recordings []struct {
Releases []musicBrainzRelease `json:"releases"`
} `json:"recordings"`
}
func formatMusicBrainzGenre(tags []musicBrainzTag) string {
if len(tags) == 0 {
return ""
}
caser := cases.Title(language.English)
seen := make(map[string]struct{}, len(tags))
maxCount := -1
bestTag := ""
for _, tag := range tags {
name := strings.TrimSpace(tag.Name)
if name == "" {
continue
}
key := strings.ToLower(name)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
formatted := caser.String(name)
if tag.Count > maxCount {
maxCount = tag.Count
bestTag = formatted
}
}
return bestTag
}
func formatMusicBrainzArtistCredit(credits []musicBrainzArtistCredit) string {
var builder strings.Builder
for _, credit := range credits {
name := strings.TrimSpace(credit.Name)
if name == "" {
continue
}
builder.WriteString(name)
builder.WriteString(credit.JoinPhrase)
}
return strings.TrimSpace(builder.String())
}
func selectMusicBrainzAlbumArtist(releases []musicBrainzRelease, albumName string) string {
if len(releases) == 0 {
return ""
}
normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName))
if normalizedAlbum != "" {
for _, release := range releases {
if strings.ToLower(strings.TrimSpace(release.Title)) != normalizedAlbum {
continue
}
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
return albumArtist
}
}
}
for _, release := range releases {
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
return albumArtist
}
}
return ""
}
// fetchMusicBrainzRecordingByISRC queries the MusicBrainz recording endpoint
// for the given ISRC with the supplied inc= parameter, retrying up to 3 times,
// and decodes the JSON response into payload. It returns the normalized ISRC.
func fetchMusicBrainzRecordingByISRC(isrc string, inc string, payload any) (string, error) {
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
if normalizedISRC == "" {
return "", fmt.Errorf("no ISRC provided")
}
client := NewMetadataHTTPClient(10 * time.Second)
query := fmt.Sprintf("isrc:%s", normalizedISRC)
reqURL := fmt.Sprintf(
"%s/recording?query=%s&fmt=json&inc=%s",
musicBrainzAPIBase,
url.QueryEscape(query),
inc,
)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", getRandomUserAgent())
var resp *http.Response
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
resp, lastErr = client.Do(req)
if lastErr == nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close()
}
if attempt < 2 {
time.Sleep(2 * time.Second)
}
}
if lastErr != nil {
return "", lastErr
}
if resp == nil {
return "", fmt.Errorf("MusicBrainz request failed without response")
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(payload); err != nil {
return "", err
}
return normalizedISRC, nil
}
func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) {
var payload musicBrainzAlbumArtistResponse
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "releases+artist-credits", &payload)
if err != nil {
return "", err
}
for _, recording := range payload.Recordings {
if albumArtist := selectMusicBrainzAlbumArtist(recording.Releases, albumName); albumArtist != "" {
return albumArtist, nil
}
}
return "", fmt.Errorf("no MusicBrainz album artist found for ISRC: %s", normalizedISRC)
}
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
var payload musicBrainzRecordingResponse
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "tags", &payload)
if err != nil {
return "", err
}
if len(payload.Recordings) == 0 {
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
}
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
if genre == "" {
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
}
return genre, nil
}
+785
View File
@@ -0,0 +1,785 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return GetDeezerClient().GetExtendedMetadataByISRC(ctx, isrc)
}
var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC
var fetchMusicBrainzAlbumArtistByISRC = FetchMusicBrainzAlbumArtistByISRC
type reEnrichRequest struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
LyricsMode string `json:"lyrics_mode,omitempty"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ReleaseDate string `json:"release_date"`
ISRC string `json:"isrc"`
Genre string `json:"genre"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Composer string `json:"composer"`
DurationMs int64 `json:"duration_ms"`
SearchOnline bool `json:"search_online"`
UpdateFields []string `json:"update_fields,omitempty"`
}
// shouldUpdateField returns true if the given field group should be updated.
// When UpdateFields is empty/nil, all fields are updated (backward compatible).
func (r *reEnrichRequest) shouldUpdateField(field string) bool {
if len(r.UpdateFields) == 0 {
return true
}
for _, f := range r.UpdateFields {
if f == field {
return true
}
}
return false
}
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
// callers that do not send lyrics_mode are unaffected.
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
}
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
// next to the audio file. Only 'external' and 'both' request a sidecar.
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
return mode == "external" || mode == "both"
}
// reEnrichSameRelease reports whether the candidate track appears to come
// from the same release as the file's existing album. An ISRC identifies a
// recording, not a release: the same song often also resolves to a
// compilation, whose album name, cover, and track positions must not
// replace the original release's.
func reEnrichSameRelease(currentAlbum, candidateAlbum string) bool {
if isPlaceholderReEnrichValue(currentAlbum) ||
strings.TrimSpace(candidateAlbum) == "" {
return true
}
return titlesMatch(currentAlbum, candidateAlbum)
}
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
if req == nil {
return
}
sameRelease := reEnrichSameRelease(req.AlbumName, track.AlbumName)
if !sameRelease {
GoLog("[ReEnrich] Candidate album %q differs from file album %q; keeping release identity (album, cover, positions, date)\n",
track.AlbumName, req.AlbumName)
}
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else if track.QobuzID != "" {
req.SpotifyID = "qobuz:" + track.QobuzID
} else if track.TidalID != "" {
req.SpotifyID = "tidal:" + track.TidalID
} else if track.ID != "" {
req.SpotifyID = track.ID
}
if req.shouldUpdateField("basic_tags") {
if track.Name != "" {
req.TrackName = track.Name
}
if track.Artists != "" {
req.ArtistName = track.Artists
}
if sameRelease {
if track.AlbumName != "" {
req.AlbumName = track.AlbumName
}
if track.AlbumArtist != "" {
req.AlbumArtist = track.AlbumArtist
}
}
}
if sameRelease && req.shouldUpdateField("track_info") {
if track.TrackNumber > 0 {
req.TrackNumber = track.TrackNumber
}
if track.TotalTracks > 0 {
req.TotalTracks = track.TotalTracks
}
if track.DiscNumber > 0 {
req.DiscNumber = track.DiscNumber
}
if track.TotalDiscs > 0 {
req.TotalDiscs = track.TotalDiscs
}
}
if req.shouldUpdateField("release_info") {
if sameRelease && track.ReleaseDate != "" {
req.ReleaseDate = track.ReleaseDate
}
if track.ISRC != "" {
req.ISRC = track.ISRC
}
}
if sameRelease && req.shouldUpdateField("cover") {
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
req.CoverURL = coverURL
}
}
if track.DurationMS > 0 {
req.DurationMs = int64(track.DurationMS)
}
if req.shouldUpdateField("extra") {
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
if track.Composer != "" {
req.Composer = track.Composer
}
}
}
func isPlaceholderReEnrichValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "unknown", "unknown artist", "unknown title", "unknown album":
return true
default:
return false
}
}
func buildReEnrichSearchQuery(req reEnrichRequest) string {
parts := make([]string, 0, 2)
if !isPlaceholderReEnrichValue(req.TrackName) {
parts = append(parts, strings.TrimSpace(req.TrackName))
}
if !isPlaceholderReEnrichValue(req.ArtistName) {
parts = append(parts, strings.TrimSpace(req.ArtistName))
}
if len(parts) == 0 && !isPlaceholderReEnrichValue(req.AlbumName) {
parts = append(parts, strings.TrimSpace(req.AlbumName))
}
return strings.TrimSpace(strings.Join(parts, " "))
}
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
return DownloadRequest{
TrackName: req.TrackName,
ArtistName: req.ArtistName,
AlbumName: req.AlbumName,
ReleaseDate: req.ReleaseDate,
ISRC: req.ISRC,
DurationMS: int(req.DurationMs),
ArtistTagMode: req.ArtistTagMode,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
TotalDiscs: req.TotalDiscs,
Composer: req.Composer,
}
}
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
metadata := map[string]string{}
if req.shouldUpdateField("basic_tags") {
if req.TrackName != "" {
metadata["TITLE"] = req.TrackName
}
if req.ArtistName != "" {
metadata["ARTIST"] = req.ArtistName
}
if req.AlbumName != "" {
metadata["ALBUM"] = req.AlbumName
}
if req.AlbumArtist != "" {
metadata["ALBUMARTIST"] = req.AlbumArtist
}
}
if req.shouldUpdateField("release_info") {
if req.ReleaseDate != "" {
metadata["DATE"] = req.ReleaseDate
}
if req.ISRC != "" {
metadata["ISRC"] = req.ISRC
}
}
if req.shouldUpdateField("extra") {
if req.Genre != "" {
metadata["GENRE"] = req.Genre
}
if req.Label != "" {
metadata["ORGANIZATION"] = req.Label
}
if req.Copyright != "" {
metadata["COPYRIGHT"] = req.Copyright
}
if req.Composer != "" {
metadata["COMPOSER"] = req.Composer
}
}
if req.shouldUpdateField("track_info") {
if req.TrackNumber > 0 {
metadata["TRACKNUMBER"] = formatIndexValue(req.TrackNumber, req.TotalTracks)
}
if req.DiscNumber > 0 {
metadata["DISCNUMBER"] = formatIndexValue(req.DiscNumber, req.TotalDiscs)
}
}
if req.shouldUpdateField("lyrics") {
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
metadata["LYRICS"] = lyricsLRC
metadata["UNSYNCEDLYRICS"] = lyricsLRC
}
}
return metadata
}
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
if len(tracks) == 0 {
return nil
}
downloadReq := reEnrichDownloadRequest(req)
currentISRC := strings.TrimSpace(req.ISRC)
currentAlbum := strings.TrimSpace(req.AlbumName)
effectiveTrackName := req.TrackName
if isPlaceholderReEnrichValue(effectiveTrackName) {
effectiveTrackName = ""
}
effectiveArtistName := req.ArtistName
if isPlaceholderReEnrichValue(effectiveArtistName) {
effectiveArtistName = ""
}
var best *ExtTrackMetadata
bestScore := -1 << 30
for i := range tracks {
track := &tracks[i]
score := 0
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
if !exactISRCMatch {
if effectiveTrackName != "" && !titleMatches {
continue
}
if effectiveArtistName != "" && !artistMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
continue
}
}
if verified {
score += 2000
}
if exactISRCMatch {
score += 10000
}
if titleMatches {
score += 400
}
if artistMatches {
score += 320
}
if currentAlbum != "" && track.AlbumName != "" {
switch {
case albumMatches:
score += 120
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
score += 50
}
}
if req.DurationMs > 0 && track.DurationMS > 0 {
diff := int(req.DurationMs/1000) - (track.DurationMS / 1000)
if diff < 0 {
diff = -diff
}
if diff <= 10 {
score += 80
}
}
if track.ReleaseDate != "" {
score += 70
}
if track.TrackNumber > 0 {
score += 20
}
if track.DiscNumber > 0 {
score += 10
}
if track.ISRC != "" {
score += 40
}
if best == nil || score > bestScore {
best = track
bestScore = score
}
}
return best
}
func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrackMetadata {
if track == nil {
return nil
}
deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:"))
return &ExtTrackMetadata{
ID: track.SpotifyID,
Name: track.Name,
Artists: track.Artists,
AlbumName: track.AlbumName,
AlbumArtist: track.AlbumArtist,
DurationMS: track.DurationMS,
CoverURL: track.Images,
Images: track.Images,
ReleaseDate: track.ReleaseDate,
TrackNumber: track.TrackNumber,
TotalTracks: track.TotalTracks,
DiscNumber: track.DiscNumber,
TotalDiscs: track.TotalDiscs,
ISRC: track.ISRC,
ProviderID: providerID,
DeezerID: deezerID,
SpotifyID: track.SpotifyID,
Composer: track.Composer,
Explicit: track.Explicit,
}
}
func normalizeReEnrichSpotifyTrackID(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if extracted := extractSpotifyIDFromURL(trimmed); extracted != "" {
return extracted
}
if len(trimmed) == 22 && !strings.Contains(trimmed, ":") && !strings.Contains(trimmed, "/") {
return trimmed
}
return ""
}
func resolveReEnrichTrackFromIdentifiers(req reEnrichRequest) (*ExtTrackMetadata, error) {
deezerClient := GetDeezerClient()
downloadReq := reEnrichDownloadRequest(req)
if isrc := strings.TrimSpace(req.ISRC); isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
track, err := deezerClient.SearchByISRC(ctx, isrc)
cancel()
if err == nil && track != nil {
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
return extTrackFromTrackMetadata(track, "deezer"), nil
}
}
}
sourceTrackID := strings.TrimSpace(req.SpotifyID)
if sourceTrackID == "" {
return nil, nil
}
deezerID := strings.TrimSpace(strings.TrimPrefix(sourceTrackID, "deezer:"))
if deezerID == sourceTrackID {
deezerID = extractDeezerIDFromURL(sourceTrackID)
}
if deezerID == "" {
spotifyID := normalizeReEnrichSpotifyTrackID(sourceTrackID)
if spotifyID != "" {
resolvedDeezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyID)
if err == nil {
deezerID = strings.TrimSpace(resolvedDeezerID)
}
}
}
if deezerID == "" {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil || trackResp == nil {
return nil, err
}
track := &trackResp.Track
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if !trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
return nil, nil
}
return extTrackFromTrackMetadata(track, "deezer"), nil
}
func preferredReleaseMetadata(
req DownloadRequest,
album string,
releaseDate string,
trackNumber int,
discNumber int,
) (string, string, int, int) {
preferredAlbum := strings.TrimSpace(req.AlbumName)
if preferredAlbum == "" {
preferredAlbum = album
}
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
if preferredReleaseDate == "" {
preferredReleaseDate = releaseDate
}
preferredTrackNumber := req.TrackNumber
if preferredTrackNumber == 0 {
preferredTrackNumber = trackNumber
}
preferredDiscNumber := req.DiscNumber
if preferredDiscNumber == 0 {
preferredDiscNumber = discNumber
}
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
}
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
// complete metadata from the internet before embedding.
func ReEnrichFile(requestJSON string) (string, error) {
var req reEnrichRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", fmt.Errorf("failed to parse request: %w", err)
}
if req.FilePath == "" {
return "", fmt.Errorf("file_path is required")
}
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
if req.SearchOnline {
found := false
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
manager := getExtensionManager()
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
applyReEnrichTrackMetadata(&req, *identifierTrack)
found = true
}
searchQuery := buildReEnrichSearchQuery(req)
if searchQuery != "" {
GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery)
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
if searchErr == nil && len(tracks) > 0 {
track := selectBestReEnrichTrack(req, tracks)
if track != nil {
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
applyReEnrichTrackMetadata(&req, *track)
found = true
}
} else if searchErr != nil {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
}
} else {
GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
}
if req.shouldUpdateField("basic_tags") && req.AlbumArtist == "" && req.ISRC != "" {
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
if err != nil {
GoLog("[ReEnrich] Failed to get album artist from MusicBrainz: %v\n", err)
} else if strings.TrimSpace(albumArtist) != "" {
req.AlbumArtist = strings.TrimSpace(albumArtist)
GoLog("[ReEnrich] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
found = true
}
}
// Try to enrich extra metadata from ISRC if not already set.
if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
}
if !found {
GoLog("[ReEnrich] No online match found, using existing metadata\n")
}
}
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
lower := strings.ToLower(req.FilePath)
isFlac := strings.HasSuffix(lower, ".flac")
var coverTempPath string
var coverDataBytes []byte
if req.CoverURL != "" && req.shouldUpdateField("cover") {
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
if err != nil {
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
} else {
coverDataBytes = coverData
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
// MP3/Opus requires a real image file path for Dart FFmpeg.
// FLAC uses in-memory embed and does not require temp files.
if !isFlac {
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
if err != nil {
fallbackDir := filepath.Dir(req.FilePath)
if fallbackDir == "" || fallbackDir == "." {
GoLog("[ReEnrich] Failed to create cover temp file: %v\n", err)
} else {
tmpFile, err = os.CreateTemp(fallbackDir, "reenrich_cover_*.jpg")
if err != nil {
GoLog("[ReEnrich] Failed to create cover temp file (fallback dir %s): %v\n", fallbackDir, err)
}
}
}
if err == nil && tmpFile != nil {
coverTempPath = tmpFile.Name()
if _, writeErr := tmpFile.Write(coverData); writeErr != nil {
GoLog("[ReEnrich] Failed writing cover temp file: %v\n", writeErr)
tmpFile.Close()
os.Remove(coverTempPath)
coverTempPath = ""
} else if closeErr := tmpFile.Close(); closeErr != nil {
GoLog("[ReEnrich] Failed closing cover temp file: %v\n", closeErr)
os.Remove(coverTempPath)
coverTempPath = ""
}
}
}
}
}
// Only cleanup cover temp for FLAC (native embed).
// For MP3/Opus, Dart needs the file for FFmpeg — Dart handles cleanup.
cleanupCover := true
defer func() {
if cleanupCover && coverTempPath != "" {
os.Remove(coverTempPath)
}
}()
// Preserve existing lyrics when online enrichment does not return a replacement.
var lyricsLRC string
if req.shouldUpdateField("lyrics") {
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
lyricsLRC = existingLyrics
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
}
}
if req.EmbedLyrics && req.shouldUpdateField("lyrics") {
client := NewLyricsClient()
durationSec := float64(req.DurationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec)
if err != nil {
GoLog("[ReEnrich] Lyrics not found: %v\n", err)
} else if !lyrics.Instrumental {
lyricsLRC = convertToLRCWithMetadata(lyrics, req.TrackName, req.ArtistName)
GoLog("[ReEnrich] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
GoLog("[ReEnrich] Track is instrumental\n")
}
}
// Build enrichedMeta map: only include fields from selected update groups
// so that the caller (Dart) does not overwrite non-selected metadata in its
// local library database with potentially stale cached values.
enrichedMeta := map[string]any{
"spotify_id": req.SpotifyID,
"duration_ms": req.DurationMs,
}
if req.shouldUpdateField("basic_tags") {
enrichedMeta["track_name"] = req.TrackName
enrichedMeta["artist_name"] = req.ArtistName
enrichedMeta["album_name"] = req.AlbumName
enrichedMeta["album_artist"] = req.AlbumArtist
}
if req.shouldUpdateField("track_info") {
enrichedMeta["track_number"] = req.TrackNumber
enrichedMeta["total_tracks"] = req.TotalTracks
enrichedMeta["disc_number"] = req.DiscNumber
enrichedMeta["total_discs"] = req.TotalDiscs
}
if req.shouldUpdateField("release_info") {
enrichedMeta["release_date"] = req.ReleaseDate
enrichedMeta["isrc"] = req.ISRC
}
if req.shouldUpdateField("cover") {
enrichedMeta["cover_url"] = req.CoverURL
}
if req.shouldUpdateField("extra") {
enrichedMeta["genre"] = req.Genre
enrichedMeta["label"] = req.Label
enrichedMeta["copyright"] = req.Copyright
enrichedMeta["composer"] = req.Composer
}
if isFlac {
// Only populate Metadata fields for selected update groups; empty/zero
// values cause EmbedMetadata's setComment() to skip those tags,
// preserving whatever is already in the file.
metadata := Metadata{
ArtistTagMode: req.ArtistTagMode,
}
if req.shouldUpdateField("basic_tags") {
metadata.Title = req.TrackName
metadata.Artist = req.ArtistName
metadata.Album = req.AlbumName
metadata.AlbumArtist = req.AlbumArtist
}
if req.shouldUpdateField("track_info") {
metadata.TrackNumber = req.TrackNumber
metadata.TotalTracks = req.TotalTracks
metadata.DiscNumber = req.DiscNumber
metadata.TotalDiscs = req.TotalDiscs
}
if req.shouldUpdateField("release_info") {
metadata.Date = req.ReleaseDate
metadata.ISRC = req.ISRC
}
if req.shouldUpdateField("lyrics") {
if req.lyricsEmbedEnabled() {
metadata.Lyrics = lyricsLRC
}
}
if req.shouldUpdateField("extra") {
metadata.Genre = req.Genre
metadata.Label = req.Label
metadata.Copyright = req.Copyright
metadata.Composer = req.Composer
}
if len(coverDataBytes) > 0 {
if err := EmbedMetadataWithCoverData(req.FilePath, metadata, coverDataBytes); err != nil {
return "", fmt.Errorf("failed to embed metadata with cover: %w", err)
}
} else {
if err := EmbedMetadata(req.FilePath, metadata, ""); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
}
if len(coverDataBytes) > 0 {
embeddedCover, err := ExtractCoverArt(req.FilePath)
if err != nil || len(embeddedCover) == 0 {
if err != nil {
return "", fmt.Errorf("metadata embedded but cover verification failed: %w", err)
}
return "", fmt.Errorf("metadata embedded but cover verification failed: empty embedded cover")
}
GoLog("[ReEnrich] Cover verified after embed (%d bytes)\n", len(embeddedCover))
}
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
result := map[string]any{
"method": "native",
"success": true,
"enriched_metadata": enrichedMeta,
"lyrics": lyricsLRC,
"write_external_lrc": req.EmbedLyrics &&
req.shouldUpdateField("lyrics") &&
req.lyricsSidecarEnabled() &&
strings.TrimSpace(lyricsLRC) != "",
}
s, _ := marshalJSONString(result)
return s, nil
}
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false
ffmpegMetadata := buildReEnrichFFmpegMetadata(&req, lyricsLRC)
result := map[string]any{
"method": "ffmpeg",
"cover_path": coverTempPath,
"lyrics": lyricsLRC,
"enriched_metadata": enrichedMeta,
"metadata": ffmpegMetadata,
"write_external_lrc": req.EmbedLyrics &&
req.shouldUpdateField("lyrics") &&
req.lyricsSidecarEnabled() &&
strings.TrimSpace(lyricsLRC) != "",
}
s, _ := marshalJSONString(result)
return s, nil
}
+148
View File
@@ -0,0 +1,148 @@
package gobackend
import (
"fmt"
"net/url"
"path/filepath"
"strings"
)
func InitExtensionRepoJSON(cacheDir string) error {
initExtensionRepo(cacheDir)
return nil
}
func SetRepoRegistryURLJSON(registryURL string) error {
repo := getExtensionRepo()
if repo == nil {
return fmt.Errorf("extension repo not initialized")
}
resolved, err := resolveRegistryURL(registryURL)
if err != nil {
return err
}
if err := requireHTTPSURL(resolved, "registry"); err != nil {
return err
}
repo.setRegistryURL(resolved)
return nil
}
func ClearRepoRegistryURLJSON() error {
repo := getExtensionRepo()
if repo == nil {
return fmt.Errorf("extension repo not initialized")
}
repo.setRegistryURL("")
repo.clearCache()
return nil
}
func GetRepoRegistryURLJSON() (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
return repo.getRegistryURL(), nil
}
func GetRepoExtensionsJSON(forceRefresh bool) (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
extensions, err := repo.getExtensionsWithStatus(forceRefresh)
if err != nil {
return "", err
}
return marshalJSONString(extensions)
}
func SearchRepoExtensionsJSON(query, category string) (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
extensions, err := repo.searchExtensions(query, category)
if err != nil {
return "", err
}
return marshalJSONString(extensions)
}
func GetRepoCategoriesJSON() (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
categories := repo.getCategories()
return marshalJSONString(categories)
}
func repoExtensionPackageSuffix(downloadURL string) string {
rawPath := downloadURL
if parsed, err := url.Parse(downloadURL); err == nil {
rawPath = parsed.Path
}
lowerPath := strings.ToLower(rawPath)
if strings.HasSuffix(lowerPath, ".sflx") {
return ".sflx"
}
if strings.HasSuffix(lowerPath, ".spotiflac-ext") {
return ".spotiflac-ext"
}
return ".spotiflac-ext"
}
func buildRepoExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) {
if strings.TrimSpace(extensionID) == "" {
return "", fmt.Errorf("invalid extension id")
}
safeExtensionID := sanitizeFilename(extensionID)
return filepath.Join(destDir, safeExtensionID+repoExtensionPackageSuffix(downloadURL)), nil
}
func DownloadRepoExtensionJSON(extensionID, destDir string) (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
ext, err := repo.findExtension(extensionID)
if err != nil {
return "", err
}
destPath, err := buildRepoExtensionDestPath(destDir, extensionID, ext.getDownloadURL())
if err != nil {
return "", err
}
err = repo.downloadExtension(extensionID, destPath)
if err != nil {
return "", err
}
return destPath, nil
}
func ClearRepoCacheJSON() error {
repo := getExtensionRepo()
if repo == nil {
return fmt.Errorf("extension repo not initialized")
}
repo.clearCache()
return nil
}
@@ -1,7 +1,6 @@
package gobackend
import (
"context"
"io"
"net/http"
"os"
@@ -55,13 +54,9 @@ func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
origClient := globalSongLinkClient
origRetryConfig := songLinkRetryConfig
origSearchByISRC := songLinkSearchByISRC
origCheckFromDeezer := songLinkCheckAvailabilityFromDeezer
defer func() {
globalSongLinkClient = origClient
songLinkRetryConfig = origRetryConfig
songLinkSearchByISRC = origSearchByISRC
songLinkCheckAvailabilityFromDeezer = origCheckFromDeezer
SetSongLinkNetworkOptions(false, false)
}()
songLinkRetryConfig = func() RetryConfig {
@@ -69,11 +64,12 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
}
globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
var body string
if req.URL.Host == "api.zarz.moe" {
switch req.URL.Host {
case "api.zarz.moe":
body = `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/spotify-1","Deezer":"https://www.deezer.com/track/101","Tidal":"https://listen.tidal.com/track/202","YouTube":"https://youtu.be/yt1","AmazonMusic":"https://music.amazon.com/tracks/amz1","Qobuz":"https://open.qobuz.com/track/303"}}`
} else if req.URL.Host == "api.song.link" {
case "api.song.link":
body = `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}`
} else {
default:
t.Fatalf("unexpected SongLink request: %s", req.URL.String())
}
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
@@ -81,15 +77,6 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
songLinkClientOnce.Do(func() {})
SetSongLinkNetworkOptions(true, true)
if availabilityJSON, err := CheckAvailability("spotify-1", ""); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
t.Fatalf("CheckAvailability = %q/%v", availabilityJSON, err)
}
if availabilityJSON, err := CheckAvailabilityFromDeezerID("101"); err != nil || !strings.Contains(availabilityJSON, `"spotify_id":"spotify-1"`) {
t.Fatalf("CheckAvailabilityFromDeezerID = %q/%v", availabilityJSON, err)
}
if availabilityJSON, err := CheckAvailabilityByPlatformID("deezer", "song", "101"); err != nil || !strings.Contains(availabilityJSON, `"tidal_url"`) {
t.Fatalf("CheckAvailabilityByPlatformID = %q/%v", availabilityJSON, err)
}
if spotifyID, err := GetSpotifyIDFromDeezerTrack("101"); err != nil || spotifyID != "spotify-1" {
t.Fatalf("GetSpotifyIDFromDeezerTrack = %q/%v", spotifyID, err)
}
@@ -121,15 +108,6 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
t.Fatalf("CheckAvailabilityFromURL = %#v/%v", availability, err)
}
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
return &TrackMetadata{SpotifyID: "deezer:101", ExternalURL: "https://www.deezer.com/track/101"}, nil
}
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
return &TrackAvailability{SpotifyID: "spotify-1", Deezer: true, DeezerID: deezerTrackID}, nil
}
if availabilityJSON, err := CheckAvailability("", "USRC17607839"); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
t.Fatalf("CheckAvailability by ISRC = %q/%v", availabilityJSON, err)
}
if songLinkExtractDeezerTrackID(nil) != "" || songLinkExtractDeezerTrackID(&TrackMetadata{ExternalURL: "https://www.deezer.com/track/202"}) != "202" {
t.Fatal("songLinkExtractDeezerTrackID mismatch")
}
+109 -39
View File
@@ -31,6 +31,79 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
}
}
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
cases := []string{
"HTTP 401 for /tickets",
"HTTP status 428: precondition required",
"Verification required",
}
for _, tc := range cases {
if got := classifyDownloadErrorType(tc); got != "verification_required" {
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
}
}
}
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
dir := t.TempDir()
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
CleanupExtensions()
defer CleanupExtensions()
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
ext.ID = "deezer"
ext.Manifest.Name = "deezer"
manager := getExtensionManager()
manager.mu.Lock()
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
manager.mu.Unlock()
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
if err != nil {
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
}
if !strings.Contains(jsonText, "album-track") {
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
}
}
func TestExtensionTrackExportsPreserveExplicitFlag(t *testing.T) {
dir := t.TempDir()
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
manager := getExtensionManager()
manager.mu.Lock()
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
manager.mu.Unlock()
defer CleanupExtensions()
assertExplicit := func(name, jsonText string, err error) {
t.Helper()
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if !strings.Contains(jsonText, `"explicit":true`) {
t.Fatalf("%s dropped explicit flag: %s", name, jsonText)
}
}
jsonText, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`)
assertExplicit("custom search", jsonText, err)
for _, resourceType := range []string{"track", "album", "playlist", "artist"} {
jsonText, err = GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1")
assertExplicit("provider metadata "+resourceType, jsonText, err)
}
jsonText, err = HandleURLWithExtensionJSON("https://example.test/track/1")
assertExplicit("URL handler", jsonText, err)
}
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
@@ -53,18 +126,12 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
manager.mu.Unlock()
}()
if response, err := DownloadTrack(`{}`); err != nil || !strings.Contains(response, "retired") {
t.Fatalf("DownloadTrack = %q/%v", response, err)
}
if response, err := DownloadByStrategy(`not-json`); err != nil || !strings.Contains(response, "Invalid request") {
t.Fatalf("DownloadByStrategy invalid = %q/%v", response, err)
}
if response, err := DownloadByStrategy(`{"use_extensions":false}`); err != nil || !strings.Contains(response, "disabled") {
t.Fatalf("DownloadByStrategy disabled = %q/%v", response, err)
}
if response, err := DownloadWithFallback(`{}`); err != nil || !strings.Contains(response, "retired") {
t.Fatalf("DownloadWithFallback = %q/%v", response, err)
}
InitItemProgress("item-1")
FinishItemProgress("item-1")
@@ -145,12 +212,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
t.Fatalf("SanitizeFilename = %q", got)
}
if response, err := PreWarmTrackCacheJSON(`not-json`); err != nil || !strings.Contains(response, "Invalid JSON") {
t.Fatalf("PreWarmTrackCacheJSON invalid = %q/%v", response, err)
}
if response, err := PreWarmTrackCacheJSON(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(response, "success") {
t.Fatalf("PreWarmTrackCacheJSON = %q/%v", response, err)
}
if GetTrackCacheSize() != 0 {
t.Fatal("expected empty track cache")
}
@@ -181,9 +242,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if err := SetExtensionFallbackProviderIDsJSON(`["coverage-ext"]`); err != nil {
t.Fatalf("SetExtensionFallbackProviderIDsJSON: %v", err)
}
if jsonText, err := GetExtensionFallbackProviderIDsJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
t.Fatalf("GetExtensionFallbackProviderIDsJSON = %q/%v", jsonText, err)
}
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
t.Fatalf("reset extension fallback IDs: %v", err)
}
@@ -361,14 +419,14 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
CancelExtensionRequestJSON("req-home")
storeDir := filepath.Join(dir, "store")
if err := InitExtensionStoreJSON(storeDir); err != nil {
t.Fatalf("InitExtensionStoreJSON: %v", err)
if err := InitExtensionRepoJSON(storeDir); err != nil {
t.Fatalf("InitExtensionRepoJSON: %v", err)
}
if err := SetStoreRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
t.Fatalf("SetStoreRegistryURLJSON: %v", err)
if err := SetRepoRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
t.Fatalf("SetRepoRegistryURLJSON: %v", err)
}
store := getExtensionStore()
store.cache = &storeRegistry{Extensions: []storeExtension{{
store := getExtensionRepo()
store.cache = &repoRegistry{Extensions: []repoExtension{{
ID: "coverage-ext",
Name: "coverage-ext",
Version: "1.0.0",
@@ -378,29 +436,44 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
DownloadURL: "https://registry.example.com/coverage.spotiflac-ext",
}}}
store.cacheTime = time.Now()
if registryURL, err := GetStoreRegistryURLJSON(); err != nil || registryURL == "" {
t.Fatalf("GetStoreRegistryURLJSON = %q/%v", registryURL, err)
if registryURL, err := GetRepoRegistryURLJSON(); err != nil || registryURL == "" {
t.Fatalf("GetRepoRegistryURLJSON = %q/%v", registryURL, err)
}
if storeJSON, err := GetStoreExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("GetStoreExtensionsJSON = %q/%v", storeJSON, err)
if storeJSON, err := GetRepoExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("GetRepoExtensionsJSON = %q/%v", storeJSON, err)
}
if storeJSON, err := SearchStoreExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("SearchStoreExtensionsJSON = %q/%v", storeJSON, err)
if storeJSON, err := SearchRepoExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("SearchRepoExtensionsJSON = %q/%v", storeJSON, err)
}
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
if catsJSON, err := GetRepoCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
t.Fatalf("GetRepoCategoriesJSON = %q/%v", catsJSON, err)
}
if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
if dest, err := buildRepoExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.spotiflac-ext",
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
t.Fatalf("buildRepoExtensionDestPath = %q/%v", dest, err)
}
if _, err := buildStoreExtensionDestPath(dir, " "); err == nil {
if dest, err := buildRepoExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.sflx",
); err != nil || !strings.HasSuffix(dest, ".sflx") {
t.Fatalf("buildRepoExtensionDestPath sflx = %q/%v", dest, err)
}
if _, err := buildRepoExtensionDestPath(
dir,
" ",
"https://registry.example.com/coverage.sflx",
); err == nil {
t.Fatal("expected invalid extension id")
}
if err := ClearStoreCacheJSON(); err != nil {
t.Fatalf("ClearStoreCacheJSON: %v", err)
if err := ClearRepoCacheJSON(); err != nil {
t.Fatalf("ClearRepoCacheJSON: %v", err)
}
if err := ClearStoreRegistryURLJSON(); err != nil {
t.Fatalf("ClearStoreRegistryURLJSON: %v", err)
if err := ClearRepoRegistryURLJSON(); err != nil {
t.Fatalf("ClearRepoRegistryURLJSON: %v", err)
}
SetLibraryCoverCacheDirJSON(filepath.Join(dir, "covers"))
@@ -431,9 +504,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if metadataJSON, err := ReadAudioMetadataJSON(filepath.Join(libraryDir, "missing.mp3")); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataJSON = %q/%v", metadataJSON, err)
}
if metadataJSON, err := ReadAudioMetadataWithHintJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing"); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataWithHintJSON = %q/%v", metadataJSON, err)
}
if metadataJSON, err := ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing", "key"); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataWithHintAndCoverCacheKeyJSON = %q/%v", metadataJSON, err)
}
+39 -2
View File
@@ -352,7 +352,7 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
AlbumName: "Resolved Album",
AlbumName: "Original Album (Deluxe)",
ReleaseDate: "",
ISRC: "",
})
@@ -360,7 +360,7 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
if req.ReleaseDate != "2024-01-01" {
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
}
if req.AlbumName != "Resolved Album" {
if req.AlbumName != "Original Album (Deluxe)" {
t.Fatalf("album = %q, want updated album", req.AlbumName)
}
if req.ISRC != "REQ123" {
@@ -368,6 +368,43 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
}
}
func TestApplyReEnrichTrackMetadataKeepsReleaseIdentityOnAlbumMismatch(t *testing.T) {
req := reEnrichRequest{
TrackName: "Afsana",
ArtistName: "Artist Name",
AlbumName: "Original Soundtrack",
CoverURL: "https://covers/original.jpg",
TrackNumber: 3,
ReleaseDate: "2005-01-01",
}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
Name: "Afsana",
Artists: "Artist Name",
AlbumName: "The Hit Machine",
CoverURL: "https://covers/compilation.jpg",
TrackNumber: 17,
ReleaseDate: "2010-01-01",
ISRC: "NEW123",
})
if req.AlbumName != "Original Soundtrack" {
t.Fatalf("album = %q, want original release kept", req.AlbumName)
}
if req.CoverURL != "https://covers/original.jpg" {
t.Fatalf("cover = %q, want original release cover kept", req.CoverURL)
}
if req.TrackNumber != 3 {
t.Fatalf("track number = %d, want original position kept", req.TrackNumber)
}
if req.ReleaseDate != "2005-01-01" {
t.Fatalf("release date = %q, want original date kept", req.ReleaseDate)
}
if req.ISRC != "NEW123" {
t.Fatalf("isrc = %q, want recording-level fields still enriched", req.ISRC)
}
}
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
+57
View File
@@ -0,0 +1,57 @@
package gobackend
import (
"net/http"
"net/url"
"testing"
"time"
)
func TestSimpleCookieJarReplacesAndExpiresCookies(t *testing.T) {
jar, err := newSimpleCookieJar()
if err != nil {
t.Fatalf("newSimpleCookieJar: %v", err)
}
u, _ := url.Parse("https://api.example.com/path")
jar.SetCookies(u, []*http.Cookie{{Name: "session", Value: "old", Path: "/"}})
jar.SetCookies(u, []*http.Cookie{{Name: "session", Value: "new", Path: "/"}})
cookies := jar.Cookies(u)
if len(cookies) != 1 || cookies[0].Value != "new" {
t.Fatalf("replacement cookies = %#v", cookies)
}
jar.SetCookies(u, []*http.Cookie{{
Name: "session",
Value: "expired",
Path: "/",
Expires: time.Now().Add(-time.Hour),
MaxAge: -1,
}})
if cookies := jar.Cookies(u); len(cookies) != 0 {
t.Fatalf("expired cookies = %#v", cookies)
}
}
func TestSimpleCookieJarClearAndScope(t *testing.T) {
jar, err := newSimpleCookieJar()
if err != nil {
t.Fatalf("newSimpleCookieJar: %v", err)
}
apiURL, _ := url.Parse("https://api.example.com/private/resource")
otherURL, _ := url.Parse("https://other.example.com/private/resource")
publicURL, _ := url.Parse("https://api.example.com/public")
jar.SetCookies(apiURL, []*http.Cookie{{Name: "session", Value: "value", Path: "/private"}})
if len(jar.Cookies(apiURL)) != 1 {
t.Fatal("expected cookie for matching host and path")
}
if len(jar.Cookies(otherURL)) != 0 || len(jar.Cookies(publicURL)) != 0 {
t.Fatal("cookie escaped its host or path scope")
}
jar.Clear()
if len(jar.Cookies(apiURL)) != 0 {
t.Fatal("Clear retained cookies")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
package gobackend
import (
"path/filepath"
"testing"
)
func TestVerifiedDownloadResumeTriesSelectedProviderBeforeMetadata(t *testing.T) {
resetPreparedDownloadRequestCacheForTest()
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
metadataExt := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
metadataExt.ID = "resume-metadata"
metadataExt.Manifest.Name = metadataExt.ID
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
downloadExt.ID = "resume-download"
downloadExt.Manifest.Name = downloadExt.ID
manager := getExtensionManager()
manager.mu.Lock()
previousExtensions := manager.extensions
manager.extensions = map[string]*loadedExtension{
metadataExt.ID: metadataExt,
downloadExt.ID: downloadExt,
}
manager.mu.Unlock()
t.Cleanup(func() {
teardownExtension(metadataExt)
teardownExtension(downloadExt)
manager.mu.Lock()
manager.extensions = previousExtensions
manager.mu.Unlock()
})
req := DownloadRequest{
ItemID: "resume-item",
Service: downloadExt.ID,
Source: metadataExt.ID,
SpotifyID: "spotify:track:1",
TrackName: "Original Song",
ArtistName: "Artist",
AlbumName: "Album",
ReleaseDate: "2026-05-04",
OutputDir: t.TempDir(),
OutputExt: ".flac",
FilenameFormat: "{title}",
Quality: "LOSSLESS",
UseFallback: false,
}
key := downloadPreparationKey(req)
cacheUnpreparedDownloadRequest(key, req)
resp, err := DownloadWithExtensionFallback(req)
if err != nil {
t.Fatalf("DownloadWithExtensionFallback: %v", err)
}
if resp == nil || !resp.Success {
t.Fatalf("resume response = %#v", resp)
}
if got := filepath.Base(resp.FilePath); got != "Original Song.flac" {
t.Fatalf("resume ran metadata enrichment before download: file = %q", got)
}
}
func TestVerifiedDownloadResumeReusesPreparedMetadata(t *testing.T) {
resetPreparedDownloadRequestCacheForTest()
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
metadataExt := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
metadataExt.ID = "prepared-metadata"
metadataExt.Manifest.Name = metadataExt.ID
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
downloadExt.ID = "prepared-download"
downloadExt.Manifest.Name = downloadExt.ID
manager := getExtensionManager()
manager.mu.Lock()
previousExtensions := manager.extensions
manager.extensions = map[string]*loadedExtension{
metadataExt.ID: metadataExt,
downloadExt.ID: downloadExt,
}
manager.mu.Unlock()
t.Cleanup(func() {
teardownExtension(metadataExt)
teardownExtension(downloadExt)
manager.mu.Lock()
manager.extensions = previousExtensions
manager.mu.Unlock()
})
req := DownloadRequest{
ItemID: "prepared-item",
Service: downloadExt.ID,
Source: metadataExt.ID,
SpotifyID: "spotify:track:2",
TrackName: "Original Song",
ArtistName: "Artist",
AlbumName: "Album",
ReleaseDate: "2026-05-04",
OutputDir: t.TempDir(),
OutputExt: ".flac",
FilenameFormat: "{title}",
Quality: "LOSSLESS",
UseFallback: false,
}
key := downloadPreparationKey(req)
prepared := req
prepared.TrackName = "Prepared Song"
cachePreparedDownloadRequest(key, prepared)
resp, err := DownloadWithExtensionFallback(req)
if err != nil {
t.Fatalf("DownloadWithExtensionFallback: %v", err)
}
if resp == nil || !resp.Success {
t.Fatalf("prepared response = %#v", resp)
}
if got := filepath.Base(resp.FilePath); got != "Prepared Song.flac" {
t.Fatalf("prepared metadata was not reused: file = %q", got)
}
}
+647
View File
@@ -0,0 +1,647 @@
package gobackend
import (
"fmt"
"strconv"
"strings"
"github.com/dop251/goja"
)
func gojaValueIsEmpty(value goja.Value) bool {
return value == nil || goja.IsUndefined(value) || goja.IsNull(value)
}
func gojaObjectString(obj *goja.Object, keys ...string) string {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
if str, ok := value.Export().(string); ok {
return str
}
}
return ""
}
func gojaObjectValue(obj *goja.Object, keys ...string) goja.Value {
for _, key := range keys {
value := obj.Get(key)
if !gojaValueIsEmpty(value) {
return value
}
}
return nil
}
func gojaObjectInt(obj *goja.Object, keys ...string) int {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
return int(value.ToInteger())
}
return 0
}
func gojaObjectInt64(obj *goja.Object, keys ...string) int64 {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
return value.ToInteger()
}
return 0
}
func gojaObjectBool(obj *goja.Object, keys ...string) bool {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
return value.ToBoolean()
}
return false
}
func gojaObjectInterfaceMap(obj *goja.Object, keys ...string) map[string]any {
value := gojaObjectValue(obj, keys...)
if gojaValueIsEmpty(value) {
return nil
}
exported, ok := value.Export().(map[string]any)
if !ok || len(exported) == 0 {
return nil
}
return exported
}
func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map[string]string {
value := gojaObjectValue(obj, keys...)
if gojaValueIsEmpty(value) {
return nil
}
valueObj := value.ToObject(vm)
objectKeys := valueObj.Keys()
if len(objectKeys) == 0 {
return nil
}
result := make(map[string]string, len(objectKeys))
for _, childKey := range objectKeys {
childValue := valueObj.Get(childKey)
if gojaValueIsEmpty(childValue) {
continue
}
result[childKey] = childValue.String()
}
if len(result) == 0 {
return nil
}
return result
}
func gojaObjectStringSlice(obj *goja.Object, keys ...string) []string {
value := gojaObjectValue(obj, keys...)
if gojaValueIsEmpty(value) {
return nil
}
exported, ok := value.Export().([]any)
if !ok || len(exported) == 0 {
return nil
}
result := make([]string, 0, len(exported))
for _, item := range exported {
str, ok := item.(string)
if !ok {
continue
}
str = strings.TrimSpace(str)
if str != "" {
result = append(result, str)
}
}
if len(result) == 0 {
return nil
}
return result
}
func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) {
if gojaValueIsEmpty(value) {
return 0, nil
}
lengthValue := value.ToObject(vm).Get("length")
if gojaValueIsEmpty(lengthValue) {
return 0, fmt.Errorf("value is not an array")
}
length := lengthValue.ToInteger()
if length <= 0 {
return 0, nil
}
return int(length), nil
}
func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetadata {
obj := value.ToObject(vm)
return ExtTrackMetadata{
ID: gojaObjectString(obj, "id"),
Name: gojaObjectString(obj, "name"),
Artists: gojaObjectString(obj, "artists"),
AlbumName: gojaObjectString(obj, "album_name", "albumName"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
AlbumID: gojaObjectString(obj, "album_id", "albumId"),
AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"),
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"),
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"),
Images: gojaObjectString(obj, "images"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
ISRC: gojaObjectString(obj, "isrc"),
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
ItemType: gojaObjectString(obj, "item_type", "itemType"),
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
Explicit: gojaObjectBool(obj, "explicit", "is_explicit", "isExplicit"),
TidalID: gojaObjectString(obj, "tidal_id", "tidalId"),
QobuzID: gojaObjectString(obj, "qobuz_id", "qobuzId"),
DeezerID: gojaObjectString(obj, "deezer_id", "deezerId"),
SpotifyID: gojaObjectString(obj, "spotify_id", "spotifyId"),
ExternalLinks: gojaObjectStringMap(vm, obj, "external_links", "externalLinks"),
Label: gojaObjectString(obj, "label"),
Copyright: gojaObjectString(obj, "copyright"),
Genre: gojaObjectString(obj, "genre"),
Composer: gojaObjectString(obj, "composer"),
AudioQuality: gojaObjectString(obj, "audio_quality", "audioQuality"),
AudioModes: gojaObjectString(obj, "audio_modes", "audioModes"),
}
}
func parseExtensionTrackArray(vm *goja.Runtime, value goja.Value) ([]ExtTrackMetadata, error) {
length, err := gojaArrayLength(value, vm)
if err != nil {
return nil, err
}
if length == 0 {
return []ExtTrackMetadata{}, nil
}
arrayObj := value.ToObject(vm)
tracks := make([]ExtTrackMetadata, 0, length)
for i := 0; i < length; i++ {
trackValue := arrayObj.Get(strconv.Itoa(i))
if gojaValueIsEmpty(trackValue) {
continue
}
tracks = append(tracks, parseExtensionTrackValue(vm, trackValue))
}
return tracks, nil
}
func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetadata, error) {
if gojaValueIsEmpty(value) {
return ExtAlbumMetadata{}, nil
}
obj := value.ToObject(vm)
tracks := []ExtTrackMetadata{}
if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) {
parsedTracks, err := parseExtensionTrackArray(vm, tracksValue)
if err != nil {
return ExtAlbumMetadata{}, err
}
tracks = parsedTracks
}
return ExtAlbumMetadata{
ID: gojaObjectString(obj, "id"),
Name: gojaObjectString(obj, "name"),
Artists: gojaObjectString(obj, "artists"),
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"),
Tracks: tracks,
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
}.withTrackFallbacks(), nil
}
// withTrackFallbacks fills the album-level artist and release date from the
// album's tracks when the extension did not provide them at the album level.
// This is a generic mechanism so any extension benefits, without per-extension
// special-casing in the app.
func (a ExtAlbumMetadata) withTrackFallbacks() ExtAlbumMetadata {
if strings.TrimSpace(a.Artists) == "" {
a.Artists = albumArtistFromTracks(a.Tracks)
}
if strings.TrimSpace(a.ReleaseDate) == "" {
a.ReleaseDate = albumReleaseDateFromTracks(a.Tracks)
}
if len(a.AudioTraits) == 0 {
a.AudioTraits = albumAudioTraitsFromTracks(a.Tracks)
}
return a
}
// albumArtistFromTracks prefers an explicit per-track album artist, then falls
// back to the most common track artist across the album.
func albumArtistFromTracks(tracks []ExtTrackMetadata) string {
for _, t := range tracks {
if s := strings.TrimSpace(t.AlbumArtist); s != "" {
return s
}
}
counts := map[string]int{}
order := []string{}
for _, t := range tracks {
artist := strings.TrimSpace(t.Artists)
if artist == "" {
continue
}
if _, ok := counts[artist]; !ok {
order = append(order, artist)
}
counts[artist]++
}
best := ""
bestCount := 0
for _, artist := range order {
if counts[artist] > bestCount {
best = artist
bestCount = counts[artist]
}
}
return best
}
// albumReleaseDateFromTracks returns the first non-empty track release date.
func albumReleaseDateFromTracks(tracks []ExtTrackMetadata) string {
for _, t := range tracks {
if s := strings.TrimSpace(t.ReleaseDate); s != "" {
return s
}
}
return ""
}
// albumAudioTraitsFromTracks derives album-level audio badges (Dolby Atmos,
// Hi-Res Lossless, Lossless) from the per-track audio quality/mode fields that
// extensions like Tidal and Qobuz already provide. Tokens match what the album
// header understands ("dolby_atmos", "hi_res_lossless", "lossless").
func albumAudioTraitsFromTracks(tracks []ExtTrackMetadata) []string {
atmos := false
hiRes := false
lossless := false
for _, t := range tracks {
modes := strings.ToUpper(t.AudioModes)
quality := strings.ToUpper(t.AudioQuality)
if strings.Contains(modes, "ATMOS") || strings.Contains(quality, "ATMOS") {
atmos = true
}
if strings.Contains(quality, "HI_RES") ||
strings.Contains(quality, "HIRES") ||
strings.Contains(quality, "MASTER") ||
strings.Contains(quality, "MQA") {
hiRes = true
}
if strings.Contains(quality, "LOSSLESS") ||
strings.Contains(quality, "FLAC") {
lossless = true
}
if bd, sr := parseBitDepthSampleRate(quality); bd > 0 {
if bd > 16 || sr > 48 {
hiRes = true
} else {
lossless = true
}
}
}
traits := []string{}
if atmos {
traits = append(traits, "dolby_atmos")
}
if hiRes {
traits = append(traits, "hi_res_lossless")
} else if lossless {
traits = append(traits, "lossless")
}
return traits
}
// parseBitDepthSampleRate extracts a bit depth and sample rate (in kHz) from
// labels such as "24bit/96kHz", "16bit/44.1kHz" or "24bit".
func parseBitDepthSampleRate(quality string) (int, float64) {
lower := strings.ToLower(quality)
bitDepth := 0
sampleRate := 0.0
if idx := strings.Index(lower, "bit"); idx > 0 {
j := idx
for j > 0 && lower[j-1] >= '0' && lower[j-1] <= '9' {
j--
}
if n, err := strconv.Atoi(lower[j:idx]); err == nil {
bitDepth = n
}
}
if idx := strings.Index(lower, "khz"); idx > 0 {
j := idx
for j > 0 && ((lower[j-1] >= '0' && lower[j-1] <= '9') || lower[j-1] == '.') {
j--
}
if f, err := strconv.ParseFloat(lower[j:idx], 64); err == nil {
sampleRate = f
}
}
return bitDepth, sampleRate
}
func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) {
length, err := gojaArrayLength(value, vm)
if err != nil {
return nil, err
}
if length == 0 {
return []ExtAlbumMetadata{}, nil
}
arrayObj := value.ToObject(vm)
albums := make([]ExtAlbumMetadata, 0, length)
for i := 0; i < length; i++ {
albumValue := arrayObj.Get(strconv.Itoa(i))
if gojaValueIsEmpty(albumValue) {
continue
}
album, err := parseExtensionAlbumValue(vm, albumValue)
if err != nil {
return nil, err
}
albums = append(albums, album)
}
return albums, nil
}
func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMetadata, error) {
if gojaValueIsEmpty(value) {
return ExtArtistMetadata{}, nil
}
obj := value.ToObject(vm)
albums := []ExtAlbumMetadata{}
if albumsValue := gojaObjectValue(obj, "albums"); !gojaValueIsEmpty(albumsValue) {
parsedAlbums, err := parseExtensionAlbumArray(vm, albumsValue)
if err != nil {
return ExtArtistMetadata{}, err
}
albums = parsedAlbums
}
releases := []ExtAlbumMetadata{}
if releasesValue := gojaObjectValue(obj, "releases"); !gojaValueIsEmpty(releasesValue) {
parsedReleases, err := parseExtensionAlbumArray(vm, releasesValue)
if err != nil {
return ExtArtistMetadata{}, err
}
releases = parsedReleases
}
topTracks := []ExtTrackMetadata{}
if topTracksValue := gojaObjectValue(obj, "top_tracks", "topTracks"); !gojaValueIsEmpty(topTracksValue) {
parsedTopTracks, err := parseExtensionTrackArray(vm, topTracksValue)
if err != nil {
return ExtArtistMetadata{}, err
}
topTracks = parsedTopTracks
}
return ExtArtistMetadata{
ID: gojaObjectString(obj, "id"),
Name: gojaObjectString(obj, "name"),
ImageURL: gojaObjectString(obj, "image_url", "imageUrl"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
Listeners: gojaObjectInt(obj, "listeners"),
Albums: albums,
Releases: releases,
TopTracks: topTracks,
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
}, nil
}
func parseExtensionAvailabilityValue(vm *goja.Runtime, value goja.Value) ExtAvailabilityResult {
obj := value.ToObject(vm)
return ExtAvailabilityResult{
Available: gojaObjectBool(obj, "available"),
Reason: gojaObjectString(obj, "reason"),
TrackID: gojaObjectString(obj, "track_id", "trackId"),
SkipFallback: gojaObjectBool(obj, "skip_fallback", "skipFallback"),
}
}
func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *DownloadDecryptionInfo {
if gojaValueIsEmpty(value) {
return nil
}
obj := value.ToObject(vm)
info := &DownloadDecryptionInfo{
Strategy: gojaObjectString(obj, "strategy"),
Key: gojaObjectString(obj, "key"),
IV: gojaObjectString(obj, "iv"),
InputFormat: gojaObjectString(obj, "input_format", "inputFormat"),
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
Options: gojaObjectInterfaceMap(obj, "options"),
}
if info.Strategy == "" && info.Key == "" && info.IV == "" && info.InputFormat == "" && info.OutputExtension == "" && len(info.Options) == 0 {
return nil
}
return info
}
func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult {
obj := value.ToObject(vm)
return ExtDownloadResult{
Success: gojaObjectBool(obj, "success"),
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
Title: gojaObjectString(obj, "title"),
Artist: gojaObjectString(obj, "artist"),
Album: gojaObjectString(obj, "album"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
ISRC: gojaObjectString(obj, "isrc"),
Genre: gojaObjectString(obj, "genre"),
Label: gojaObjectString(obj, "label"),
Copyright: gojaObjectString(obj, "copyright"),
Composer: gojaObjectString(obj, "composer"),
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
RequiresContainerConversion: gojaObjectBool(
obj,
"requires_container_conversion",
"requiresContainerConversion",
),
}
}
func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) {
obj := value.ToObject(vm)
handleResult := ExtURLHandleResult{
Type: gojaObjectString(obj, "type"),
Name: gojaObjectString(obj, "name"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
}
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
track := parseExtensionTrackValue(vm, trackValue)
handleResult.Track = &track
}
if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) {
tracks, err := parseExtensionTrackArray(vm, tracksValue)
if err != nil {
return ExtURLHandleResult{}, err
}
handleResult.Tracks = tracks
}
if albumValue := gojaObjectValue(obj, "album"); !gojaValueIsEmpty(albumValue) {
album, err := parseExtensionAlbumValue(vm, albumValue)
if err != nil {
return ExtURLHandleResult{}, err
}
handleResult.Album = &album
}
if artistValue := gojaObjectValue(obj, "artist"); !gojaValueIsEmpty(artistValue) {
artist, err := parseExtensionArtistValue(vm, artistValue)
if err != nil {
return ExtURLHandleResult{}, err
}
handleResult.Artist = &artist
}
return handleResult, nil
}
func parseExtensionPostProcessValue(vm *goja.Runtime, value goja.Value) PostProcessResult {
obj := value.ToObject(vm)
return PostProcessResult{
Success: gojaObjectBool(obj, "success"),
NewFilePath: gojaObjectString(obj, "new_file_path", "newFilePath"),
NewFileURI: gojaObjectString(obj, "new_file_uri", "newFileUri"),
Error: gojaObjectString(obj, "error"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
}
}
func parseExtensionLyricsLineArray(vm *goja.Runtime, value goja.Value) ([]ExtLyricsLine, error) {
length, err := gojaArrayLength(value, vm)
if err != nil {
return nil, err
}
if length == 0 {
return []ExtLyricsLine{}, nil
}
arrayObj := value.ToObject(vm)
lines := make([]ExtLyricsLine, 0, length)
for i := 0; i < length; i++ {
lineValue := arrayObj.Get(strconv.Itoa(i))
if gojaValueIsEmpty(lineValue) {
continue
}
lineObj := lineValue.ToObject(vm)
lines = append(lines, ExtLyricsLine{
StartTimeMs: gojaObjectInt64(lineObj, "startTimeMs", "start_time_ms"),
Words: gojaObjectString(lineObj, "words"),
EndTimeMs: gojaObjectInt64(lineObj, "endTimeMs", "end_time_ms"),
})
}
return lines, nil
}
func parseExtensionLyricsValue(vm *goja.Runtime, value goja.Value) (ExtLyricsResult, error) {
obj := value.ToObject(vm)
lines := []ExtLyricsLine{}
if linesValue := gojaObjectValue(obj, "lines"); !gojaValueIsEmpty(linesValue) {
parsedLines, err := parseExtensionLyricsLineArray(vm, linesValue)
if err != nil {
return ExtLyricsResult{}, err
}
lines = parsedLines
}
return ExtLyricsResult{
Lines: lines,
SyncType: gojaObjectString(obj, "syncType", "sync_type"),
Instrumental: gojaObjectBool(obj, "instrumental"),
PlainLyrics: gojaObjectString(obj, "plainLyrics", "plain_lyrics"),
Provider: gojaObjectString(obj, "provider"),
}, nil
}
func parseExtensionSearchResult(vm *goja.Runtime, value goja.Value) (ExtSearchResult, error) {
if gojaValueIsEmpty(value) {
return ExtSearchResult{}, nil
}
resultObj := value.ToObject(vm)
tracksValue := resultObj.Get("tracks")
if gojaValueIsEmpty(tracksValue) {
tracks, err := parseExtensionTrackArray(vm, value)
if err != nil {
return ExtSearchResult{}, err
}
return ExtSearchResult{
Tracks: tracks,
Total: len(tracks),
}, nil
}
tracks, err := parseExtensionTrackArray(vm, tracksValue)
if err != nil {
return ExtSearchResult{}, err
}
total := gojaObjectInt(resultObj, "total")
if total == 0 {
total = len(tracks)
}
return ExtSearchResult{
Tracks: tracks,
Total: total,
}, nil
}
+97 -22
View File
@@ -15,7 +15,9 @@ import (
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
extensionHealthDefaultCache = 60 * time.Second
extensionHealthDefaultCache = 10 * time.Minute
extensionHealthMinCache = 60 * time.Second
extensionHealthUnknownCache = 2 * time.Minute
)
type ExtensionHealthResult struct {
@@ -50,6 +52,12 @@ var (
extensionHealthCache = map[string]cachedExtensionHealthResult{}
)
func clearExtensionHealthCache() {
extensionHealthCacheMu.Lock()
extensionHealthCache = map[string]cachedExtensionHealthResult{}
extensionHealthCacheMu.Unlock()
}
func CheckExtensionHealthJSON(extensionID string) (string, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -58,6 +66,7 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
}
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
bytes, err := json.Marshal(result)
if err != nil {
return "", err
@@ -85,16 +94,31 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
extensionHealthCacheMu.Unlock()
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
return result
}
func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return
}
cacheKey := strings.TrimSpace(ext.ID)
if cacheKey == "" {
return
}
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
ttl = extensionHealthUnknownCache
}
extensionHealthCacheMu.Lock()
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
result: result,
expiresAt: now.Add(ttl),
expiresAt: time.Now().Add(ttl),
}
extensionHealthCacheMu.Unlock()
return result
}
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
@@ -149,6 +173,9 @@ func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
continue
}
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
if checkTTL < extensionHealthMinCache {
checkTTL = extensionHealthMinCache
}
if checkTTL < ttl {
ttl = checkTTL
}
@@ -226,7 +253,11 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
resp, err := NewMetadataHTTPClient(timeout).Do(req)
result.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
result.Status = "offline"
if isTransientExtensionHealthError(err) {
result.Status = "unknown"
} else {
result.Status = "offline"
}
result.Error = err.Error()
return result
}
@@ -262,12 +293,16 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
return result
}
func isTransientExtensionHealthError(err error) bool {
return isTransientNetworkError(err) || isConnectivityFailure(err)
}
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
if len(strings.TrimSpace(string(body))) == 0 {
return "online", ""
}
var payload map[string]interface{}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return "online", ""
}
@@ -287,18 +322,21 @@ func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string
case "degraded", "partial", "warning", "warn":
return "degraded", rawStatus
case "down", "offline", "error", "failed", "fail", "unhealthy":
if isTransientHealthStatusMessage(string(body)) {
return "unknown", rawStatus
}
return "offline", rawStatus
default:
return "online", rawStatus
}
}
func classifyExtensionHealthService(payload map[string]interface{}, serviceKey string) (string, string, bool) {
func classifyExtensionHealthService(payload map[string]any, serviceKey string) (string, string, bool) {
rawServices, ok := payload["services"]
if !ok {
return "", "", false
}
services, ok := rawServices.(map[string]interface{})
services, ok := rawServices.(map[string]any)
if !ok {
return "", "", false
}
@@ -306,7 +344,7 @@ func classifyExtensionHealthService(payload map[string]interface{}, serviceKey s
if !ok {
return "unknown", fmt.Sprintf("service '%s' not found", serviceKey), true
}
service, ok := rawService.(map[string]interface{})
service, ok := rawService.(map[string]any)
if !ok {
return "unknown", fmt.Sprintf("service '%s' has invalid health payload", serviceKey), true
}
@@ -327,42 +365,53 @@ func classifyExtensionHealthService(payload map[string]interface{}, serviceKey s
rawStatus, hasStatus := service["status"]
okValue, hasOK := service["ok"].(bool)
joinedMessage := strings.Join(messageParts, ": ")
transient := isTransientHealthStatusMessage(detail) ||
isTransientHealthStatusMessage(errText) ||
isTransientHealthStatusMessage(label)
if statusCode, ok := healthNumber(rawStatus); ok {
if statusCode >= 200 && statusCode < 300 {
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return "degraded", strings.Join(messageParts, ": "), true
return "degraded", joinedMessage, true
}
if statusCode == http.StatusInternalServerError && hasOK && okValue {
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
}
return "offline", strings.Join(messageParts, ": "), true
if transient || isTransientHealthStatusCode(statusCode) {
return "unknown", joinedMessage, true
}
return "offline", joinedMessage, true
}
if isExtensionHealthAuthRequired(detail) {
return "degraded", strings.Join(messageParts, ": "), true
return "degraded", joinedMessage, true
}
if transient {
return "unknown", joinedMessage, true
}
if hasOK {
if okValue {
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
}
return "offline", strings.Join(messageParts, ": "), true
return "offline", joinedMessage, true
}
if !hasStatus {
return "unknown", strings.Join(messageParts, ": "), true
return "unknown", joinedMessage, true
}
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
switch statusString {
case "ok", "up", "online", "healthy", "operational":
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
case "degraded", "partial", "warning", "warn":
return "degraded", strings.Join(messageParts, ": "), true
return "degraded", joinedMessage, true
case "down", "offline", "error", "failed", "fail", "unhealthy":
return "offline", strings.Join(messageParts, ": "), true
return "offline", joinedMessage, true
default:
return "unknown", strings.Join(messageParts, ": "), true
return "unknown", joinedMessage, true
}
}
@@ -375,7 +424,33 @@ func isExtensionHealthAuthRequired(detail string) bool {
}
}
func healthNumber(value interface{}) (int, bool) {
func isTransientHealthStatusMessage(text string) bool {
t := strings.ToLower(strings.TrimSpace(text))
if t == "" {
return false
}
return strings.Contains(t, "context deadline exceeded") ||
strings.Contains(t, "deadline exceeded") ||
strings.Contains(t, "timeout") ||
strings.Contains(t, "timed out") ||
strings.Contains(t, "temporarily unavailable") ||
strings.Contains(t, "try again")
}
func isTransientHealthStatusCode(code int) bool {
switch code {
case http.StatusRequestTimeout,
http.StatusTooManyRequests,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
func healthNumber(value any) (int, bool) {
switch v := value.(type) {
case float64:
return int(v), true
@@ -1,8 +1,10 @@
package gobackend
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"testing"
@@ -18,7 +20,7 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
if status, msg := classifyExtensionHealthBody([]byte(`{"services":{"tidal":{"status":401,"label":"Tidal","detail":"auth_required"}}}`), "tidal"); status != "degraded" || !strings.Contains(msg, "Tidal") {
t.Fatalf("service status/message = %q/%q", status, msg)
}
if status, msg, ok := classifyExtensionHealthService(map[string]interface{}{"services": map[string]interface{}{}}, "missing"); !ok || status != "unknown" || !strings.Contains(msg, "missing") {
if status, msg, ok := classifyExtensionHealthService(map[string]any{"services": map[string]any{}}, "missing"); !ok || status != "unknown" || !strings.Contains(msg, "missing") {
t.Fatalf("missing service = %q/%q/%v", status, msg, ok)
}
if n, ok := healthNumber(json.Number("503")); !ok || n != 503 {
@@ -27,6 +29,12 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
if !isExtensionHealthAuthRequired(" unauthorized ") {
t.Fatal("expected auth required")
}
if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) {
t.Fatal("expected timeout health errors to be transient")
}
if !isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) {
t.Fatal("expected health transport lookup errors to be indeterminate")
}
if result := CheckExtensionHealth(nil); result.Status != "offline" {
t.Fatalf("nil health = %#v", result)
@@ -63,11 +71,7 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
}
}
func TestCoverRomajiParallelAndIDHSHelpers(t *testing.T) {
spotify := "https://i.scdn.co/image/ab67616d00001e02abcdef"
if got := GetCoverFromSpotify(spotify, true); !strings.Contains(got, spotifySizeMax) {
t.Fatalf("spotify cover = %q", got)
}
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") {
t.Fatalf("deezer cover = %q", got)
}
@@ -81,32 +85,6 @@ func TestCoverRomajiParallelAndIDHSHelpers(t *testing.T) {
t.Fatalf("expected empty cover error")
}
if !ContainsJapanese("カタカナ") || ContainsJapanese("abc") {
t.Fatal("unexpected Japanese detection")
}
if got := JapaneseToRomaji("きゃット"); got != "kyatto" {
t.Fatalf("romaji = %q", got)
}
if got := BuildSearchQuery("きゃ! song", "アーティスト"); got != "atisuto kya song" {
t.Fatalf("query = %q", got)
}
if got := CleanToASCII("A, B. C!"); got != "A B C" {
t.Fatalf("ascii = %q", got)
}
if err := PreWarmCache(`not-json`); err == nil {
t.Fatal("expected prewarm JSON error")
}
if err := PreWarmCache(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist","spotify_id":"sp","service":"tidal"}]`); err != nil {
t.Fatalf("PreWarmCache: %v", err)
}
if result := FetchCoverAndLyricsParallel("", false, "", "", "", false, 0); result == nil || result.CoverErr != nil || result.LyricsErr != nil {
t.Fatalf("parallel result = %#v", result)
}
if ClearTrackCache(); GetCacheSize() != 0 {
t.Fatal("expected empty cache size")
}
client := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
t.Fatalf("method = %s", req.Method)
+409 -161
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
@@ -44,27 +45,110 @@ func compareVersions(v1, v2 string) int {
return 0
}
type loadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *extensionRuntime
initialized bool
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
func isExtensionPackagePath(filePath string) bool {
lowerPath := strings.ToLower(filePath)
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
}
func getExtensionInitSettings(extensionID string) map[string]interface{} {
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 {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *extensionRuntime
indexProgram *goja.Program
initialized bool
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
isolatedPoolMu sync.Mutex
isolatedPool []*isolatedRuntimeHandle
}
type isolatedRuntimeHandle struct {
vm *goja.Runtime
runtime *extensionRuntime
}
func getExtensionInitSettings(extensionID string) map[string]any {
settings := GetExtensionSettingsStore().GetAll(extensionID)
if len(settings) == 0 {
return settings
}
filtered := make(map[string]interface{}, len(settings))
filtered := make(map[string]any, len(settings))
for key, value := range settings {
if strings.HasPrefix(key, "_") {
continue
@@ -166,8 +250,8 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
}
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -237,48 +321,33 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
return nil, fmt.Errorf("extension '%s' was installed by another process", manifest.DisplayName)
}
extDir := filepath.Join(m.extensionsDir, manifest.Name)
if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err)
extDir, err := managedExtensionPath(m.extensionsDir, manifest.Name)
if err != nil {
return nil, err
}
if _, err := os.Lstat(extDir); err == nil {
return nil, fmt.Errorf("extension directory already exists for %q", manifest.Name)
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to inspect extension directory: %w", err)
}
stagingDir, err := os.MkdirTemp(m.extensionsDir, "."+manifest.Name+"-install-*")
if err != nil {
return nil, fmt.Errorf("failed to create extension staging directory: %w", err)
}
stagingCommitted := false
defer func() {
if !stagingCommitted {
_ = os.RemoveAll(stagingDir)
}
}()
if err := extractExtensionArchive(zipReader, stagingDir); err != nil {
return nil, err
}
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
continue
}
destPath := filepath.Join(extDir, relPath)
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
}
destFile, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
}
srcFile, err := file.Open()
if err != nil {
destFile.Close()
return nil, fmt.Errorf("failed to open file in archive: %w", err)
}
_, err = io.Copy(destFile, srcFile)
srcFile.Close()
destFile.Close()
if err != nil {
return nil, fmt.Errorf("failed to extract file: %w", err)
}
extDataDir, err := managedExtensionPath(m.dataDir, manifest.Name)
if err != nil {
return nil, err
}
extDataDir := filepath.Join(m.dataDir, manifest.Name)
if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
@@ -288,7 +357,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
Manifest: manifest,
Enabled: false, // New extensions start disabled
DataDir: extDataDir,
SourceDir: extDir,
SourceDir: stagingDir,
}
if err := validateExtensionLoad(ext); err != nil {
@@ -296,6 +365,11 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
ext.Enabled = false
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
}
if err := os.Rename(stagingDir, extDir); err != nil {
return nil, fmt.Errorf("failed to activate extension: %w", err)
}
stagingCommitted = true
ext.SourceDir = extDir
m.extensions[manifest.Name] = ext
GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version)
@@ -306,6 +380,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
func initializeVMLocked(ext *loadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.indexProgram = nil
ext.initialized = false
vm := goja.New()
ext.VM = vm
@@ -315,6 +390,11 @@ func initializeVMLocked(ext *loadedExtension) error {
if err != nil {
return fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return fmt.Errorf("failed to compile extension code: %w", err)
}
ext.indexProgram = indexProgram
runtime := newExtensionRuntime(ext)
ext.runtime = runtime
@@ -323,7 +403,7 @@ func initializeVMLocked(ext *loadedExtension) error {
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, len(call.Arguments))
args := make([]any, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
@@ -341,7 +421,7 @@ func initializeVMLocked(ext *loadedExtension) error {
return goja.Undefined()
})
_, err = vm.RunString(string(jsCode))
_, err = vm.RunProgram(indexProgram)
if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err)
}
@@ -356,20 +436,26 @@ func initializeVMLocked(ext *loadedExtension) error {
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
vm := goja.New()
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
indexProgram := ext.indexProgram
if indexProgram == nil {
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
}
}
runtime := &extensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
cookieJar: nil,
dataDir: ext.DataDir,
vm: vm,
storageFlushDelay: defaultStorageFlushDelay,
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
@@ -384,7 +470,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, len(call.Arguments))
args := make([]any, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
@@ -402,7 +488,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
return goja.Undefined()
})
if _, err := vm.RunString(string(jsCode)); err != nil {
if _, err := vm.RunProgram(indexProgram); err != nil {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
}
@@ -423,6 +509,109 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
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()
@@ -432,7 +621,7 @@ func (m *extensionManager) initializeVM(ext *loadedExtension) error {
func initializeExtensionRuntimeWithSettings(
vm *goja.Runtime,
extensionID string,
settings map[string]interface{},
settings map[string]any,
) error {
settingsJSON, err := json.Marshal(settings)
if err != nil {
@@ -462,7 +651,7 @@ func initializeExtensionRuntimeWithSettings(
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
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 {
@@ -479,7 +668,7 @@ func initializeExtensionRuntimeWithSettings(
func initializeExtensionWithSettingsLocked(
ext *loadedExtension,
settings map[string]interface{},
settings map[string]any,
) error {
if ext.VM == nil {
return fmt.Errorf("extension failed to load: please reinstall the extension")
@@ -534,7 +723,7 @@ func runCleanupOnVM(vm *goja.Runtime) error {
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
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 {
@@ -549,6 +738,7 @@ func runCleanupOnVM(vm *goja.Runtime) error {
}
func teardownVMLocked(ext *loadedExtension) {
drainIsolatedRuntimePool(ext)
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
}
@@ -574,6 +764,16 @@ func validateExtensionLoad(ext *loadedExtension) error {
return nil
}
func teardownExtension(ext *loadedExtension) {
if ext == nil {
return
}
ext.Enabled = false
ext.VMMu.Lock()
teardownVMLocked(ext)
ext.VMMu.Unlock()
}
func (m *extensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -583,6 +783,7 @@ func (m *extensionManager) UnloadExtension(extensionID string) error {
return fmt.Errorf("extension not found")
}
ext.Enabled = false
ext.VMMu.Lock()
teardownVMLocked(ext)
ext.VMMu.Unlock()
@@ -673,7 +874,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
loaded = append(loaded, ext.ID)
}
}
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
} else if isExtensionPackagePath(entry.Name()) {
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
if err != nil {
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
@@ -712,7 +913,14 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
return existing, nil
}
extDataDir := filepath.Join(m.dataDir, manifest.Name)
expectedSourceDir, err := managedExtensionPath(m.extensionsDir, manifest.Name)
if err != nil || filepath.Clean(dirPath) != filepath.Clean(expectedSourceDir) {
return nil, fmt.Errorf("extension directory name must match manifest name %q", manifest.Name)
}
extDataDir, err := managedExtensionPath(m.dataDir, manifest.Name)
if err != nil {
return nil, err
}
if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
@@ -754,14 +962,27 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
return err
}
sourceDir, err := managedExtensionPath(m.extensionsDir, ext.ID)
if err != nil || !isPathWithinBase(m.extensionsDir, ext.SourceDir) || filepath.Clean(ext.SourceDir) != filepath.Clean(sourceDir) {
return fmt.Errorf("refusing to remove extension outside the managed source directory")
}
dataDir, err := managedExtensionPath(m.dataDir, ext.ID)
if err != nil || !isPathWithinBase(m.dataDir, ext.DataDir) || filepath.Clean(ext.DataDir) != filepath.Clean(dataDir) {
return fmt.Errorf("refusing to remove extension outside the managed data directory")
}
if err := m.UnloadExtension(extensionID); err != nil {
return err
}
if ext.SourceDir != "" {
if err := os.RemoveAll(ext.SourceDir); err != nil {
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
}
if err := os.RemoveAll(sourceDir); err != nil {
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
}
// Uninstall means gone: storage.json and encrypted credentials must not
// linger on disk after the extension is removed.
if err := os.RemoveAll(dataDir); err != nil {
GoLog("[Extension] Warning: failed to remove data dir: %v\n", err)
}
return nil
@@ -775,8 +996,8 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
}
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -836,56 +1057,28 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
extDataDir := existing.DataDir
extDir := existing.SourceDir
extDataDir, err := managedExtensionPath(m.dataDir, newManifest.Name)
if err != nil || filepath.Clean(existing.DataDir) != filepath.Clean(extDataDir) {
return nil, fmt.Errorf("installed extension has an invalid data directory")
}
extDir, err := managedExtensionPath(m.extensionsDir, newManifest.Name)
if err != nil || filepath.Clean(existing.SourceDir) != filepath.Clean(extDir) {
return nil, fmt.Errorf("installed extension has an invalid source directory")
}
wasEnabled := existing.Enabled
m.UnloadExtension(existing.ID)
if extDir != "" {
if err := os.RemoveAll(extDir); err != nil {
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
}
stagingDir, err := os.MkdirTemp(m.extensionsDir, "."+newManifest.Name+"-upgrade-*")
if err != nil {
return nil, fmt.Errorf("failed to create upgrade staging directory: %w", err)
}
if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err)
}
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
continue
}
destPath := filepath.Join(extDir, relPath)
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
}
destFile, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
}
srcFile, err := file.Open()
if err != nil {
destFile.Close()
return nil, fmt.Errorf("failed to open file in archive: %w", err)
}
_, err = io.Copy(destFile, srcFile)
srcFile.Close()
destFile.Close()
if err != nil {
return nil, fmt.Errorf("failed to extract file: %w", err)
stagingActive := true
defer func() {
if stagingActive {
_ = os.RemoveAll(stagingDir)
}
}()
if err := extractExtensionArchive(zipReader, stagingDir); err != nil {
return nil, err
}
ext := &loadedExtension{
@@ -893,22 +1086,53 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
Manifest: newManifest,
Enabled: wasEnabled, // Preserve enabled state from before upgrade
DataDir: extDataDir,
SourceDir: extDir,
SourceDir: stagingDir,
}
if wasEnabled {
if err := ext.ensureRuntimeReady(); err != nil {
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
return nil, fmt.Errorf("upgraded extension failed validation: %w", err)
}
} else if err := validateExtensionLoad(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
return nil, fmt.Errorf("upgraded extension failed validation: %w", err)
}
backupDir, err := os.MkdirTemp(m.extensionsDir, "."+newManifest.Name+"-backup-*")
if err != nil {
teardownExtension(ext)
return nil, fmt.Errorf("failed to prepare upgrade backup: %w", err)
}
if err := os.Remove(backupDir); err != nil {
teardownExtension(ext)
return nil, fmt.Errorf("failed to prepare upgrade backup: %w", err)
}
if err := os.Rename(extDir, backupDir); err != nil {
teardownExtension(ext)
return nil, fmt.Errorf("failed to preserve current extension: %w", err)
}
if err := os.Rename(stagingDir, extDir); err != nil {
_ = os.Rename(backupDir, extDir)
teardownExtension(ext)
return nil, fmt.Errorf("failed to activate upgraded extension: %w", err)
}
stagingActive = false
ext.SourceDir = extDir
existing.Enabled = false
if err := m.UnloadExtension(existing.ID); err != nil {
_ = os.RemoveAll(extDir)
_ = os.Rename(backupDir, extDir)
existing.Enabled = wasEnabled
teardownExtension(ext)
return nil, fmt.Errorf("failed to unload current extension: %w", err)
}
m.mu.Lock()
m.extensions[newManifest.Name] = ext
m.mu.Unlock()
if err := os.RemoveAll(backupDir); err != nil {
GoLog("[Extension] Warning: failed to remove upgrade backup: %v\n", err)
}
GoLog("[Extension] Upgraded extension: %s to v%s\n", newManifest.DisplayName, newManifest.Version)
@@ -924,8 +1148,8 @@ type ExtensionUpgradeInfo struct {
}
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -1024,7 +1248,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"service_health,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
Capabilities map[string]any `json:"capabilities,omitempty"`
}
infos := make([]ExtensionInfo, len(extensions))
@@ -1036,6 +1260,15 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
if ext.Manifest.Permissions.Storage {
permissions = append(permissions, "storage:enabled")
}
if ext.Manifest.Permissions.File {
permissions = append(permissions, "file:enabled")
}
if ext.Manifest.Permissions.AllowHTTP {
permissions = append(permissions, "network:http")
}
if ext.Manifest.HasCapability("rawFfmpeg") {
permissions = append(permissions, "ffmpeg:raw")
}
status := "loaded"
if ext.Error != "" {
@@ -1046,15 +1279,19 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
iconPath := ""
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
if _, err := os.Stat(possibleIcon); err == nil {
iconPath = possibleIcon
possibleIcon, safe := safeExtensionAssetPath(ext.SourceDir, ext.Manifest.Icon)
if safe {
if _, err := os.Stat(possibleIcon); err == nil {
iconPath = possibleIcon
}
}
}
if iconPath == "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
if _, err := os.Stat(possibleIcon); err == nil {
iconPath = possibleIcon
possibleIcon, safe := safeExtensionAssetPath(ext.SourceDir, "icon.png")
if safe {
if _, err := os.Stat(possibleIcon); err == nil {
iconPath = possibleIcon
}
}
}
@@ -1095,7 +1332,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
return string(jsonBytes), nil
}
func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -1150,7 +1387,7 @@ func (m *extensionManager) UnloadAllExtensions() {
GoLog("[Extension] All extensions unloaded\n")
}
func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]any, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -1170,14 +1407,16 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
// Merge extension return values onto the top-level JSON object so Flutter can read
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
actionNameLiteral := strconv.Quote(actionName)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
return { success: true, pending: true, message: 'Action started' };
}
(function() {
var actionName = %s;
function runAction(fn) {
try {
var result = fn();
if (result && typeof result.then === 'function') {
return { success: true, pending: true, message: 'Action started' };
}
if (result !== null && result !== undefined && typeof result === 'object') {
var isArr = false;
if (typeof Array !== 'undefined' && Array.isArray) {
@@ -1192,29 +1431,38 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
}
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
} catch (e) {
return { success: false, error: e.toString() };
}
}
}
return { success: false, error: 'Action function not found: %s' };
})()
`, actionName, actionName, actionName)
if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') {
return runAction(function() { return extension[actionName](); });
}
if (actionName === 'completeGrant' && typeof session !== 'undefined' && session && typeof session.completeGrant === 'function') {
return runAction(function() { return session.completeGrant(); });
}
return { success: false, error: 'Action function not found: ' + actionName };
})()
`, actionNameLiteral)
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
if err != nil {
if IsRuntimeUnsafeError(err) {
quarantineRuntimeLocked(ext, vm)
}
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
return nil, fmt.Errorf("action failed: %v", err)
}
if result == nil || goja.IsUndefined(result) {
return map[string]interface{}{"success": true}, nil
return map[string]any{"success": true}, nil
}
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if resultMap, ok := exported.(map[string]any); ok {
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
return resultMap, nil
}
return map[string]interface{}{"success": true, "result": exported}, nil
return map[string]any{"success": true, "result": exported}, nil
}
@@ -34,9 +34,13 @@ registerExtension({
});
`
pkgV1 := filepath.Join(dir, "manager-ext-v1.spotiflac-ext")
createTestExtensionPackage(t, pkgV1, "manager-ext", "1.0.0", js, map[string]string{"../unsafe.txt": "skip"})
createTestExtensionPackage(t, pkgV1, "manager-ext", "1.0.0", js, nil)
pkgV2 := filepath.Join(dir, "manager-ext-v2.spotiflac-ext")
createTestExtensionPackage(t, pkgV2, "manager-ext", "1.1.0", js, nil)
brokenUpgrade := filepath.Join(dir, "manager-ext-broken.spotiflac-ext")
createTestExtensionPackage(t, brokenUpgrade, "manager-ext", "1.0.1", `registerExtension({`, nil)
unsafePkg := filepath.Join(dir, "unsafe.spotiflac-ext")
createTestExtensionPackage(t, unsafePkg, "manager-ext", "1.0.0", js, map[string]string{"../unsafe.txt": "blocked"})
if compareVersions("v1.2.0", "1.1.9") <= 0 || compareVersions("1.0.0", "1.0") != 0 || compareVersions("1.0.0", "1.0.1") >= 0 {
t.Fatal("compareVersions mismatch")
@@ -47,6 +51,9 @@ registerExtension({
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "missing.spotiflac-ext")); err == nil {
t.Fatal("expected invalid package error")
}
if _, err := manager.LoadExtensionFromFile(unsafePkg); err == nil {
t.Fatal("expected unsafe archive path to reject the package")
}
ext, err := manager.LoadExtensionFromFile(pkgV1)
if err != nil {
@@ -55,9 +62,6 @@ registerExtension({
if ext.ID != "manager-ext" || ext.Enabled || ext.SourceDir == "" {
t.Fatalf("loaded extension = %#v", ext)
}
if _, err := os.Stat(filepath.Join(ext.SourceDir, "unsafe.txt")); err == nil {
t.Fatal("unsafe archive path should not be extracted")
}
if _, err := manager.LoadExtensionFromFile(pkgV1); err == nil {
t.Fatal("expected duplicate version error")
}
@@ -66,7 +70,7 @@ registerExtension({
if err != nil || !strings.Contains(installedJSON, "manager-ext") || !strings.Contains(installedJSON, "icon_path") {
t.Fatalf("GetInstalledExtensionsJSON = %q/%v", installedJSON, err)
}
var installed []map[string]interface{}
var installed []map[string]any
if err := json.Unmarshal([]byte(installedJSON), &installed); err != nil || len(installed) != 1 {
t.Fatalf("decode installed = %#v/%v", installed, err)
}
@@ -80,7 +84,7 @@ registerExtension({
if !ext.Enabled || ext.VM == nil || !ext.initialized {
t.Fatalf("enabled extension = %#v", ext)
}
if err := manager.InitializeExtension("manager-ext", map[string]interface{}{"quality": "hires"}); err != nil {
if err := manager.InitializeExtension("manager-ext", map[string]any{"quality": "hires"}); err != nil {
t.Fatalf("InitializeExtension: %v", err)
}
action, err := manager.InvokeAction("manager-ext", "doAction")
@@ -93,6 +97,16 @@ registerExtension({
if err := manager.SetExtensionEnabled("manager-ext", false); err != nil {
t.Fatalf("disable extension: %v", err)
}
if _, err := manager.UpgradeExtension(brokenUpgrade); err == nil {
t.Fatal("expected invalid upgrade to be rejected")
}
stillInstalled, err := manager.GetExtension("manager-ext")
if err != nil || stillInstalled.Manifest.Version != "1.0.0" {
t.Fatalf("failed upgrade replaced the installed version: %#v/%v", stillInstalled, err)
}
if _, err := os.Stat(filepath.Join(stillInstalled.SourceDir, "index.js")); err != nil {
t.Fatalf("failed upgrade removed the working source: %v", err)
}
if ext.VM != nil || ext.initialized {
t.Fatalf("expected VM teardown, got %#v", ext)
}
+92 -24
View File
@@ -3,9 +3,13 @@ package gobackend
import (
"encoding/json"
"fmt"
"net/url"
"regexp"
"strings"
)
var extensionIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`)
type ExtensionType string
const (
@@ -38,7 +42,7 @@ type ExtensionSetting struct {
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"`
Default any `json:"default,omitempty"`
Options []string `json:"options,omitempty"`
Action string `json:"action,omitempty"`
}
@@ -57,7 +61,7 @@ type QualitySpecificSetting struct {
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"`
Default any `json:"default,omitempty"`
Options []string `json:"options,omitempty"`
}
@@ -113,28 +117,49 @@ type ExtensionHealthCheck struct {
Required bool `json:"required,omitempty"`
}
type SignedSessionEndpoints struct {
Bootstrap string `json:"bootstrap,omitempty"`
Challenge string `json:"challenge,omitempty"`
Exchange string `json:"exchange,omitempty"`
Refresh string `json:"refresh,omitempty"`
}
type SignedSessionConfig struct {
Namespace string `json:"namespace"`
BaseURL string `json:"baseUrl"`
AppVersion string `json:"appVersion,omitempty"`
Platform string `json:"platform,omitempty"`
CallbackURL string `json:"callbackUrl,omitempty"`
SchemeLabel string `json:"schemeLabel,omitempty"`
HeaderPrefix string `json:"headerPrefix,omitempty"`
TimeWindowSeconds int `json:"timeWindowSeconds,omitempty"`
Endpoints SignedSessionEndpoints `json:"endpoints,omitempty"`
}
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
Capabilities map[string]any `json:"capabilities,omitempty"`
}
type ManifestValidationError struct {
@@ -163,6 +188,12 @@ func (m *ExtensionManifest) Validate() error {
if strings.TrimSpace(m.Name) == "" {
return &ManifestValidationError{Field: "name", Message: "name is required"}
}
if !extensionIDPattern.MatchString(m.Name) {
return &ManifestValidationError{
Field: "name",
Message: "name must be a lowercase extension ID containing only letters, numbers, '.', '_' or '-'",
}
}
if strings.TrimSpace(m.Version) == "" {
return &ManifestValidationError{Field: "version", Message: "version is required"}
@@ -200,7 +231,6 @@ func (m *ExtensionManifest) Validate() error {
}
}
// Select type requires options
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].options", i),
@@ -238,9 +268,47 @@ func (m *ExtensionManifest) Validate() error {
}
}
if m.SignedSession != nil {
if !m.Permissions.Storage {
return &ManifestValidationError{Field: "permissions.storage", Message: "signedSession requires storage permission"}
}
if strings.TrimSpace(m.SignedSession.Namespace) == "" {
return &ManifestValidationError{Field: "signedSession.namespace", Message: "namespace is required"}
}
baseURL := strings.TrimSpace(m.SignedSession.BaseURL)
if baseURL == "" {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is required"}
}
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl must use https"}
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Hostname() == "" {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is invalid"}
}
if !m.IsDomainAllowed(parsed.Hostname()) {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl host must be listed in permissions.network"}
}
}
if m.HasCapability("rawFfmpeg") && !m.Permissions.File {
return &ManifestValidationError{Field: "permissions.file", Message: "rawFfmpeg capability requires file permission"}
}
return nil
}
func (m *ExtensionManifest) HasCapability(name string) bool {
if m == nil || m.Capabilities == nil {
return false
}
value, ok := m.Capabilities[name]
if !ok {
return false
}
enabled, ok := value.(bool)
return ok && enabled
}
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
for _, et := range m.Types {
if et == t {
+201
View File
@@ -0,0 +1,201 @@
package gobackend
import (
"strings"
"sync"
)
var providerPriority []string
var providerPriorityMu sync.RWMutex
var extensionFallbackProviderIDs []string
var extensionFallbackProviderIDsMu sync.RWMutex
var metadataProviderPriority []string
var metadataProviderPriorityMu sync.RWMutex
func SetProviderPriority(providerIDs []string) {
providerPriorityMu.Lock()
defer providerPriorityMu.Unlock()
providerPriority = sanitizeDownloadProviderPriority(providerIDs)
GoLog("[Extension] Download provider priority set: %v\n", providerPriority)
}
func GetProviderPriority() []string {
providerPriorityMu.RLock()
defer providerPriorityMu.RUnlock()
if len(providerPriority) == 0 {
return []string{}
}
result := make([]string, len(providerPriority))
copy(result, providerPriority)
return result
}
func sanitizeDownloadProviderPriority(providerIDs []string) []string {
sanitized := make([]string, 0, len(providerIDs))
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
if providerID == "" {
continue
}
if isRetiredBuiltInDownloadProvider(providerID) {
continue
}
seenKey := strings.ToLower(providerID)
if _, exists := seen[seenKey]; exists {
continue
}
seen[seenKey] = struct{}{}
sanitized = append(sanitized, providerID)
}
return sanitized
}
func isRetiredBuiltInDownloadProvider(providerID string) bool {
normalized := strings.ToLower(strings.TrimSpace(providerID))
if normalized == "" {
return false
}
switch normalized {
case "deezer", "qobuz", "tidal":
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
return manifest.IsDownloadProvider()
})
default:
return false
}
}
func isRetiredBuiltInMetadataProvider(providerID string) bool {
normalized := strings.ToLower(strings.TrimSpace(providerID))
if normalized == "" {
return false
}
switch normalized {
case "deezer", "spotify", "qobuz", "tidal":
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
return manifest.IsMetadataProvider()
})
default:
return false
}
}
func hasEnabledExtensionProvider(providerID string, matches func(*ExtensionManifest) bool) bool {
if providerID == "" || matches == nil {
return false
}
manager := getExtensionManager()
manager.mu.RLock()
defer manager.mu.RUnlock()
for id, ext := range manager.extensions {
if !strings.EqualFold(strings.TrimSpace(id), providerID) {
continue
}
if ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil {
return false
}
return matches(ext.Manifest)
}
return false
}
func SetExtensionFallbackProviderIDs(providerIDs []string) {
extensionFallbackProviderIDsMu.Lock()
defer extensionFallbackProviderIDsMu.Unlock()
if providerIDs == nil {
extensionFallbackProviderIDs = nil
GoLog("[Extension] Extension fallback providers reset to default (all enabled download extensions)\n")
return
}
sanitized := make([]string, 0, len(providerIDs))
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
if providerID == "" {
continue
}
if _, exists := seen[providerID]; exists {
continue
}
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
extensionFallbackProviderIDs = sanitized
GoLog("[Extension] Extension fallback providers set: %v\n", sanitized)
}
func GetExtensionFallbackProviderIDs() []string {
extensionFallbackProviderIDsMu.RLock()
defer extensionFallbackProviderIDsMu.RUnlock()
if extensionFallbackProviderIDs == nil {
return nil
}
result := make([]string, len(extensionFallbackProviderIDs))
copy(result, extensionFallbackProviderIDs)
return result
}
func isExtensionFallbackAllowed(providerID string) bool {
allowed := GetExtensionFallbackProviderIDs()
if allowed == nil {
return true
}
for _, allowedProviderID := range allowed {
if allowedProviderID == providerID {
return true
}
}
return false
}
func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
sanitized := make([]string, 0, len(providerIDs))
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
if providerID == "" || isRetiredBuiltInMetadataProvider(providerID) {
continue
}
if _, exists := seen[providerID]; exists {
continue
}
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
metadataProviderPriority = sanitized
GoLog("[Extension] Metadata provider priority set: %v\n", sanitized)
}
func GetMetadataProviderPriority() []string {
metadataProviderPriorityMu.RLock()
defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 {
return []string{}
}
result := make([]string, len(metadataProviderPriority))
copy(result, metadataProviderPriority)
return result
}
@@ -57,22 +57,14 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) {
t.Fatalf("enriched = %#v", enriched)
}
availability, err := provider.CheckAvailability("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz")
availability, err := provider.CheckAvailabilityForItemID("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz", 0, "")
if err != nil {
t.Fatalf("CheckAvailability: %v", err)
t.Fatalf("CheckAvailabilityForItemID: %v", err)
}
if !availability.Available || availability.TrackID != "download-track" || !availability.SkipFallback {
t.Fatalf("availability = %#v", availability)
}
downloadURL, err := provider.GetDownloadURL("track-1", "LOSSLESS")
if err != nil {
t.Fatalf("GetDownloadURL: %v", err)
}
if downloadURL.Format != "flac" || downloadURL.BitDepth != 24 || downloadURL.SampleRate != 96000 {
t.Fatalf("download URL = %#v", downloadURL)
}
progress := []int{}
download, err := provider.Download("track-1", "LOSSLESS", filepath.Join(t.TempDir(), "song.flac"), "", func(percent int) {
progress = append(progress, percent)
@@ -100,18 +92,7 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) {
t.Fatalf("url result = %#v", urlResult)
}
match, err := provider.MatchTrack(
map[string]interface{}{"name": "Song", "artists": "Artist"},
[]map[string]interface{}{{"id": "download-track", "name": "Song"}},
)
if err != nil {
t.Fatalf("MatchTrack: %v", err)
}
if !match.Matched || match.TrackID != "download-track" {
t.Fatalf("match = %#v", match)
}
post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]interface{}{"title": "Song"}, "hook")
post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]any{"title": "Song"}, "hook")
if err != nil {
t.Fatalf("PostProcess: %v", err)
}
@@ -121,8 +102,8 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) {
}
func TestExtensionProviderAndManagerSelectionHelpers(t *testing.T) {
manifest := &ExtensionManifest{Capabilities: map[string]interface{}{
"replacesBuiltInProviders": []interface{}{" Deezer ", 7, ""},
manifest := &ExtensionManifest{Capabilities: map[string]any{
"replacesBuiltInProviders": []any{" Deezer ", 7, ""},
}}
if values := manifestCapabilityStringList(manifest, "replacesBuiltInProviders"); len(values) != 1 || values[0] != "deezer" {
t.Fatalf("capability list = %#v", values)
+209
View File
@@ -0,0 +1,209 @@
package gobackend
import "strings"
type ExtTrackMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
ArtistURL string `json:"artist_url,omitempty"`
ExternalURL string `json:"external_urls,omitempty"`
DurationMS int `json:"duration_ms"`
CoverURL string `json:"cover_url,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
Images string `json:"images,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ISRC string `json:"isrc,omitempty"`
ProviderID string `json:"provider_id"`
ItemType string `json:"item_type,omitempty"`
AlbumType string `json:"album_type,omitempty"`
Explicit bool `json:"explicit,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"`
ExternalLinks map[string]string `json:"external_links,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Genre string `json:"genre,omitempty"`
Composer string `json:"composer,omitempty"`
AudioQuality string `json:"audio_quality,omitempty"`
AudioModes string `json:"audio_modes,omitempty"`
}
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
if t.CoverURL != "" {
return t.CoverURL
}
return t.Images
}
type ExtAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
ArtistID string `json:"artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"`
HeaderVideo string `json:"header_video,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"`
AlbumType string `json:"album_type,omitempty"`
AudioTraits []string `json:"audio_traits,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks"`
ProviderID string `json:"provider_id"`
}
type ExtArtistMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"`
HeaderVideo string `json:"header_video,omitempty"`
Listeners int `json:"listeners,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
ProviderID string `json:"provider_id"`
}
type ExtSearchResult struct {
Tracks []ExtTrackMetadata `json:"tracks"`
Total int `json:"total"`
}
type ExtAvailabilityResult struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
TrackID string `json:"track_id,omitempty"`
SkipFallback bool `json:"skip_fallback,omitempty"`
}
type DownloadDecryptionInfo struct {
Strategy string `json:"strategy,omitempty"`
Key string `json:"key,omitempty"`
IV string `json:"iv,omitempty"`
InputFormat string `json:"input_format,omitempty"`
OutputExtension string `json:"output_extension,omitempty"`
Options map[string]any `json:"options,omitempty"`
}
type ExtDownloadResult struct {
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ISRC string `json:"isrc,omitempty"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
ActualExtension string `json:"actual_extension,omitempty"`
OutputExtension string `json:"output_extension,omitempty"`
ActualContainer string `json:"actual_container,omitempty"`
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
}
const genericFFmpegMOVDecryptionStrategy = "ffmpeg.mov_key"
func cloneDownloadDecryptionInfo(info *DownloadDecryptionInfo) *DownloadDecryptionInfo {
if info == nil {
return nil
}
cloned := &DownloadDecryptionInfo{
Strategy: strings.TrimSpace(info.Strategy),
Key: strings.TrimSpace(info.Key),
IV: strings.TrimSpace(info.IV),
InputFormat: strings.TrimSpace(info.InputFormat),
OutputExtension: strings.TrimSpace(info.OutputExtension),
}
if len(info.Options) > 0 {
cloned.Options = make(map[string]any, len(info.Options))
for key, value := range info.Options {
cloned.Options[key] = value
}
}
return cloned
}
func normalizeDownloadDecryptionStrategy(strategy string) string {
switch strings.ToLower(strings.TrimSpace(strategy)) {
case "", "ffmpeg.mov_key", "ffmpeg_mov_key", "mov_decryption_key", "mp4_decryption_key", "ffmpeg.mp4_decryption_key":
return genericFFmpegMOVDecryptionStrategy
default:
return strings.TrimSpace(strategy)
}
}
func normalizeDownloadDecryptionInfo(info *DownloadDecryptionInfo, legacyKey string) *DownloadDecryptionInfo {
normalized := cloneDownloadDecryptionInfo(info)
trimmedLegacyKey := strings.TrimSpace(legacyKey)
if normalized == nil {
if trimmedLegacyKey == "" {
return nil
}
return &DownloadDecryptionInfo{
Strategy: genericFFmpegMOVDecryptionStrategy,
Key: trimmedLegacyKey,
InputFormat: "mov",
}
}
normalized.Strategy = normalizeDownloadDecryptionStrategy(normalized.Strategy)
if normalized.Key == "" && trimmedLegacyKey != "" {
normalized.Key = trimmedLegacyKey
}
if normalized.Strategy == "" && normalized.Key != "" {
normalized.Strategy = genericFFmpegMOVDecryptionStrategy
}
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.InputFormat == "" {
normalized.InputFormat = "mov"
}
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.Key == "" {
return nil
}
return normalized
}
func normalizedDownloadDecryptionKey(info *DownloadDecryptionInfo, legacyKey string) string {
if normalized := normalizeDownloadDecryptionInfo(info, legacyKey); normalized != nil {
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy {
return normalized.Key
}
}
return strings.TrimSpace(legacyKey)
}
+978
View File
@@ -0,0 +1,978 @@
package gobackend
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/dop251/goja"
)
type extensionProviderWrapper struct {
extension *loadedExtension
vm *goja.Runtime
}
func newExtensionProviderWrapper(ext *loadedExtension) *extensionProviderWrapper {
return &extensionProviderWrapper{
extension: ext,
vm: ext.VM,
}
}
func (p *extensionProviderWrapper) lockReadyVM() error {
vm, err := p.extension.lockReadyVM()
if err != nil {
return err
}
p.vm = vm
return nil
}
// extCallOpts configures a shared extension invocation. It covers the
// skeleton common to most extensionProviderWrapper methods: perf tracking, VM
// locking, optional download/request cancellation binding, and translating
// timeouts/cancellation into the right error.
type extCallOpts struct {
perfName string
invoke func(vm *goja.Runtime) (goja.Value, error)
timeout time.Duration
itemID string // optional: binds download-cancel + active-item tracking
requestID string // optional: binds request-cancel via context (customSearch only)
// beforeRun runs after lock+cancel setup, right before the invocation. Its
// returned cleanup, if any, runs after the call.
beforeRun func() func()
// timeoutMessage overrides the default "<perfName> timeout: extension took
// too long to respond".
timeoutMessage string
// rawError returns non-timeout script errors unwrapped instead of
// "<perfName> failed: %w".
rawError bool
}
// callExtension locks the extension's VM, runs opts.invoke, and hands
// the raw result to parse while the VM lock is still held. parse is where
// each caller does its type-specific parsing, perf.recordParse/setItems, and
// any ProviderID stamping.
func callExtension[T any](p *extensionProviderWrapper, opts extCallOpts, parse func(perf *extensionCallPerf, result goja.Value) (T, error)) (T, error) {
var zero T
perf := newExtensionCallPerf(p.extension.ID, opts.perfName)
defer perf.finish()
initStartedAt := time.Now()
if err := p.lockReadyVM(); err != nil {
return zero, err
}
perf.recordInit(time.Since(initStartedAt))
defer p.extension.VMMu.Unlock()
if opts.itemID != "" {
if p.extension.runtime != nil {
p.extension.runtime.setActiveDownloadItemID(opts.itemID)
defer p.extension.runtime.clearActiveDownloadItemID()
}
initDownloadCancel(opts.itemID)
defer clearDownloadCancel(opts.itemID)
if isDownloadCancelled(opts.itemID) {
return zero, ErrDownloadCancelled
}
}
ctx := context.Background()
if opts.requestID != "" {
if p.extension.runtime != nil {
p.extension.runtime.setActiveRequestID(opts.requestID)
defer p.extension.runtime.clearActiveRequestID()
}
ctx = initExtensionRequestCancel(opts.requestID)
defer clearExtensionRequestCancel(opts.requestID)
if isExtensionRequestCancelled(opts.requestID) {
return zero, ErrExtensionRequestCancelled
}
}
if opts.beforeRun != nil {
if cleanup := opts.beforeRun(); cleanup != nil {
defer cleanup()
}
}
jsStartedAt := time.Now()
result, err := runGojaCallWithTimeoutContextAndRecover(ctx, p.vm, func() (goja.Value, error) {
return opts.invoke(p.vm)
}, opts.timeout)
perf.recordJS(time.Since(jsStartedAt))
perf.recordPayload(result)
if err != nil {
if IsRuntimeUnsafeError(err) {
quarantineRuntimeLocked(p.extension, p.vm)
}
if opts.requestID != "" && isExtensionRequestCancelled(opts.requestID) {
return zero, ErrExtensionRequestCancelled
}
if opts.itemID != "" && isDownloadCancelled(opts.itemID) {
return zero, ErrDownloadCancelled
}
if opts.requestID != "" && errors.Is(err, ErrExtensionRequestCancelled) {
return zero, ErrExtensionRequestCancelled
}
if IsTimeoutError(err) {
if opts.timeoutMessage != "" {
return zero, errors.New(opts.timeoutMessage)
}
return zero, fmt.Errorf("%s timeout: extension took too long to respond", opts.perfName)
}
if opts.rawError {
return zero, err
}
return zero, fmt.Errorf("%s failed: %w", opts.perfName, err)
}
if opts.itemID != "" && isDownloadCancelled(opts.itemID) {
return zero, ErrDownloadCancelled
}
if opts.requestID != "" && isExtensionRequestCancelled(opts.requestID) {
return zero, ErrExtensionRequestCancelled
}
return parse(perf, result)
}
func invokeExtensionMethod(vm *goja.Runtime, method string, args ...any) (goja.Value, error) {
extensionValue := vm.Get("extension")
if gojaValueIsEmpty(extensionValue) {
return goja.Null(), nil
}
extensionObject := extensionValue.ToObject(vm)
callable, ok := goja.AssertFunction(extensionObject.Get(method))
if !ok {
return goja.Null(), nil
}
values := make([]goja.Value, len(args))
for i, arg := range args {
values[i] = gojaArgumentValue(vm, arg)
}
return callable(extensionObject, values...)
}
func gojaArgumentValue(vm *goja.Runtime, value any) goja.Value {
switch typed := value.(type) {
case goja.Value:
return typed
case map[string]any:
obj := vm.NewObject()
for key, child := range typed {
_ = obj.Set(key, gojaArgumentValue(vm, child))
}
return obj
case map[string]string:
obj := vm.NewObject()
for key, child := range typed {
_ = obj.Set(key, child)
}
return obj
case []any:
children := make([]any, len(typed))
for i, child := range typed {
children[i] = gojaArgumentValue(vm, child)
}
return vm.NewArray(children...)
case []string:
children := make([]any, len(typed))
for i, child := range typed {
children[i] = child
}
return vm.NewArray(children...)
default:
return vm.ToValue(value)
}
}
func extensionMethodInvocation(method string, args ...any) func(*goja.Runtime) (goja.Value, error) {
return func(vm *goja.Runtime) (goja.Value, error) {
return invokeExtensionMethod(vm, method, args...)
}
}
func hasExtensionMethod(vm *goja.Runtime, method string) bool {
extensionValue := vm.Get("extension")
if gojaValueIsEmpty(extensionValue) {
return false
}
_, ok := goja.AssertFunction(extensionValue.ToObject(vm).Get(method))
return ok
}
func invokeExtensionOrGlobal(vm *goja.Runtime, method string, args ...any) (goja.Value, error) {
if hasExtensionMethod(vm, method) {
return invokeExtensionMethod(vm, method, args...)
}
callable, ok := goja.AssertFunction(vm.Get(method))
if !ok {
return goja.Null(), nil
}
values := make([]goja.Value, len(args))
for i, arg := range args {
values[i] = gojaArgumentValue(vm, arg)
}
return callable(vm.GlobalObject(), values...)
}
func extensionTrackInput(track *ExtTrackMetadata) map[string]any {
if track == nil {
return map[string]any{}
}
return map[string]any{
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"album_id": track.AlbumID,
"album_url": track.AlbumURL,
"artist_id": track.ArtistID,
"artist_url": track.ArtistURL,
"external_urls": track.ExternalURL,
"duration_ms": track.DurationMS,
"cover_url": track.CoverURL,
"preview_url": track.PreviewURL,
"images": track.Images,
"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,
"explicit": track.Explicit,
"tidal_id": track.TidalID,
"qobuz_id": track.QobuzID,
"deezer_id": track.DeezerID,
"spotify_id": track.SpotifyID,
"external_links": track.ExternalLinks,
"label": track.Label,
"copyright": track.Copyright,
"genre": track.Genre,
"composer": track.Composer,
"audio_quality": track.AudioQuality,
"audio_modes": track.AudioModes,
}
}
func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
return p.SearchTracksForItemID(query, limit, "")
}
func (p *extensionProviderWrapper) SearchTracksForItemID(query string, limit int, itemID string) (*ExtSearchResult, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
return callExtension(p, extCallOpts{
perfName: "searchTracks",
invoke: extensionMethodInvocation("searchTracks", query, limit),
timeout: DefaultJSTimeout,
itemID: itemID,
}, func(perf *extensionCallPerf, result goja.Value) (*ExtSearchResult, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return nil, fmt.Errorf("searchTracks returned null")
}
parseStartedAt := time.Now()
searchResult, err := parseExtensionSearchResult(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
if err != nil {
return nil, fmt.Errorf("failed to parse search result: %w", err)
}
perf.setItems(len(searchResult.Tracks))
for i := range searchResult.Tracks {
searchResult.Tracks[i].ProviderID = p.extension.ID
}
return &searchResult, nil
})
}
func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
return callExtension(p, extCallOpts{
perfName: "getTrack",
invoke: extensionMethodInvocation("getTrack", trackID),
timeout: DefaultJSTimeout,
}, func(perf *extensionCallPerf, result goja.Value) (*ExtTrackMetadata, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return nil, fmt.Errorf("getTrack returned null")
}
parseStartedAt := time.Now()
track := parseExtensionTrackValue(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
perf.setItems(1)
track.ProviderID = p.extension.ID
return &track, nil
})
}
func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
return callExtension(p, extCallOpts{
perfName: "getAlbum",
invoke: extensionMethodInvocation("getAlbum", albumID),
timeout: DefaultJSTimeout,
}, func(perf *extensionCallPerf, result goja.Value) (*ExtAlbumMetadata, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return nil, fmt.Errorf("getAlbum returned null")
}
parseStartedAt := time.Now()
album, err := parseExtensionAlbumValue(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
if err != nil {
return nil, fmt.Errorf("failed to parse album: %w", err)
}
perf.setItems(len(album.Tracks))
album.ProviderID = p.extension.ID
for i := range album.Tracks {
album.Tracks[i].ProviderID = p.extension.ID
}
return &album, nil
})
}
func (p *extensionProviderWrapper) GetPlaylist(playlistID string) (*ExtAlbumMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
return callExtension(p, extCallOpts{
perfName: "getPlaylist",
invoke: func(vm *goja.Runtime) (goja.Value, error) {
if hasExtensionMethod(vm, "getPlaylist") {
return invokeExtensionMethod(vm, "getPlaylist", playlistID)
}
return invokeExtensionMethod(vm, "getAlbum", playlistID)
},
timeout: DefaultJSTimeout,
}, func(perf *extensionCallPerf, result goja.Value) (*ExtAlbumMetadata, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return nil, fmt.Errorf("getPlaylist returned null")
}
parseStartedAt := time.Now()
playlist, err := parseExtensionAlbumValue(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
if err != nil {
return nil, fmt.Errorf("failed to parse playlist: %w", err)
}
perf.setItems(len(playlist.Tracks))
playlist.ProviderID = p.extension.ID
for i := range playlist.Tracks {
playlist.Tracks[i].ProviderID = p.extension.ID
}
return &playlist, nil
})
}
func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
return callExtension(p, extCallOpts{
perfName: "getArtist",
invoke: extensionMethodInvocation("getArtist", artistID),
timeout: DefaultJSTimeout,
}, func(perf *extensionCallPerf, result goja.Value) (*ExtArtistMetadata, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return nil, fmt.Errorf("getArtist returned null")
}
parseStartedAt := time.Now()
artist, err := parseExtensionArtistValue(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
if err != nil {
return nil, fmt.Errorf("failed to parse artist: %w", err)
}
perf.setItems(len(artist.Albums) + len(artist.Releases) + len(artist.TopTracks))
artist.ProviderID = p.extension.ID
for i := range artist.Releases {
artist.Releases[i].ProviderID = p.extension.ID
for j := range artist.Releases[i].Tracks {
artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
}
}
return &artist, nil
})
}
func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
return p.EnrichTrackForItemID(track, "")
}
// EnrichTrackForItemID is excluded from the shared callExtension helper:
// unlike the other providers it must return the original track (not an error)
// on every failure path, which doesn't fit the helper's error-returning shape.
func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata, itemID string) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return track, nil
}
if !p.extension.Enabled {
return track, nil
}
perf := newExtensionCallPerf(p.extension.ID, "enrichTrack")
defer perf.finish()
initStartedAt := time.Now()
if err := p.lockReadyVM(); err != nil {
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
return track, nil
}
perf.recordInit(time.Since(initStartedAt))
defer p.extension.VMMu.Unlock()
if itemID != "" {
if p.extension.runtime != nil {
p.extension.runtime.setActiveDownloadItemID(itemID)
defer p.extension.runtime.clearActiveDownloadItemID()
}
initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
if isDownloadCancelled(itemID) {
return track, ErrDownloadCancelled
}
}
jsStartedAt := time.Now()
result, err := runGojaCallWithTimeoutAndRecover(p.vm, func() (goja.Value, error) {
return invokeExtensionMethod(p.vm, "enrichTrack", extensionTrackInput(track))
}, DefaultJSTimeout)
perf.recordJS(time.Since(jsStartedAt))
perf.recordPayload(result)
if err != nil {
if IsRuntimeUnsafeError(err) {
quarantineRuntimeLocked(p.extension, p.vm)
}
if isDownloadCancelled(itemID) {
return track, ErrDownloadCancelled
}
if IsTimeoutError(err) {
GoLog("[Extension] EnrichTrack timeout for %s\n", p.extension.ID)
} else {
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
}
return track, nil
}
if isDownloadCancelled(itemID) {
return track, ErrDownloadCancelled
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return track, nil
}
parseStartedAt := time.Now()
enrichedTrack := parseExtensionTrackValue(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
perf.setItems(1)
enrichedTrack.ProviderID = track.ProviderID
return &enrichedTrack, nil
}
func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string, durationMS int, itemID string) (*ExtAvailabilityResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
availabilityOptions := map[string]any{
"spotify_id": spotifyID,
"deezer_id": deezerID,
"tidal_id": tidalID,
"qobuz_id": qobuzID,
"duration_ms": durationMS,
}
return callExtension(p, extCallOpts{
perfName: "checkAvailability",
invoke: extensionMethodInvocation("checkAvailability", isrc, trackName, artistName, availabilityOptions),
timeout: DefaultJSTimeout,
itemID: itemID,
beforeRun: func() func() {
// Drop any stale flag so the post-run check below only sees
// verification requested by THIS call.
if p.extension.runtime != nil {
p.extension.runtime.consumeVerificationRequired()
}
return nil
},
}, func(perf *extensionCallPerf, result goja.Value) (*ExtAvailabilityResult, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil
}
parseStartedAt := time.Now()
availability := parseExtensionAvailabilityValue(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
perf.setItems(1)
// A signed-session call inside checkAvailability required
// verification. Extensions often swallow that and report a plain
// "not available", which would silently skip this provider's
// challenge; surface it as an error so the fallback loop pauses and
// opens the challenge instead.
if !availability.Available && p.extension.runtime != nil {
if p.extension.runtime.consumeVerificationRequired() != "" {
return nil, fmt.Errorf(
"VERIFY_REQUIRED: extension '%s' needs signed-session verification",
p.extension.ID,
)
}
}
return &availability, nil
})
}
const ExtDownloadTimeout = DownloadTimeout
// Download is excluded from the shared callExtension helper: it runs in
// an isolated VM/runtime (not p.vm/p.extension.VMMu) with a progress
// callback, which the helper's lock+perf model doesn't cover.
func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
perf := newExtensionCallPerf(p.extension.ID, "download")
defer perf.finish()
initStartedAt := time.Now()
vm, runtime, err := acquireIsolatedExtensionRuntime(p.extension)
perf.recordInit(time.Since(initStartedAt))
if err != nil {
return &ExtDownloadResult{
Success: false,
ErrorMessage: err.Error(),
ErrorType: "init_error",
}, nil
}
vmHealthy := false
cleanupSafe := true
defer func() {
releaseIsolatedExtensionRuntime(p.extension, vm, runtime, vmHealthy, cleanupSafe)
}()
if runtime != nil {
runtime.setActiveDownloadItemID(itemID)
defer runtime.clearActiveDownloadItemID()
}
if itemID != "" {
initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
SetItemPreparing(itemID)
}
progressCallback := vm.ToValue(func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
percent := int(call.Arguments[0].ToInteger())
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
if onProgress != nil {
onProgress(percent)
}
}
return goja.Undefined()
})
if runtime != nil {
// Drop any stale flag (pooled runtimes survive across downloads) so
// the post-run check only sees verification from THIS call.
runtime.consumeVerificationRequired()
}
jsStartedAt := time.Now()
result, err := runGojaCallWithTimeoutAndRecover(vm, func() (goja.Value, error) {
return invokeExtensionMethod(vm, "download", trackID, quality, outputPath, progressCallback)
}, ExtDownloadTimeout)
perf.recordJS(time.Since(jsStartedAt))
perf.recordPayload(result)
vmHealthy = err == nil
cleanupSafe = !IsRuntimeUnsafeError(err)
if err != nil {
errMsg := err.Error()
errType := "script_error"
if IsTimeoutError(err) {
errMsg = "download timeout: extension took too long to complete"
errType = "timeout"
}
return &ExtDownloadResult{
Success: false,
ErrorMessage: errMsg,
ErrorType: errType,
}, nil
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return &ExtDownloadResult{
Success: false,
ErrorMessage: "download returned null",
ErrorType: "not_implemented",
}, nil
}
parseStartedAt := time.Now()
downloadResult := parseExtensionDownloadResultValue(vm, result)
perf.recordParse(time.Since(parseStartedAt))
perf.setItems(1)
downloadResult.Decryption = normalizeDownloadDecryptionInfo(
downloadResult.Decryption,
downloadResult.DecryptionKey,
)
downloadResult.DecryptionKey = normalizedDownloadDecryptionKey(
downloadResult.Decryption,
downloadResult.DecryptionKey,
)
// A signed-session call inside download() required verification but the
// script reported a generic failure; tag the result so the fallback loop
// pauses and opens this provider's challenge instead of skipping it.
if runtime != nil && !downloadResult.Success {
if runtime.consumeVerificationRequired() != "" &&
!strings.EqualFold(downloadResult.ErrorType, "verification_required") {
downloadResult.ErrorType = "verification_required"
if downloadResult.ErrorMessage == "" {
downloadResult.ErrorMessage = "Verification required"
}
}
}
return &downloadResult, nil
}
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]any) ([]ExtTrackMetadata, error) {
return p.customSearch(query, options, "", "")
}
func (p *extensionProviderWrapper) CustomSearchForRequestID(query string, options map[string]any, requestID string) ([]ExtTrackMetadata, error) {
return p.customSearch(query, options, "", requestID)
}
func (p *extensionProviderWrapper) customSearch(query string, options map[string]any, itemID, requestID string) ([]ExtTrackMetadata, error) {
if !p.extension.Manifest.HasCustomSearch() {
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if options == nil {
options = map[string]any{}
}
return callExtension(p, extCallOpts{
perfName: "customSearch",
invoke: extensionMethodInvocation("customSearch", query, options),
timeout: DefaultJSTimeout,
itemID: itemID,
requestID: requestID,
}, func(perf *extensionCallPerf, result goja.Value) ([]ExtTrackMetadata, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return []ExtTrackMetadata{}, nil
}
parseStartedAt := time.Now()
tracks, err := parseExtensionTrackArray(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
if err != nil {
return nil, fmt.Errorf("failed to parse search result: %w", err)
}
perf.setItems(len(tracks))
for i := range tracks {
tracks[i].ProviderID = p.extension.ID
}
return tracks, nil
})
}
type ExtURLHandleResult struct {
Type string `json:"type"`
Track *ExtTrackMetadata `json:"track,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
Album *ExtAlbumMetadata `json:"album,omitempty"`
Artist *ExtArtistMetadata `json:"artist,omitempty"`
Name string `json:"name,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"`
HeaderVideo string `json:"header_video,omitempty"`
}
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
if !p.extension.Manifest.HasURLHandler() {
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
return callExtension(p, extCallOpts{
perfName: "handleUrl",
invoke: extensionMethodInvocation("handleUrl", url),
timeout: DefaultJSTimeout,
}, func(perf *extensionCallPerf, result goja.Value) (*ExtURLHandleResult, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return nil, fmt.Errorf("handleUrl returned null - URL not recognized")
}
parseStartedAt := time.Now()
handleResult, err := parseExtensionURLHandleValue(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
if err != nil {
return nil, fmt.Errorf("failed to parse URL handle result: %w", err)
}
urlItems := len(handleResult.Tracks)
if handleResult.Track != nil {
urlItems++
}
if handleResult.Album != nil {
urlItems += 1 + len(handleResult.Album.Tracks)
}
if handleResult.Artist != nil {
urlItems += 1 + len(handleResult.Artist.Albums) + len(handleResult.Artist.Releases) + len(handleResult.Artist.TopTracks)
}
perf.setItems(urlItems)
if handleResult.Track != nil {
handleResult.Track.ProviderID = p.extension.ID
}
for i := range handleResult.Tracks {
handleResult.Tracks[i].ProviderID = p.extension.ID
}
if handleResult.Album != nil {
handleResult.Album.ProviderID = p.extension.ID
for i := range handleResult.Album.Tracks {
handleResult.Album.Tracks[i].ProviderID = p.extension.ID
}
}
if handleResult.Artist != nil {
handleResult.Artist.ProviderID = p.extension.ID
for i := range handleResult.Artist.Albums {
handleResult.Artist.Albums[i].ProviderID = p.extension.ID
for j := range handleResult.Artist.Albums[i].Tracks {
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
}
}
for i := range handleResult.Artist.Releases {
handleResult.Artist.Releases[i].ProviderID = p.extension.ID
for j := range handleResult.Artist.Releases[i].Tracks {
handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
}
}
for i := range handleResult.Artist.TopTracks {
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
}
}
return &handleResult, nil
})
}
type PostProcessResult struct {
Success bool `json:"success"`
NewFilePath string `json:"new_file_path,omitempty"`
NewFileURI string `json:"new_file_uri,omitempty"`
Error string `json:"error,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
}
type PostProcessInput struct {
Path string `json:"path,omitempty"`
URI string `json:"uri,omitempty"`
Name string `json:"name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size,omitempty"`
IsSAF bool `json:"is_saf,omitempty"`
}
func postProcessInputMap(input PostProcessInput) map[string]any {
result := make(map[string]any, 6)
if input.Path != "" {
result["path"] = input.Path
}
if input.URI != "" {
result["uri"] = input.URI
}
if input.Name != "" {
result["name"] = input.Name
}
if input.MimeType != "" {
result["mime_type"] = input.MimeType
}
if input.Size != 0 {
result["size"] = input.Size
}
if input.IsSAF {
result["is_saf"] = true
}
return result
}
const PostProcessTimeout = 2 * time.Minute
// postProcessCommon backs both PostProcess (V1) and PostProcessV2. V1 probes
// only extension.postProcess (its original contract: V2-only extensions are
// not invoked via V1); V2 probes postProcessV2 first, then falls back to
// postProcess.
func (p *extensionProviderWrapper) postProcessCommon(input PostProcessInput, metadata map[string]any, hookID string, preferV2 bool) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() {
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
filePath := input.Path
perfName := "postProcess"
var invoke func(*goja.Runtime) (goja.Value, error)
if preferV2 {
perfName = "postProcessV2"
inputMap := postProcessInputMap(input)
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 {
invoke = extensionMethodInvocation("postProcess", filePath, metadata, hookID)
}
result, err := callExtension(p, extCallOpts{
perfName: perfName,
invoke: invoke,
timeout: PostProcessTimeout,
timeoutMessage: "postProcess timeout: extension took too long to complete",
rawError: true,
}, func(perf *extensionCallPerf, value goja.Value) (*PostProcessResult, error) {
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return &PostProcessResult{Success: false, Error: "postProcess returned null"}, nil
}
parseStartedAt := time.Now()
postResult := parseExtensionPostProcessValue(p.vm, value)
perf.recordParse(time.Since(parseStartedAt))
perf.setItems(1)
return &postResult, nil
})
if err != nil {
return &PostProcessResult{Success: false, Error: err.Error()}, 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 {
Lines []ExtLyricsLine `json:"lines"`
SyncType string `json:"syncType"`
Instrumental bool `json:"instrumental"`
PlainLyrics string `json:"plainLyrics"`
Provider string `json:"provider"`
}
type ExtLyricsLine struct {
StartTimeMs int64 `json:"startTimeMs"`
Words string `json:"words"`
EndTimeMs int64 `json:"endTimeMs"`
}
func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
if !p.extension.Manifest.IsLyricsProvider() {
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
return callExtension(p, extCallOpts{
perfName: "fetchLyrics",
invoke: extensionMethodInvocation("fetchLyrics", trackName, artistName, albumName, durationSec),
timeout: DefaultJSTimeout,
}, func(perf *extensionCallPerf, result goja.Value) (*LyricsResponse, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return nil, fmt.Errorf("fetchLyrics returned null")
}
parseStartedAt := time.Now()
extResult, err := parseExtensionLyricsValue(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
if err != nil {
return nil, fmt.Errorf("failed to parse lyrics result: %w", err)
}
perf.setItems(len(extResult.Lines))
response := &LyricsResponse{
SyncType: extResult.SyncType,
Instrumental: extResult.Instrumental,
PlainLyrics: extResult.PlainLyrics,
Provider: extResult.Provider,
Source: "Extension: " + p.extension.ID,
}
if response.Provider == "" {
response.Provider = p.extension.Manifest.DisplayName
}
for _, line := range extResult.Lines {
response.Lines = append(response.Lines, LyricsLine(line))
}
if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental {
response.SyncType = "UNSYNCED"
for _, line := range strings.Split(response.PlainLyrics, "\n") {
if strings.TrimSpace(line) != "" {
response.Lines = append(response.Lines, LyricsLine{
StartTimeMs: 0,
Words: line,
EndTimeMs: 0,
})
}
}
}
return response, nil
})
}
File diff suppressed because it is too large Load Diff
-9
View File
@@ -745,15 +745,6 @@ func TestParseExtensionURLHandleResult(t *testing.T) {
func TestParseExtensionAuxiliaryResults(t *testing.T) {
vm := goja.New()
matchValue, err := vm.RunString(`({ matched: true, trackId: "track-1", confidence: 0.92, reason: "isrc" })`)
if err != nil {
t.Fatalf("build match value: %v", err)
}
match := parseExtensionMatchTrackValue(vm, matchValue)
if !match.Matched || match.TrackID != "track-1" || match.Confidence != 0.92 || match.Reason != "isrc" {
t.Fatalf("unexpected match result: %+v", match)
}
postValue, err := vm.RunString(`({ success: true, newFilePath: "/tmp/new.flac", newFileUri: "content://new", bitDepth: 24, sampleRate: 96000 })`)
if err != nil {
t.Fatalf("build post-process value: %v", err)
@@ -21,7 +21,7 @@ const (
CategoryIntegration = "integration"
)
type storeExtension struct {
type repoExtension struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
@@ -40,7 +40,7 @@ type storeExtension struct {
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
}
func (e *storeExtension) getDisplayName() string {
func (e *repoExtension) getDisplayName() string {
if e.DisplayName != "" {
return e.DisplayName
}
@@ -50,34 +50,34 @@ func (e *storeExtension) getDisplayName() string {
return e.Name
}
func (e *storeExtension) getDownloadURL() string {
func (e *repoExtension) getDownloadURL() string {
if e.DownloadURL != "" {
return e.DownloadURL
}
return e.DownloadURLAlt
}
func (e *storeExtension) getIconURL() string {
func (e *repoExtension) getIconURL() string {
if e.IconURL != "" {
return e.IconURL
}
return e.IconURLAlt
}
func (e *storeExtension) getMinAppVersion() string {
func (e *repoExtension) getMinAppVersion() string {
if e.MinAppVersion != "" {
return e.MinAppVersion
}
return e.MinAppVersionAlt
}
type storeRegistry struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
Extensions []storeExtension `json:"extensions"`
type repoRegistry struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
Extensions []repoExtension `json:"extensions"`
}
type storeExtensionResponse struct {
type repoExtensionResponse struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
@@ -95,8 +95,8 @@ type storeExtensionResponse struct {
HasUpdate bool `json:"has_update"`
}
func (e *storeExtension) toResponse() storeExtensionResponse {
resp := storeExtensionResponse{
func (e *repoExtension) toResponse() repoExtensionResponse {
resp := repoExtensionResponse{
ID: e.ID,
Name: e.Name,
DisplayName: e.getDisplayName(),
@@ -117,18 +117,18 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
return resp
}
type extensionStore struct {
type extensionRepo struct {
registryURL string
cacheDir string
cache *storeRegistry
cache *repoRegistry
cacheMu sync.RWMutex
cacheTime time.Time
cacheTTL time.Duration
}
var (
globalExtensionStore *extensionStore
extensionStoreMu sync.Mutex
globalExtensionRepo *extensionRepo
extensionRepoMu sync.Mutex
)
const (
@@ -136,22 +136,22 @@ const (
cacheFileName = "store_cache.json"
)
func initExtensionStore(cacheDir string) *extensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
func initExtensionRepo(cacheDir string) *extensionRepo {
extensionRepoMu.Lock()
defer extensionRepoMu.Unlock()
if globalExtensionStore == nil {
globalExtensionStore = &extensionStore{
if globalExtensionRepo == nil {
globalExtensionRepo = &extensionRepo{
registryURL: "",
cacheDir: cacheDir,
cacheTTL: cacheTTL,
}
globalExtensionStore.loadDiskCache()
globalExtensionRepo.loadDiskCache()
}
return globalExtensionStore
return globalExtensionRepo
}
func (s *extensionStore) setRegistryURL(registryURL string) {
func (s *extensionRepo) setRegistryURL(registryURL string) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
@@ -168,22 +168,22 @@ func (s *extensionStore) setRegistryURL(registryURL string) {
os.Remove(cachePath)
}
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
LogInfo("ExtensionRepo", "Registry URL updated to: %s", registryURL)
}
func (s *extensionStore) getRegistryURL() string {
func (s *extensionRepo) getRegistryURL() string {
s.cacheMu.RLock()
defer s.cacheMu.RUnlock()
return s.registryURL
}
func getExtensionStore() *extensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
return globalExtensionStore
func getExtensionRepo() *extensionRepo {
extensionRepoMu.Lock()
defer extensionRepoMu.Unlock()
return globalExtensionRepo
}
func (s *extensionStore) loadDiskCache() {
func (s *extensionRepo) loadDiskCache() {
if s.cacheDir == "" {
return
}
@@ -195,8 +195,9 @@ func (s *extensionStore) loadDiskCache() {
}
var cacheData struct {
Registry storeRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
RegistryURL string `json:"registry_url"`
Registry repoRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}
if err := json.Unmarshal(data, &cacheData); err != nil {
@@ -205,20 +206,27 @@ func (s *extensionStore) loadDiskCache() {
s.cache = &cacheData.Registry
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
if s.registryURL == "" {
// Restore the URL that produced this cache so a later setRegistryURL
// with the same URL keeps the cache instead of wiping it.
s.registryURL = cacheData.RegistryURL
}
LogDebug("ExtensionRepo", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
}
func (s *extensionStore) saveDiskCache() {
func (s *extensionRepo) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil {
return
}
cacheData := struct {
Registry storeRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
RegistryURL string `json:"registry_url"`
Registry repoRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}{
Registry: *s.cache,
CacheTime: s.cacheTime.Unix(),
RegistryURL: s.registryURL,
Registry: *s.cache,
CacheTime: s.cacheTime.Unix(),
}
data, err := json.Marshal(cacheData)
@@ -230,7 +238,7 @@ func (s *extensionStore) saveDiskCache() {
os.WriteFile(cachePath, data, 0644)
}
func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
func (s *extensionRepo) fetchRegistry(forceRefresh bool) (*repoRegistry, error) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
@@ -239,7 +247,7 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
}
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
LogDebug("ExtensionRepo", "Using cached registry (%d extensions)", len(s.cache.Extensions))
return s.cache, nil
}
@@ -247,13 +255,13 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
return nil, err
}
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
LogInfo("ExtensionRepo", "Fetching registry from %s", s.registryURL)
client := NewHTTPClientWithTimeout(30 * time.Second)
req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionStore", "Failed to build registry request, using cached registry: %v", err)
LogWarn("ExtensionRepo", "Failed to build registry request, using cached registry: %v", err)
return s.cache, nil
}
return nil, fmt.Errorf("failed to build registry request: %w", err)
@@ -263,7 +271,7 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
resp, err := client.Do(req)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
LogWarn("ExtensionRepo", "Network error, using cached registry: %v", err)
return s.cache, nil
}
return nil, fmt.Errorf("failed to fetch registry: %w", err)
@@ -272,7 +280,7 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
if resp.StatusCode != http.StatusOK {
if s.cache != nil {
LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode)
LogWarn("ExtensionRepo", "HTTP %d, using cached registry", resp.StatusCode)
return s.cache, nil
}
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
@@ -283,20 +291,35 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
return nil, fmt.Errorf("failed to read registry: %w", err)
}
var registry storeRegistry
if err := json.Unmarshal(body, &registry); err != nil {
return nil, fmt.Errorf("failed to parse registry: %w", err)
registry, err := parseRegistryBody(body)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionRepo", "Failed to parse registry, using cached registry: %v", err)
return s.cache, nil
}
return nil, err
}
s.cache = &registry
s.cache = registry
s.cacheTime = time.Now()
s.saveDiskCache()
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
LogInfo("ExtensionRepo", "Fetched %d extensions from registry", len(registry.Extensions))
return registry, nil
}
func parseRegistryBody(body []byte) (*repoRegistry, error) {
var registry repoRegistry
if err := json.Unmarshal(body, &registry); err != nil {
if strings.HasPrefix(strings.TrimSpace(string(body)), "<") {
return nil, fmt.Errorf("registry URL returned a web page instead of JSON. Make sure the URL points to a registry.json file or a GitHub repository that contains one")
}
return nil, fmt.Errorf("failed to parse registry: %w", err)
}
return &registry, nil
}
func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
func (s *extensionRepo) getExtensionsWithStatus(forceRefresh bool) ([]repoExtensionResponse, error) {
registry, err := s.fetchRegistry(forceRefresh)
if err != nil {
return nil, err
@@ -311,9 +334,9 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
}
}
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
LogDebug("ExtensionRepo", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
result := make([]storeExtensionResponse, 0, len(registry.Extensions))
result := make([]repoExtensionResponse, 0, len(registry.Extensions))
for i := range registry.Extensions {
ext := &registry.Extensions[i]
resp := ext.toResponse()
@@ -326,33 +349,37 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
result = append(result, resp)
}
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
LogDebug("ExtensionRepo", "Built store response payload for %d extensions", len(result))
return result, nil
}
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
func (s *extensionRepo) findExtension(extensionID string) (*repoExtension, error) {
registry, err := s.fetchRegistry(false)
if err != nil {
return err
return nil, err
}
var ext *storeExtension
for _, e := range registry.Extensions {
if e.ID == extensionID {
ext = &e
break
ext := e
return &ext, nil
}
}
if ext == nil {
return fmt.Errorf("extension %s not found in store", extensionID)
return nil, fmt.Errorf("extension %s not found in repo", extensionID)
}
func (s *extensionRepo) downloadExtension(extensionID string, destPath string) error {
ext, err := s.findExtension(extensionID)
if err != nil {
return err
}
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
return err
}
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
LogInfo("ExtensionRepo", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := NewHTTPClientWithTimeout(5 * time.Minute)
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
@@ -383,7 +410,7 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
return fmt.Errorf("failed to write file: %w", err)
}
LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath)
LogInfo("ExtensionRepo", "Downloaded %s to %s", ext.getDisplayName(), destPath)
return nil
}
@@ -418,7 +445,7 @@ func resolveRegistryURL(input string) (string, error) {
branch := resolveGitHubDefaultBranch(owner, repo)
resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch)
LogInfo("ExtensionRepo", "Resolved %s → %s (branch: %s)", input, resolved, branch)
return resolved, nil
}
@@ -428,13 +455,13 @@ func resolveGitHubDefaultBranch(owner, repo string) string {
resp, err := client.Get(apiURL)
if err != nil {
LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v falling back to main", owner, repo, err)
LogWarn("ExtensionRepo", "GitHub API request failed for %s/%s: %v falling back to main", owner, repo, err)
return "main"
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s falling back to main", resp.StatusCode, owner, repo)
LogWarn("ExtensionRepo", "GitHub API returned %d for %s/%s falling back to main", resp.StatusCode, owner, repo)
return "main"
}
@@ -442,7 +469,7 @@ func resolveGitHubDefaultBranch(owner, repo string) string {
DefaultBranch string `json:"default_branch"`
}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s falling back to main", owner, repo)
LogWarn("ExtensionRepo", "Could not parse default_branch for %s/%s falling back to main", owner, repo)
return "main"
}
@@ -463,7 +490,7 @@ func requireHTTPSURL(rawURL string, context string) error {
return nil
}
func (s *extensionStore) getCategories() []string {
func (s *extensionRepo) getCategories() []string {
return []string{
CategoryMetadata,
CategoryDownload,
@@ -473,7 +500,7 @@ func (s *extensionStore) getCategories() []string {
}
}
func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
func (s *extensionRepo) searchExtensions(query string, category string) ([]repoExtensionResponse, error) {
extensions, err := s.getExtensionsWithStatus(false)
if err != nil {
return nil, err
@@ -483,8 +510,8 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
return extensions, nil
}
result := make([]storeExtensionResponse, 0, len(extensions))
queryLower := toLower(query)
result := make([]repoExtensionResponse, 0, len(extensions))
queryLower := strings.ToLower(query)
for _, ext := range extensions {
if category != "" && ext.Category != category {
@@ -492,12 +519,12 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
}
if query != "" {
if !containsIgnoreCase(ext.Name, queryLower) &&
!containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) {
if !strings.Contains(strings.ToLower(ext.Name), queryLower) &&
!strings.Contains(strings.ToLower(ext.DisplayName), queryLower) &&
!strings.Contains(strings.ToLower(ext.Description), queryLower) {
found := false
for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) {
if strings.Contains(strings.ToLower(tag), queryLower) {
found = true
break
}
@@ -514,7 +541,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
return result, nil
}
func (s *extensionStore) clearCache() {
func (s *extensionRepo) clearCache() {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
@@ -526,34 +553,5 @@ func (s *extensionStore) clearCache() {
os.Remove(cachePath)
}
LogInfo("ExtensionStore", "Cache cleared")
}
func containsIgnoreCase(s, substr string) bool {
return containsStr(toLower(s), substr)
}
func toLower(s string) string {
result := make([]byte, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
result[i] = c
}
return string(result)
}
func containsStr(s, substr string) bool {
return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0)
}
func findSubstring(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
LogInfo("ExtensionRepo", "Cache cleared")
}
+208 -80
View File
@@ -1,18 +1,45 @@
package gobackend
import (
"context"
"fmt"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/dop251/goja"
)
// allowPrivateNetworkAccess, when enabled, disables the SSRF guard that blocks
// requests resolving to private/local/loopback addresses. This is opt-in and
// intended for users who route the app's traffic through a local proxy or
// custom DNS (e.g. a local mirror of api.zarz.moe). Disabled by default.
var allowPrivateNetworkAccess atomic.Bool
// SetAllowPrivateNetwork toggles whether extensions and built-in network code
// are permitted to reach private/local network targets. Exposed to the Flutter
// layer via the platform bridge.
func SetAllowPrivateNetwork(allowed bool) {
allowPrivateNetworkAccess.Store(allowed)
if allowed {
GoLog("[HTTP] Private/local network access ENABLED (SSRF guard relaxed)\n")
} else {
GoLog("[HTTP] Private/local network access disabled (default)\n")
}
}
// IsPrivateNetworkAllowed reports the current state of the private-network guard.
func IsPrivateNetworkAllowed() bool {
return allowPrivateNetworkAccess.Load()
}
const DefaultJSTimeout = 30 * time.Second
var (
@@ -35,8 +62,13 @@ type PendingAuthRequest struct {
ExtensionID string
AuthURL string
CallbackURL string
CreatedAt time.Time
}
// Challenge URLs are short-lived; serving one past this age sends the user
// to an already-expired verification page.
const pendingAuthRequestTTL = 5 * time.Minute
var (
pendingAuthRequests = make(map[string]*PendingAuthRequest)
pendingAuthRequestsMu sync.RWMutex
@@ -84,7 +116,7 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
type extensionRuntime struct {
extensionID string
manifest *ExtensionManifest
settings map[string]interface{}
settings map[string]any
httpClient *http.Client
downloadClient *http.Client
cookieJar http.CookieJar
@@ -97,18 +129,40 @@ type extensionRuntime struct {
activeRequestMu sync.RWMutex
activeRequestID string
storageMu sync.RWMutex
storageCache map[string]interface{}
storageLoaded bool
storageDirty bool
storageClosed bool
storageTimer *time.Timer
storageWriteMu sync.Mutex
storageMu sync.RWMutex
storageCache map[string]any
storageClosed bool
credentialsMu sync.RWMutex
credentialsCache map[string]interface{}
credentialsLoaded bool
storageFlushDelay time.Duration
credentialsMu sync.RWMutex
credentialsCache map[string]any
// Set when a signed-session call inside the current script invocation
// required verification. The provider wrapper consumes it after the
// script returns, so verification surfaces even when the extension
// script swallowed the needsVerification response (issue: fallback
// skipped provider B's challenge and failed outright).
verificationMu sync.Mutex
verificationRequiredURL string
}
func (r *extensionRuntime) noteVerificationRequired(authURL string) {
r.verificationMu.Lock()
if authURL == "" {
authURL = "pending"
}
r.verificationRequiredURL = authURL
r.verificationMu.Unlock()
}
// consumeVerificationRequired returns the noted auth URL (or "pending") and
// clears the flag; "" means no verification was requested since the last
// consume.
func (r *extensionRuntime) consumeVerificationRequired() string {
r.verificationMu.Lock()
url := r.verificationRequiredURL
r.verificationRequiredURL = ""
r.verificationMu.Unlock()
return url
}
type privateIPCacheEntry struct {
@@ -127,17 +181,22 @@ var (
privateIPCacheMu sync.RWMutex
)
func clearPrivateIPCache() {
privateIPCacheMu.Lock()
privateIPCache = make(map[string]privateIPCacheEntry)
privateIPCacheMu.Unlock()
}
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
jar, _ := newSimpleCookieJar()
runtime := &extensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
cookieJar: jar,
dataDir: ext.DataDir,
vm: ext.VM,
storageFlushDelay: defaultStorageFlushDelay,
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]any),
cookieJar: jar,
dataDir: ext.DataDir,
vm: ext.VM,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
@@ -171,7 +230,7 @@ func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Dur
return time.Duration(seconds) * time.Second
}
func parseExtensionTimeoutSeconds(raw interface{}) int {
func parseExtensionTimeoutSeconds(raw any) int {
switch v := raw.(type) {
case int:
return v
@@ -241,10 +300,53 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re
if requestID == "" {
return req
}
return req.WithContext(initExtensionRequestCancel(requestID))
return req.WithContext(extensionRequestCancelContext(requestID))
}
return req.WithContext(initDownloadCancel(itemID))
return req.WithContext(downloadCancelContext(itemID))
}
// downloadStallTimeout is how long a download may go without receiving a single
// byte before the stall watchdog aborts it. A dead radio mid-transfer otherwise
// blocks on Body.Read until the 24h client timeout with no error and no retry.
const downloadStallTimeout = 60 * time.Second
// stallWatchdog cancels an in-flight download when no data arrives within
// timeout. It wraps the request context in a child cancel so firing it does NOT
// set the user-cancel flag (isDownloadCancelled stays false) — a stall is a
// distinct, retryable condition. Call reset() after every successful Read and
// stop() when the transfer ends.
type stallWatchdog struct {
cancel context.CancelFunc
timer *time.Timer
timeout time.Duration
stalled atomic.Bool
}
func bindStallWatchdog(req *http.Request, timeout time.Duration) (*http.Request, *stallWatchdog) {
ctx, cancel := context.WithCancel(req.Context())
w := &stallWatchdog{cancel: cancel, timeout: timeout}
w.timer = time.AfterFunc(timeout, func() {
w.stalled.Store(true)
cancel()
})
return req.WithContext(ctx), w
}
func (w *stallWatchdog) reset() { w.timer.Reset(w.timeout) }
// stop halts the timer and releases the child context so a completed download
// leaks neither a pending timer nor a live cancel func.
func (w *stallWatchdog) stop() {
w.timer.Stop()
w.cancel()
}
// stallError is returned when the watchdog fires. The message is deliberately
// free of "cancel" and worded to classify as retryable network failure, so the
// fallback layer retries instead of treating it as a user cancellation.
func (r *extensionRuntime) stallError() goja.Value {
return r.jsError("download stalled: no data received for %ds (network timeout)", int(downloadStallTimeout.Seconds()))
}
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
@@ -303,6 +405,12 @@ func (e *RedirectBlockedError) Error() string {
}
func isPrivateIP(host string) bool {
// Opt-in escape hatch: when the user has enabled private/local network
// access, treat every host as public so local proxies / custom DNS work.
if allowPrivateNetworkAccess.Load() {
return false
}
hostLower := strings.ToLower(strings.TrimSpace(host))
if hostLower == "" {
return false
@@ -326,13 +434,7 @@ func isPrivateIP(host string) bool {
return false
}
isPrivate := false
for _, ip := range ips {
if isPrivateIPAddr(ip) {
isPrivate = true
break
}
}
isPrivate := slices.ContainsFunc(ips, isPrivateIPAddr)
setPrivateIPCache(hostLower, isPrivate, privateIPCacheTTL)
return isPrivate
@@ -399,30 +501,39 @@ func isPrivateIPAddr(ip net.IP) bool {
}
type simpleCookieJar struct {
cookies map[string][]*http.Cookie
mu sync.RWMutex
mu sync.RWMutex
jar *cookiejar.Jar
}
func newSimpleCookieJar() (*simpleCookieJar, error) {
return &simpleCookieJar{
cookies: make(map[string][]*http.Cookie),
}, nil
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &simpleCookieJar{jar: jar}, nil
}
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
j.mu.Lock()
defer j.mu.Unlock()
key := u.Host
j.cookies[key] = append(j.cookies[key], cookies...)
j.mu.RLock()
defer j.mu.RUnlock()
j.jar.SetCookies(u, cookies)
}
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
j.mu.RLock()
defer j.mu.RUnlock()
return j.cookies[u.Host]
return j.jar.Cookies(u)
}
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
func (j *simpleCookieJar) Clear() {
jar, _ := cookiejar.New(nil)
j.mu.Lock()
j.jar = jar
j.mu.Unlock()
}
func (r *extensionRuntime) SetSettings(settings map[string]any) {
r.settings = settings
}
@@ -439,50 +550,67 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
httpObj.Set("clearCookies", r.httpClearCookies)
vm.Set("http", httpObj)
storageObj := vm.NewObject()
storageObj.Set("get", r.storageGet)
storageObj.Set("set", r.storageSet)
storageObj.Set("remove", r.storageRemove)
vm.Set("storage", storageObj)
if r.manifest != nil && r.manifest.Permissions.Storage {
storageObj := vm.NewObject()
storageObj.Set("get", r.storageGet)
storageObj.Set("set", r.storageSet)
storageObj.Set("remove", r.storageRemove)
vm.Set("storage", storageObj)
credentialsObj := vm.NewObject()
credentialsObj.Set("store", r.credentialsStore)
credentialsObj.Set("get", r.credentialsGet)
credentialsObj.Set("remove", r.credentialsRemove)
credentialsObj.Set("has", r.credentialsHas)
vm.Set("credentials", credentialsObj)
credentialsObj := vm.NewObject()
credentialsObj.Set("store", r.credentialsStore)
credentialsObj.Set("get", r.credentialsGet)
credentialsObj.Set("remove", r.credentialsRemove)
credentialsObj.Set("has", r.credentialsHas)
vm.Set("credentials", credentialsObj)
}
authObj := vm.NewObject()
authObj.Set("openAuthUrl", r.authOpenUrl)
authObj.Set("getAuthCode", r.authGetCode)
authObj.Set("setAuthCode", r.authSetCode)
authObj.Set("clearAuth", r.authClear)
authObj.Set("isAuthenticated", r.authIsAuthenticated)
authObj.Set("getTokens", r.authGetTokens)
authObj.Set("generatePKCE", r.authGeneratePKCE)
authObj.Set("getPKCE", r.authGetPKCE)
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
vm.Set("auth", authObj)
if r.manifest != nil && r.manifest.Permissions.Storage {
authObj := vm.NewObject()
authObj.Set("openAuthUrl", r.authOpenUrl)
authObj.Set("getAuthCode", r.authGetCode)
authObj.Set("setAuthCode", r.authSetCode)
authObj.Set("clearAuth", r.authClear)
authObj.Set("isAuthenticated", r.authIsAuthenticated)
authObj.Set("getTokens", r.authGetTokens)
authObj.Set("generatePKCE", r.authGeneratePKCE)
authObj.Set("getPKCE", r.authGetPKCE)
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
vm.Set("auth", authObj)
fileObj := vm.NewObject()
fileObj.Set("download", r.fileDownload)
fileObj.Set("exists", r.fileExists)
fileObj.Set("delete", r.fileDelete)
fileObj.Set("read", r.fileRead)
fileObj.Set("readBytes", r.fileReadBytes)
fileObj.Set("write", r.fileWrite)
fileObj.Set("writeBytes", r.fileWriteBytes)
fileObj.Set("copy", r.fileCopy)
fileObj.Set("move", r.fileMove)
fileObj.Set("getSize", r.fileGetSize)
vm.Set("file", fileObj)
if r.manifest.SignedSession != nil {
sessionObj := vm.NewObject()
sessionObj.Set("signedFetch", r.signedSessionFetch)
sessionObj.Set("completeGrant", r.signedSessionCompleteGrant)
sessionObj.Set("status", r.signedSessionStatus)
sessionObj.Set("clear", r.signedSessionClear)
vm.Set("session", sessionObj)
}
}
ffmpegObj := vm.NewObject()
ffmpegObj.Set("execute", r.ffmpegExecute)
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
ffmpegObj.Set("convert", r.ffmpegConvert)
vm.Set("ffmpeg", ffmpegObj)
if r.manifest != nil && r.manifest.Permissions.File {
fileObj := vm.NewObject()
fileObj.Set("download", r.fileDownload)
fileObj.Set("exists", r.fileExists)
fileObj.Set("delete", r.fileDelete)
fileObj.Set("read", r.fileRead)
fileObj.Set("readBytes", r.fileReadBytes)
fileObj.Set("write", r.fileWrite)
fileObj.Set("writeBytes", r.fileWriteBytes)
fileObj.Set("copy", r.fileCopy)
fileObj.Set("move", r.fileMove)
fileObj.Set("getSize", r.fileGetSize)
vm.Set("file", fileObj)
ffmpegObj := vm.NewObject()
if r.manifest.HasCapability("rawFfmpeg") {
ffmpegObj.Set("execute", r.ffmpegExecute)
}
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
ffmpegObj.Set("convert", r.ffmpegConvert)
vm.Set("ffmpeg", ffmpegObj)
}
matchingObj := vm.NewObject()
matchingObj.Set("compareStrings", r.matchingCompareStrings)
+37 -85
View File
@@ -54,10 +54,7 @@ func summarizeURLForLog(urlStr string) string {
func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "auth URL is required",
})
return r.jsError("auth URL is required")
}
authURL := call.Arguments[0].String()
@@ -67,10 +64,7 @@ func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
}
if err := validateExtensionAuthURL(authURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
pendingAuthRequestsMu.Lock()
@@ -78,6 +72,7 @@ func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
ExtensionID: r.extensionID,
AuthURL: authURL,
CallbackURL: callbackURL,
CreatedAt: time.Now(),
}
pendingAuthRequestsMu.Unlock()
@@ -93,8 +88,7 @@ func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
return r.vm.ToValue(map[string]interface{}{
"success": true,
return r.jsSuccess(map[string]any{
"message": "Auth URL will be opened by the app",
})
}
@@ -130,7 +124,7 @@ func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
switch v := arg.(type) {
case string:
state.AuthCode = v
case map[string]interface{}:
case map[string]any:
if code, ok := v["code"].(string); ok {
state.AuthCode = code
}
@@ -184,10 +178,10 @@ func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
state, exists := extensionAuthState[r.extensionID]
if !exists {
return r.vm.ToValue(map[string]interface{}{})
return r.vm.ToValue(map[string]any{})
}
result := map[string]interface{}{
result := map[string]any{
"access_token": state.AccessToken,
"refresh_token": state.RefreshToken,
"is_authenticated": state.IsAuthenticated,
@@ -239,7 +233,7 @@ func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
verifier, err := generatePKCEVerifier(length)
if err != nil {
GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
@@ -258,7 +252,7 @@ func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier))
return r.vm.ToValue(map[string]interface{}{
return r.vm.ToValue(map[string]any{
"verifier": verifier,
"challenge": challenge,
"method": "S256",
@@ -271,10 +265,10 @@ func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
state, exists := extensionAuthState[r.extensionID]
if !exists || state.PKCEVerifier == "" {
return r.vm.ToValue(map[string]interface{}{})
return r.vm.ToValue(map[string]any{})
}
return r.vm.ToValue(map[string]interface{}{
return r.vm.ToValue(map[string]any{
"verifier": state.PKCEVerifier,
"challenge": state.PKCEChallenge,
"method": "S256",
@@ -283,19 +277,13 @@ func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "config object is required",
})
return r.jsError("config object is required")
}
configObj := call.Arguments[0].Export()
config, ok := configObj.(map[string]interface{})
config, ok := configObj.(map[string]any)
if !ok {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "config must be an object",
})
return r.jsError("config must be an object")
}
authURL, _ := config["authUrl"].(string)
@@ -303,27 +291,18 @@ func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
redirectURI, _ := config["redirectUri"].(string)
if authURL == "" || clientID == "" || redirectURI == "" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "authUrl, clientId, and redirectUri are required",
})
return r.jsError("authUrl, clientId, and redirectUri are required")
}
if err := validateExtensionAuthURL(authURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
scope, _ := config["scope"].(string)
extraParams, _ := config["extraParams"].(map[string]interface{})
extraParams, _ := config["extraParams"].(map[string]any)
verifier, err := generatePKCEVerifier(64)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to generate PKCE: %v", err),
})
return r.jsError("failed to generate PKCE: %v", err)
}
challenge := generatePKCEChallenge(verifier)
@@ -340,10 +319,7 @@ func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
parsedURL, err := url.Parse(authURL)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("invalid authUrl: %v", err),
})
return r.jsError("invalid authUrl: %v", err)
}
query := parsedURL.Query()
@@ -369,15 +345,15 @@ func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
ExtensionID: r.extensionID,
AuthURL: fullAuthURL,
CallbackURL: redirectURI,
CreatedAt: time.Now(),
}
pendingAuthRequestsMu.Unlock()
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
return r.vm.ToValue(map[string]interface{}{
"success": true,
return r.jsSuccess(map[string]any{
"authUrl": fullAuthURL,
"pkce": map[string]interface{}{
"pkce": map[string]any{
"verifier": verifier,
"challenge": challenge,
"method": "S256",
@@ -387,19 +363,13 @@ func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "config object is required",
})
return r.jsError("config object is required")
}
configObj := call.Arguments[0].Export()
config, ok := configObj.(map[string]interface{})
config, ok := configObj.(map[string]any)
if !ok {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "config must be an object",
})
return r.jsError("config must be an object")
}
tokenURL, _ := config["tokenUrl"].(string)
@@ -408,10 +378,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
code, _ := config["code"].(string)
if tokenURL == "" || clientID == "" || code == "" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "tokenUrl, clientId, and code are required",
})
return r.jsError("tokenUrl, clientId, and code are required")
}
extensionAuthStateMu.RLock()
@@ -423,17 +390,11 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
extensionAuthStateMu.RUnlock()
if verifier == "" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first",
})
return r.jsError("no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first")
}
if err := r.validateDomain(tokenURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
formData := url.Values{}
@@ -445,7 +406,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
formData.Set("redirect_uri", redirectURI)
}
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
if extraParams, ok := config["extraParams"].(map[string]any); ok {
for k, v := range extraParams {
formData.Set(k, fmt.Sprintf("%v", v))
}
@@ -453,10 +414,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
req = r.bindDownloadCancelContext(req)
@@ -465,28 +423,22 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
bodyPreview := sanitizeSensitiveLogText(string(body))
if len(bodyPreview) > 1000 {
bodyPreview = bodyPreview[:1000] + "...[truncated]"
}
var tokenResp map[string]interface{}
var tokenResp map[string]any
if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to parse token response: %v", err),
"body": bodyPreview,
@@ -495,7 +447,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
if errMsg, ok := tokenResp["error"].(string); ok {
errDesc, _ := tokenResp["error_description"].(string)
return r.vm.ToValue(map[string]interface{}{
return r.vm.ToValue(map[string]any{
"success": false,
"error": errMsg,
"error_description": errDesc,
@@ -507,7 +459,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
expiresIn, _ := tokenResp["expires_in"].(float64)
if accessToken == "" {
return r.vm.ToValue(map[string]interface{}{
return r.vm.ToValue(map[string]any{
"success": false,
"error": "no access_token in response",
"body": bodyPreview,
@@ -532,7 +484,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
result := map[string]interface{}{
result := map[string]any{
"success": true,
"access_token": accessToken,
"refresh_token": refreshToken,
+34 -66
View File
@@ -23,7 +23,7 @@ type runtimeBlockCipherOptions struct {
Padding string
}
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]any {
if len(call.Arguments) <= index {
return nil
}
@@ -34,13 +34,13 @@ func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]i
}
exported := value.Export()
if options, ok := exported.(map[string]interface{}); ok {
if options, ok := exported.(map[string]any); ok {
return options
}
return nil
}
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
func runtimeOptionString(options map[string]any, key, defaultValue string) string {
if options == nil {
return defaultValue
}
@@ -61,7 +61,7 @@ func runtimeOptionString(options map[string]interface{}, key, defaultValue strin
return defaultValue
}
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
func runtimeOptionBool(options map[string]any, key string, defaultValue bool) bool {
if options == nil {
return defaultValue
}
@@ -89,7 +89,7 @@ func runtimeOptionBool(options map[string]interface{}, key string, defaultValue
return defaultValue
}
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
func runtimeOptionInt64(options map[string]any, key string, defaultValue int64) int64 {
if options == nil {
return defaultValue
}
@@ -121,7 +121,7 @@ func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue
return defaultValue
}
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
func runtimeOptionHasKey(options map[string]any, key string) bool {
if options == nil {
return false
}
@@ -150,7 +150,7 @@ func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
}
}
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
func decodeRuntimeBytesValue(raw any, encoding string) ([]byte, error) {
switch value := raw.(type) {
case string:
return decodeRuntimeBytesString(value, encoding)
@@ -163,7 +163,7 @@ func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
cloned := make([]byte, len(src))
copy(cloned, src)
return cloned, nil
case []interface{}:
case []any:
decoded := make([]byte, len(value))
for i, item := range value {
switch num := item.(type) {
@@ -196,7 +196,7 @@ func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
}
}
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
func parseRuntimeBlockCipherOptions(options map[string]any) (*runtimeBlockCipherOptions, error) {
parsed := &runtimeBlockCipherOptions{
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
@@ -270,44 +270,28 @@ func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "data and options are required",
})
return r.jsError("data and options are required")
}
options := parseRuntimeOptionsArgument(call, 1)
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
switch parsedOptions.Mode {
case "cbc", "ctr":
// supported
default:
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
})
return r.jsError("unsupported block cipher mode: %s", parsedOptions.Mode)
}
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
block, err := newRuntimeBlockCipher(parsedOptions)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
if len(parsedOptions.IV) != block.BlockSize() {
@@ -315,10 +299,7 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
if parsedOptions.Mode == "ctr" {
ivLabel = "iv (counter)"
}
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm),
})
return r.jsError("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm)
}
var output []byte
@@ -333,10 +314,7 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
data = applyPKCS7Padding(data, block.BlockSize())
}
if len(data)%block.BlockSize() != 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
})
return r.jsError("input length must be a multiple of %d bytes", block.BlockSize())
}
output = make([]byte, len(data))
@@ -345,10 +323,7 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
if parsedOptions.Padding == "pkcs7" {
output, err = removePKCS7Padding(output, block.BlockSize())
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
}
} else {
@@ -358,14 +333,10 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
return r.jsSuccess(map[string]any{
"data": encoded,
"block_size": block.BlockSize(),
})
@@ -393,21 +364,20 @@ func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value
// dominant cost under the goja interpreter.
//
// JS signature:
// utils.decryptCTRSegments(data, {
// algorithm: "aes", // optional, default "aes"
// key: "<hex>", keyEncoding: "hex",
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
// ivEncoding: "base64", // encoding of each segment.iv, default base64
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
// })
//
// utils.decryptCTRSegments(data, {
// algorithm: "aes", // optional, default "aes"
// key: "<hex>", keyEncoding: "hex",
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
// ivEncoding: "base64", // encoding of each segment.iv, default base64
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
// })
//
// Returns { success, data, segments_processed } or { success:false, error }.
func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value {
fail := func(msg string) goja.Value {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": msg,
})
return r.jsError("%s", msg)
}
if len(call.Arguments) < 2 {
@@ -468,14 +438,14 @@ func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value
if !ok || rawSegments == nil {
return fail("segments array is required")
}
segments, ok := rawSegments.([]interface{})
segments, ok := rawSegments.([]any)
if !ok {
return fail("segments must be an array")
}
processed := 0
for i, rawSeg := range segments {
seg, ok := rawSeg.(map[string]interface{})
seg, ok := rawSeg.(map[string]any)
if !ok {
return fail(fmt.Sprintf("segment %d is not an object", i))
}
@@ -515,8 +485,7 @@ func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value
// Return raw bytes as an ArrayBuffer when requested (zero-copy-ish, no
// base64). Otherwise fall back to an encoded string.
if outputEncoding == "bytes" || outputEncoding == "raw" {
return r.vm.ToValue(map[string]interface{}{
"success": true,
return r.jsSuccess(map[string]any{
"data": r.vm.NewArrayBuffer(data),
"segments_processed": processed,
})
@@ -527,8 +496,7 @@ func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value
return fail(err.Error())
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
return r.jsSuccess(map[string]any{
"data": encoded,
"segments_processed": processed,
})
+37 -33
View File
@@ -51,14 +51,17 @@ func ClearFFmpegCommand(commandID string) {
}
func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if r.manifest == nil || !r.manifest.Permissions.File || !r.manifest.HasCapability("rawFfmpeg") {
return r.jsError("raw FFmpeg execution permission denied")
}
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "command is required",
})
return r.jsError("command is required")
}
command := call.Arguments[0].String()
return r.executeFFmpegCommand(call.Arguments[0].String(), "", "")
}
func (r *extensionRuntime) executeFFmpegCommand(command, inputPath, outputPath string) goja.Value {
ffmpegCommandsMu.Lock()
ffmpegCommandID++
@@ -66,6 +69,8 @@ func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
ffmpegCommands[cmdID] = &FFmpegCommand{
ExtensionID: r.extensionID,
Command: command,
InputPath: inputPath,
OutputPath: outputPath,
Completed: false,
}
ffmpegCommandsMu.Unlock()
@@ -82,7 +87,7 @@ func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if completed {
ffmpegCommandsMu.RLock()
result := map[string]interface{}{
result := map[string]any{
"success": cmd.Success,
"output": cmd.Output,
}
@@ -97,10 +102,7 @@ func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if time.Since(start) > timeout {
ClearFFmpegCommand(cmdID)
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "FFmpeg command timed out",
})
return r.jsError("FFmpeg command timed out")
}
time.Sleep(100 * time.Millisecond)
@@ -108,25 +110,24 @@ func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
}
func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
if r.manifest == nil || !r.manifest.Permissions.File {
return r.jsError("file permission denied")
}
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "file path is required",
})
return r.jsError("file path is required")
}
filePath := call.Arguments[0].String()
filePath, err := r.validatePath(call.Arguments[0].String())
if err != nil {
return r.jsError("%s", err.Error())
}
quality, err := GetAudioQuality(filePath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
return r.jsSuccess(map[string]any{
"bit_depth": quality.BitDepth,
"sample_rate": quality.SampleRate,
"total_samples": quality.TotalSamples,
@@ -136,19 +137,25 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
}
func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
if r.manifest == nil || !r.manifest.Permissions.File {
return r.jsError("file permission denied")
}
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "input and output paths are required",
})
return r.jsError("input and output paths are required")
}
inputPath := call.Arguments[0].String()
outputPath := call.Arguments[1].String()
inputPath, err := r.validatePath(call.Arguments[0].String())
if err != nil {
return r.jsError("invalid input path: %v", err)
}
outputPath, err := r.validatePath(call.Arguments[1].String())
if err != nil {
return r.jsError("invalid output path: %v", err)
}
options := map[string]interface{}{}
options := map[string]any{}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
if opts, ok := call.Arguments[2].Export().(map[string]any); ok {
options = opts
}
}
@@ -176,8 +183,5 @@ func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
command := strings.Join(cmdParts, " ")
execCall := goja.FunctionCall{
Arguments: []goja.Value{r.vm.ToValue(command)},
}
return r.ffmpegExecute(execCall)
return r.executeFFmpegCommand(command, inputPath, outputPath)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
package gobackend
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/dop251/goja"
)
func TestExtensionFileReadLengthRequiresChunking(t *testing.T) {
if _, err := extensionFileReadLength(maxExtensionFileReadBytes+1, 0, -1); err == nil {
t.Fatal("unbounded large read was accepted")
}
if _, err := extensionFileReadLength(maxExtensionFileReadBytes+1, 0, maxExtensionFileReadBytes+1); err == nil {
t.Fatal("oversized explicit read was accepted")
}
if got, err := extensionFileReadLength(maxExtensionFileReadBytes+1, maxExtensionFileReadBytes, -1); err != nil || got != 1 {
t.Fatalf("tail read length = %d, %v", got, err)
}
if got, err := extensionFileReadLength(5, 2, 10); err != nil || got != 3 {
t.Fatalf("clamped read length = %d, %v", got, err)
}
}
func TestExtensionFileAPIsRejectUnboundedLargeReads(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "large.bin")
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := file.Truncate(maxExtensionFileReadBytes + 1); err != nil {
file.Close()
t.Fatal(err)
}
file.Close()
vm := goja.New()
runtime := &extensionRuntime{
vm: vm,
dataDir: dir,
manifest: &ExtensionManifest{Permissions: ExtensionPermissions{File: true}},
}
readResult := runtime.fileRead(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("large.bin")}}).Export().(map[string]any)
if readResult["success"] != false || !strings.Contains(readResult["error"].(string), "chunks") {
t.Fatalf("fileRead result = %#v", readResult)
}
readBytesResult := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("large.bin")}}).Export().(map[string]any)
if readBytesResult["success"] != false || !strings.Contains(readBytesResult["error"].(string), "chunks") {
t.Fatalf("fileReadBytes result = %#v", readBytesResult)
}
}
+132 -322
View File
@@ -35,6 +35,12 @@ func readExtensionHTTPResponseBody(resp *http.Response) ([]byte, error) {
return body, nil
}
func setDefaultExtensionUA(req *http.Request) {
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
}
func (r *extensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
@@ -68,130 +74,88 @@ func (r *extensionRuntime) validateDomain(urlStr string) error {
return nil
}
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// parseGojaHeaders converts an exported goja value (expected map[string]any)
// into string headers. Non-map values yield an empty map.
func parseGojaHeaders(v any) map[string]string {
headers := make(map[string]string)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
if h, ok := v.(map[string]any); ok {
for k, val := range h {
headers[k] = fmt.Sprintf("%v", val)
}
}
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body),
"headers": respHeaders,
})
return headers
}
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
// coerceExportedBody stringifies a request body already exported from goja:
// strings pass through, maps/arrays are JSON-encoded, anything else is %v.
func coerceExportedBody(v any) (string, error) {
switch b := v.(type) {
case string:
return b, nil
case map[string]any, []any:
jsonBytes, err := json.Marshal(b)
if err != nil {
return "", fmt.Errorf("failed to stringify body: %v", err)
}
return string(jsonBytes), nil
default:
return fmt.Sprintf("%v", b), nil
}
}
// coerceGojaBody is coerceExportedBody for a raw goja argument; undefined/null
// yield "", and the fallback uses goja's own String() conversion.
func coerceGojaBody(v goja.Value) (string, error) {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return "", nil
}
switch b := v.Export().(type) {
case string:
return b, nil
case map[string]any, []any:
return coerceExportedBody(b)
default:
return v.String(), nil
}
}
func flattenHTTPHeaders(h http.Header) map[string]any {
flat := make(map[string]any, len(h))
for k, v := range h {
if len(v) == 1 {
flat[k] = v[0]
} else {
flat[k] = v
}
}
return flat
}
// checkExtensionURL extracts and allowlist-validates the URL argument.
// On failure it returns a non-nil error value to hand back to JS.
func (r *extensionRuntime) checkExtensionURL(call goja.FunctionCall) (string, goja.Value) {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
return "", r.vm.ToValue(map[string]any{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
return "", r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
return urlStr, nil
}
var bodyStr string
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export()
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
bodyStr = string(jsonBytes)
default:
bodyStr = call.Arguments[1].String()
}
}
headers := make(map[string]string)
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
headersObj := call.Arguments[2].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
// doExtensionHTTP builds and executes the request, returning the extension
// response map (or {"error": ...}). defaultJSON sets Content-Type
// application/json when the caller did not provide one.
func (r *extensionRuntime) doExtensionHTTP(method, urlStr string, body io.Reader, defaultJSON bool, headers map[string]string) goja.Value {
req, err := http.NewRequest(method, urlStr, body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
@@ -200,156 +164,91 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
if req.Header.Get("Content-Type") == "" {
setDefaultExtensionUA(req)
if defaultJSON && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
defer resp.Body.Close()
body, err := readExtensionHTTPResponseBody(resp)
respBody, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
return r.vm.ToValue(map[string]interface{}{
return r.vm.ToValue(map[string]any{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body),
"headers": respHeaders,
"body": string(respBody),
"headers": flattenHTTPHeaders(resp.Header),
})
}
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
})
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
urlStr, errVal := r.checkExtensionURL(call)
if errVal != nil {
return errVal
}
headers := parseGojaHeaders(call.Argument(1).Export())
return r.doExtensionHTTP("GET", urlStr, nil, false, headers)
}
urlStr := call.Arguments[0].String()
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
urlStr, errVal := r.checkExtensionURL(call)
if errVal != nil {
return errVal
}
bodyStr, err := coerceGojaBody(call.Argument(1))
if err != nil {
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
headers := parseGojaHeaders(call.Argument(2).Export())
// POST always sends a (possibly empty) body and defaults Content-Type.
return r.doExtensionHTTP("POST", urlStr, strings.NewReader(bodyStr), true, headers)
}
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
urlStr, errVal := r.checkExtensionURL(call)
if errVal != nil {
return errVal
}
method := "GET"
var bodyStr string
headers := make(map[string]string)
var headers map[string]string
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m)
}
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
bodyStr = string(jsonBytes)
default:
bodyStr = fmt.Sprintf("%v", v)
}
}
if h, ok := opts["headers"].(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
if opts, ok := call.Argument(1).Export().(map[string]any); ok {
if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m)
}
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
var err error
if bodyStr, err = coerceExportedBody(bodyArg); err != nil {
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
}
headers = parseGojaHeaders(opts["headers"])
}
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
req, err := http.NewRequest(method, urlStr, reqBody)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body),
"headers": respHeaders,
})
return r.doExtensionHTTP(method, urlStr, reqBody, bodyStr != "", headers)
}
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
@@ -365,124 +264,35 @@ func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
}
func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
urlStr, errVal := r.checkExtensionURL(call)
if errVal != nil {
return errVal
}
// DELETE takes (url, headers); other methods take (url, body, headers).
var bodyStr string
headers := make(map[string]string)
if method == "DELETE" {
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
} else {
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export()
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
bodyStr = string(jsonBytes)
default:
bodyStr = call.Arguments[1].String()
}
}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
headersObj := call.Arguments[2].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
headerArg := 1
if method != "DELETE" {
var err error
if bodyStr, err = coerceGojaBody(call.Argument(1)); err != nil {
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
headerArg = 2
}
headers := parseGojaHeaders(call.Argument(headerArg).Export())
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
req, err := http.NewRequest(method, urlStr, reqBody)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body),
"headers": respHeaders,
})
return r.doExtensionHTTP(method, urlStr, reqBody, bodyStr != "", headers)
}
func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock()
jar.cookies = make(map[string][]*http.Cookie)
jar.mu.Unlock()
jar.Clear()
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
return r.vm.ToValue(true)
}
+18 -45
View File
@@ -25,39 +25,19 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
method := "GET"
var bodyStr string
headers := make(map[string]string)
var headers map[string]string
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m)
}
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err))
}
bodyStr = string(jsonBytes)
default:
bodyStr = fmt.Sprintf("%v", v)
}
}
if h, ok := opts["headers"]; ok && h != nil {
switch hv := h.(type) {
case map[string]interface{}:
for k, v := range hv {
headers[k] = fmt.Sprintf("%v", v)
}
}
if opts, ok := call.Argument(1).Export().(map[string]any); ok {
if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m)
}
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
var err error
if bodyStr, err = coerceExportedBody(bodyArg); err != nil {
return r.createFetchError(err.Error())
}
}
headers = parseGojaHeaders(opts["headers"])
}
var reqBody io.Reader
@@ -92,14 +72,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return r.createFetchError(err.Error())
}
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
respHeaders := flattenHTTPHeaders(resp.Header)
responseObj := r.vm.NewObject()
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
@@ -115,7 +88,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
})
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
var result interface{}
var result any
if err := json.Unmarshal(body, &result); err != nil {
GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err)
return goja.Undefined()
@@ -124,7 +97,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
})
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
byteArray := make([]interface{}, len(body))
byteArray := make([]any, len(body))
for i, b := range body {
byteArray[i] = int(b)
}
@@ -185,7 +158,7 @@ func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
input := call.Arguments[0].String()
bytes := []byte(input)
result := make([]interface{}, len(bytes))
result := make([]any, len(bytes))
for i, b := range bytes {
result[i] = int(b)
}
@@ -194,10 +167,10 @@ func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
return vm.ToValue(map[string]any{"read": 0, "written": 0})
}
input := call.Arguments[0].String()
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"read": len(input),
"written": len([]byte(input)),
})
@@ -228,7 +201,7 @@ func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
switch v := input.(type) {
case []byte:
bytes = v
case []interface{}:
case []any:
bytes = make([]byte, len(v))
for i, val := range v {
switch n := val.(type) {
@@ -357,7 +330,7 @@ func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
case string:
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
values = parsed
case map[string]interface{}:
case map[string]any:
for k, val := range v {
values.Set(k, fmt.Sprintf("%v", val))
}
@@ -0,0 +1,54 @@
package gobackend
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestStallWatchdogCancelsOnNoData verifies the watchdog aborts a transfer that
// stops sending bytes, marks itself stalled (not user-cancelled), and does so
// after resetting on the initial byte.
func TestStallWatchdogCancelsOnNoData(t *testing.T) {
block := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1000")
w.WriteHeader(http.StatusOK)
w.Write([]byte("x"))
w.(http.Flusher).Flush()
<-block // simulate a dead radio mid-transfer: stop sending
}))
defer srv.Close()
defer close(block)
req, _ := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil)
req, wd := bindStallWatchdog(req, 150*time.Millisecond)
defer wd.stop()
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
buf := make([]byte, 32)
var readErr error
for {
n, er := resp.Body.Read(buf)
if n > 0 {
wd.reset()
}
if er != nil {
readErr = er
break
}
}
if readErr == nil {
t.Fatal("expected read error from stall cancel")
}
if !wd.stalled.Load() {
t.Fatalf("watchdog did not mark stalled; err=%v", readErr)
}
}
+142 -242
View File
@@ -10,161 +10,106 @@ import (
"io"
"os"
"path/filepath"
"reflect"
"time"
"sync"
"github.com/dop251/goja"
)
const (
defaultStorageFlushDelay = 400 * time.Millisecond
storageFlushRetryDelay = 2 * time.Second
)
// Isolated per-download runtimes of the same extension share the storage,
// credentials, and salt files on disk, so writers must be serialized
// process-wide; the per-runtime mutexes only cover a single VM.
var extensionFileMus sync.Map // file path -> *sync.Mutex
func extensionFileMu(path string) *sync.Mutex {
mu, _ := extensionFileMus.LoadOrStore(path, &sync.Mutex{})
return mu.(*sync.Mutex)
}
// writeExtensionFileLocked writes data via a temp file + rename so a reader
// never observes a torn write. Callers must hold extensionFileMu(path).
func writeExtensionFileLocked(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func (r *extensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json")
}
func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
if len(src) == 0 {
return make(map[string]interface{})
}
dst := make(map[string]interface{}, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func (r *extensionRuntime) ensureStorageLoaded() error {
r.storageMu.RLock()
if r.storageLoaded {
r.storageMu.RUnlock()
return nil
}
r.storageMu.RUnlock()
r.storageMu.Lock()
defer r.storageMu.Unlock()
if r.storageLoaded {
return nil
}
storagePath := r.getStoragePath()
data, err := os.ReadFile(storagePath)
func readJSONMapFile(path string) (map[string]any, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
r.storageCache = make(map[string]interface{})
r.storageLoaded = true
return nil
return make(map[string]any), nil
}
return nil, err
}
result := make(map[string]any)
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
if result == nil {
result = make(map[string]any)
}
return result, nil
}
func (r *extensionRuntime) refreshStorage() error {
path := r.getStoragePath()
fileMu := extensionFileMu(path)
fileMu.Lock()
snapshot, err := readJSONMapFile(path)
fileMu.Unlock()
if err != nil {
return err
}
var storage map[string]interface{}
if err := json.Unmarshal(data, &storage); err != nil {
return err
}
if storage == nil {
storage = make(map[string]interface{})
}
r.storageCache = storage
r.storageLoaded = true
r.storageMu.Lock()
r.storageCache = snapshot
r.storageMu.Unlock()
return nil
}
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
if err := r.ensureStorageLoaded(); err != nil {
return nil, err
}
func (r *extensionRuntime) mutateStorage(mutate func(map[string]any) bool) error {
r.storageMu.RLock()
defer r.storageMu.RUnlock()
return cloneInterfaceMap(r.storageCache), nil
}
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
if r.storageClosed {
return
closed := r.storageClosed
r.storageMu.RUnlock()
if closed {
return fmt.Errorf("storage is closed")
}
if r.storageTimer != nil {
return
}
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
}
func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
data, err := json.Marshal(storage)
path := r.getStoragePath()
fileMu := extensionFileMu(path)
fileMu.Lock()
snapshot, err := readJSONMapFile(path)
if err == nil && mutate(snapshot) {
var data []byte
data, err = json.Marshal(snapshot)
if err == nil {
err = writeExtensionFileLocked(path, data)
}
}
fileMu.Unlock()
if err != nil {
return err
}
r.storageWriteMu.Lock()
defer r.storageWriteMu.Unlock()
return os.WriteFile(r.getStoragePath(), data, 0600)
}
func (r *extensionRuntime) flushStorageDirtyAsync() {
if err := r.flushStorageDirty(); err != nil {
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
}
}
func (r *extensionRuntime) flushStorageDirty() error {
r.storageMu.Lock()
if r.storageClosed {
r.storageTimer = nil
r.storageMu.Unlock()
return nil
}
if !r.storageDirty {
r.storageTimer = nil
r.storageMu.Unlock()
return nil
}
snapshot := cloneInterfaceMap(r.storageCache)
r.storageDirty = false
r.storageTimer = nil
r.storageCache = snapshot
r.storageMu.Unlock()
if err := r.persistStorageSnapshot(snapshot); err != nil {
r.storageMu.Lock()
r.storageDirty = true
r.queueStorageFlushLocked(storageFlushRetryDelay)
r.storageMu.Unlock()
return err
}
return nil
}
func (r *extensionRuntime) flushStorageNow() error {
r.storageMu.Lock()
if r.storageTimer != nil {
r.storageTimer.Stop()
r.storageTimer = nil
}
if !r.storageLoaded || r.storageClosed {
r.storageMu.Unlock()
return nil
}
snapshot := cloneInterfaceMap(r.storageCache)
r.storageDirty = false
r.storageMu.Unlock()
return r.persistStorageSnapshot(snapshot)
// Mutations are persisted synchronously under the process-wide file lock.
return nil
}
func (r *extensionRuntime) closeStorageFlusher() {
r.storageMu.Lock()
r.storageClosed = true
r.storageDirty = false
if r.storageTimer != nil {
r.storageTimer.Stop()
r.storageTimer = nil
}
r.storageMu.Unlock()
}
@@ -175,7 +120,7 @@ func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
key := call.Arguments[0].String()
if err := r.ensureStorageLoaded(); err != nil {
if err := r.refreshStorage(); err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
return goja.Undefined()
}
@@ -201,27 +146,14 @@ func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
key := call.Arguments[0].String()
value := call.Arguments[1].Export()
if err := r.ensureStorageLoaded(); err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
if err := r.mutateStorage(func(storage map[string]any) bool {
storage[key] = value
return true
}); err != nil {
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
r.storageMu.Lock()
if r.storageClosed {
r.storageMu.Unlock()
return r.vm.ToValue(false)
}
if existing, exists := r.storageCache[key]; exists {
if reflect.DeepEqual(existing, value) {
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
}
r.storageCache[key] = value
r.storageDirty = true
r.queueStorageFlushLocked(r.storageFlushDelay)
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
@@ -232,25 +164,17 @@ func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
key := call.Arguments[0].String()
if err := r.ensureStorageLoaded(); err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
if err := r.mutateStorage(func(storage map[string]any) bool {
if _, exists := storage[key]; !exists {
return false
}
delete(storage, key)
return true
}); err != nil {
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
r.storageMu.Lock()
if r.storageClosed {
r.storageMu.Unlock()
return r.vm.ToValue(false)
}
if _, exists := r.storageCache[key]; !exists {
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
delete(r.storageCache, key)
r.storageDirty = true
r.queueStorageFlushLocked(r.storageFlushDelay)
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
@@ -265,6 +189,12 @@ func (r *extensionRuntime) getSaltPath() string {
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath()
// Serialize concurrent runtimes: if two generated different salts, the
// loser's credentials would become undecryptable.
mu := extensionFileMu(saltPath)
mu.Lock()
defer mu.Unlock()
salt, err := os.ReadFile(saltPath)
if err == nil && len(salt) == 32 {
return salt, nil
@@ -275,7 +205,7 @@ func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
if err := writeExtensionFileLocked(saltPath, salt); err != nil {
return nil, fmt.Errorf("failed to save salt: %w", err)
}
@@ -293,115 +223,93 @@ func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
return hash[:], nil
}
func (r *extensionRuntime) ensureCredentialsLoaded() error {
r.credentialsMu.RLock()
if r.credentialsLoaded {
r.credentialsMu.RUnlock()
return nil
}
r.credentialsMu.RUnlock()
r.credentialsMu.Lock()
defer r.credentialsMu.Unlock()
if r.credentialsLoaded {
return nil
}
credPath := r.getCredentialsPath()
data, err := os.ReadFile(credPath)
func (r *extensionRuntime) readCredentialsFileLocked() (map[string]any, error) {
data, err := os.ReadFile(r.getCredentialsPath())
if err != nil {
if os.IsNotExist(err) {
r.credentialsCache = make(map[string]interface{})
r.credentialsLoaded = true
return nil
return make(map[string]any), nil
}
return err
return nil, err
}
key, err := r.getEncryptionKey()
if err != nil {
return fmt.Errorf("failed to get encryption key: %w", err)
return nil, fmt.Errorf("failed to get encryption key: %w", err)
}
decrypted, err := decryptAES(data, key)
if err != nil {
return fmt.Errorf("failed to decrypt credentials: %w", err)
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
}
var creds map[string]interface{}
creds := make(map[string]any)
if err := json.Unmarshal(decrypted, &creds); err != nil {
return err
return nil, err
}
if creds == nil {
creds = make(map[string]interface{})
creds = make(map[string]any)
}
return creds, nil
}
r.credentialsCache = creds
r.credentialsLoaded = true
func (r *extensionRuntime) refreshCredentials() error {
path := r.getCredentialsPath()
fileMu := extensionFileMu(path)
fileMu.Lock()
snapshot, err := r.readCredentialsFileLocked()
fileMu.Unlock()
if err != nil {
return err
}
r.credentialsMu.Lock()
r.credentialsCache = snapshot
r.credentialsMu.Unlock()
return nil
}
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
data, err := json.Marshal(creds)
func (r *extensionRuntime) mutateCredentials(mutate func(map[string]any)) error {
path := r.getCredentialsPath()
fileMu := extensionFileMu(path)
fileMu.Lock()
snapshot, err := r.readCredentialsFileLocked()
if err == nil {
mutate(snapshot)
var data []byte
data, err = json.Marshal(snapshot)
if err == nil {
var key []byte
key, err = r.getEncryptionKey()
if err == nil {
data, err = encryptAES(data, key)
}
}
if err == nil {
err = writeExtensionFileLocked(path, data)
}
}
fileMu.Unlock()
if err != nil {
return err
}
key, err := r.getEncryptionKey()
if err != nil {
return fmt.Errorf("failed to get encryption key: %w", err)
}
encrypted, err := encryptAES(data, key)
if err != nil {
return fmt.Errorf("failed to encrypt credentials: %w", err)
}
credPath := r.getCredentialsPath()
if err := os.WriteFile(credPath, encrypted, 0600); err != nil {
return err
}
r.credentialsMu.Lock()
r.credentialsCache = cloneInterfaceMap(creds)
r.credentialsLoaded = true
r.credentialsCache = snapshot
r.credentialsMu.Unlock()
return nil
}
func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "key and value are required",
})
return r.jsError("key and value are required")
}
key := call.Arguments[0].String()
value := call.Arguments[1].Export()
if err := r.ensureCredentialsLoaded(); err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
r.credentialsMu.RLock()
nextCreds := cloneInterfaceMap(r.credentialsCache)
r.credentialsMu.RUnlock()
nextCreds[key] = value
if err := r.saveCredentials(nextCreds); err != nil {
if err := r.mutateCredentials(func(credentials map[string]any) {
credentials[key] = value
}); err != nil {
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
})
return r.jsSuccess(nil)
}
func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
@@ -411,7 +319,7 @@ func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
key := call.Arguments[0].String()
if err := r.ensureCredentialsLoaded(); err != nil {
if err := r.refreshCredentials(); err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return goja.Undefined()
}
@@ -436,17 +344,9 @@ func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
key := call.Arguments[0].String()
if err := r.ensureCredentialsLoaded(); err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
r.credentialsMu.RLock()
nextCreds := cloneInterfaceMap(r.credentialsCache)
r.credentialsMu.RUnlock()
delete(nextCreds, key)
if err := r.saveCredentials(nextCreds); err != nil {
if err := r.mutateCredentials(func(credentials map[string]any) {
delete(credentials, key)
}); err != nil {
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
@@ -461,7 +361,7 @@ func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
key := call.Arguments[0].String()
if err := r.ensureCredentialsLoaded(); err != nil {
if err := r.refreshCredentials(); err != nil {
return r.vm.ToValue(false)
}
+75 -7
View File
@@ -11,7 +11,7 @@ import (
"github.com/dop251/goja"
)
func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value interface{}) {
func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value any) {
t.Helper()
result := runtime.storageSet(goja.FunctionCall{
Arguments: []goja.Value{
@@ -24,21 +24,91 @@ func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value
}
}
func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
func TestExtensionRuntimeStorageConcurrentRuntimesMergeWrites(t *testing.T) {
dataDir := t.TempDir()
ext := &loadedExtension{ID: "merge-test", Manifest: &ExtensionManifest{Name: "merge-test"}, DataDir: dataDir}
runtimeA := newExtensionRuntime(ext)
runtimeB := newExtensionRuntime(ext)
runtimeA.RegisterAPIs(goja.New())
runtimeB.RegisterAPIs(goja.New())
start := make(chan struct{})
done := make(chan bool, 2)
go func() {
<-start
result := runtimeA.storageSet(goja.FunctionCall{Arguments: []goja.Value{
runtimeA.vm.ToValue("from_a"), runtimeA.vm.ToValue("a"),
}})
done <- result.ToBoolean()
}()
go func() {
<-start
result := runtimeB.storageSet(goja.FunctionCall{Arguments: []goja.Value{
runtimeB.vm.ToValue("from_b"), runtimeB.vm.ToValue("b"),
}})
done <- result.ToBoolean()
}()
close(start)
if !<-done || !<-done {
t.Fatal("concurrent storage write failed")
}
storage := readStorageMap(t, filepath.Join(dataDir, "storage.json"))
if storage["from_a"] != "a" || storage["from_b"] != "b" {
t.Fatalf("concurrent storage writes were not merged: %#v", storage)
}
credStart := make(chan struct{})
credDone := make(chan struct{}, 2)
for _, item := range []struct {
runtime *extensionRuntime
key string
}{
{runtimeA, "token_a"},
{runtimeB, "token_b"},
} {
item := item
go func() {
<-credStart
result := item.runtime.credentialsStore(goja.FunctionCall{Arguments: []goja.Value{
item.runtime.vm.ToValue(item.key),
item.runtime.vm.ToValue(item.key + "_value"),
}})
if success, _ := result.Export().(map[string]any)["success"].(bool); !success {
t.Errorf("credentialsStore(%s) failed", item.key)
}
credDone <- struct{}{}
}()
}
close(credStart)
<-credDone
<-credDone
reader := newExtensionRuntime(ext)
reader.RegisterAPIs(goja.New())
for _, key := range []string{"token_a", "token_b"} {
got := reader.credentialsGet(goja.FunctionCall{Arguments: []goja.Value{reader.vm.ToValue(key)}}).String()
if got != key+"_value" {
t.Fatalf("credential %s = %q", key, got)
}
}
}
func readStorageMap(t *testing.T, storagePath string) map[string]any {
t.Helper()
data, err := os.ReadFile(storagePath)
if err != nil {
t.Fatalf("failed to read storage file: %v", err)
}
var parsed map[string]interface{}
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal storage file: %v", err)
}
return parsed
}
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
func TestExtensionRuntimeStorage_AtomicWriteCompactJSON(t *testing.T) {
ext := &loadedExtension{
ID: "storage-test",
Manifest: &ExtensionManifest{
@@ -48,7 +118,6 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
}
runtime := newExtensionRuntime(ext)
runtime.storageFlushDelay = 25 * time.Millisecond
runtime.RegisterAPIs(goja.New())
setStorageValue(t, runtime, "k1", "v1")
@@ -70,7 +139,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
t.Fatalf("storage.json was not written within timeout")
}
var parsed map[string]interface{}
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("failed to unmarshal storage file: %v", err)
}
@@ -96,7 +165,6 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
}
runtime := newExtensionRuntime(ext)
runtime.storageFlushDelay = time.Hour
runtime.RegisterAPIs(ext.VM)
ext.runtime = runtime
+383 -69
View File
@@ -26,7 +26,7 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
Network: []string{"auth.example.com", "token.example.com", "api.example.com"},
},
},
settings: map[string]interface{}{},
settings: map[string]any{},
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "token.example.com":
@@ -63,7 +63,7 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
openResult := runtime.authOpenUrl(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("https://auth.example.com/login"),
vm.ToValue("app://callback"),
}}).Export().(map[string]interface{})
}}).Export().(map[string]any)
if openResult["success"] != true {
t.Fatalf("authOpenUrl = %#v", openResult)
}
@@ -73,7 +73,7 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
if code := runtime.authGetCode(goja.FunctionCall{}); !goja.IsUndefined(code) {
t.Fatalf("expected undefined code, got %v", code)
}
if ok := runtime.authSetCode(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"code": "abc", "access_token": "access", "refresh_token": "refresh", "expires_in": float64(60)})}}); !ok.ToBoolean() {
if ok := runtime.authSetCode(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]any{"code": "abc", "access_token": "access", "refresh_token": "refresh", "expires_in": float64(60)})}}); !ok.ToBoolean() {
t.Fatal("authSetCode returned false")
}
if code := runtime.authGetCode(goja.FunctionCall{}).String(); code != "abc" {
@@ -82,36 +82,36 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
if !runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
t.Fatal("expected authenticated runtime")
}
tokens := runtime.authGetTokens(goja.FunctionCall{}).Export().(map[string]interface{})
tokens := runtime.authGetTokens(goja.FunctionCall{}).Export().(map[string]any)
if tokens["access_token"] != "access" {
t.Fatalf("tokens = %#v", tokens)
}
pkce := runtime.authGeneratePKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(50))}}).Export().(map[string]interface{})
pkce := runtime.authGeneratePKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(50))}}).Export().(map[string]any)
if pkce["method"] != "S256" || pkce["verifier"] == "" || pkce["challenge"] == "" {
t.Fatalf("pkce = %#v", pkce)
}
if current := runtime.authGetPKCE(goja.FunctionCall{}).Export().(map[string]interface{}); current["verifier"] == "" {
if current := runtime.authGetPKCE(goja.FunctionCall{}).Export().(map[string]any); current["verifier"] == "" {
t.Fatalf("current pkce = %#v", current)
}
oauthConfig := map[string]interface{}{
oauthConfig := map[string]any{
"authUrl": "https://auth.example.com/oauth",
"clientId": "client",
"redirectUri": "app://callback",
"scope": "read",
"extraParams": map[string]interface{}{"prompt": "login"},
"extraParams": map[string]any{"prompt": "login"},
}
oauth := runtime.authStartOAuthWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(oauthConfig)}}).Export().(map[string]interface{})
oauth := runtime.authStartOAuthWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(oauthConfig)}}).Export().(map[string]any)
if oauth["success"] != true || !strings.Contains(oauth["authUrl"].(string), "code_challenge") {
t.Fatalf("oauth = %#v", oauth)
}
tokenConfig := map[string]interface{}{
tokenConfig := map[string]any{
"tokenUrl": "https://token.example.com/token",
"clientId": "client",
"redirectUri": "app://callback",
"code": "abc",
}
token := runtime.authExchangeCodeWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(tokenConfig)}}).Export().(map[string]interface{})
token := runtime.authExchangeCodeWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(tokenConfig)}}).Export().(map[string]any)
if token["success"] != true || token["access_token"] != "access" {
t.Fatalf("token = %#v", token)
}
@@ -160,7 +160,7 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
if err != nil {
t.Fatalf("polyfill script: %v", err)
}
var result map[string]interface{}
var result map[string]any
if err := json.Unmarshal([]byte(value.String()), &result); err != nil {
t.Fatalf("decode polyfill result: %v", err)
}
@@ -178,16 +178,313 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
}
}
type failingBodyReader struct {
data []byte
sent bool
}
func (f *failingBodyReader) Read(p []byte) (int, error) {
if !f.sent {
f.sent = true
n := copy(p, f.data)
return n, nil
}
return 0, fmt.Errorf("connection reset")
}
func newFileDownloadTestRuntime(t *testing.T, transport roundTripFunc) *extensionRuntime {
t.Helper()
return &extensionRuntime{
extensionID: "dl-ext",
manifest: &ExtensionManifest{
Name: "dl-ext",
Version: "1.0.0",
Permissions: ExtensionPermissions{
File: true,
Network: []string{"cdn.example.com"},
},
},
dataDir: t.TempDir(),
vm: goja.New(),
httpClient: &http.Client{Transport: transport},
}
}
func TestFileDownloadStagesAndPromotes(t *testing.T) {
runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("audio-bytes")),
ContentLength: int64(len("audio-bytes")),
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"] != true {
t.Fatalf("download result = %#v", result)
}
finalPath := filepath.Join(runtime.dataDir, "out", "track.flac")
data, err := os.ReadFile(finalPath)
if err != nil || string(data) != "audio-bytes" {
t.Fatalf("final file = %q/%v", data, err)
}
if _, err := os.Stat(stagedDownloadPath(finalPath)); !os.IsNotExist(err) {
t.Fatalf("staged file left behind: %v", err)
}
}
func TestFileDownloadFailureLeavesNoFinalFile(t *testing.T) {
runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(&failingBodyReader{data: []byte("partial-aud")}),
ContentLength: 1 << 20,
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)
}
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 TestFileDownloadResumesAfterMidBodyCut(t *testing.T) {
const full = "hello-world!"
var attempts int
var resumeRange, resumeIfRange string
runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
attempts++
if attempts == 1 {
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
}
resumeRange = req.Header.Get("Range")
resumeIfRange = req.Header.Get("If-Range")
h := make(http.Header)
h.Set("Content-Range", fmt.Sprintf("bytes 6-%d/%d", len(full)-1, len(full)))
return &http.Response{
StatusCode: 206,
Header: h,
Body: io.NopCloser(strings.NewReader(full[6:])),
ContentLength: int64(len(full) - 6),
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"] != true {
t.Fatalf("download result = %#v", result)
}
if attempts != 2 || resumeRange != "bytes=6-" || resumeIfRange != `"v1"` {
t.Fatalf("attempts=%d range=%q if-range=%q", attempts, resumeRange, resumeIfRange)
}
data, err := os.ReadFile(filepath.Join(runtime.dataDir, "out", "track.flac"))
if err != nil || string(data) != full {
t.Fatalf("final file = %q/%v", data, err)
}
}
func TestFileDownloadResumeRestartsWhenRangeIgnored(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"`)
if attempts == 1 {
return &http.Response{
StatusCode: 200,
Header: h,
Body: io.NopCloser(&failingBodyReader{data: []byte(full[:6])}),
ContentLength: int64(len(full)),
Request: req,
}, nil
}
// Server ignores the Range header and replays the full body.
return &http.Response{
StatusCode: 200,
Header: h,
Body: io.NopCloser(strings.NewReader(full)),
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"] != true {
t.Fatalf("download result = %#v", result)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
data, err := os.ReadFile(filepath.Join(runtime.dataDir, "out", "track.flac"))
if err != nil || string(data) != full {
t.Fatalf("final file = %q/%v", data, err)
}
}
func TestResetDownloadCancelDropsStaleFlag(t *testing.T) {
const itemID = "reset-cancel-item"
// A cancel issued while nothing is downloading pre-registers a flag that
// the next attempt would consume and abort; reset must drop it.
cancelDownload(itemID)
if !isDownloadCancelled(itemID) {
t.Fatal("expected pre-registered cancel flag")
}
resetDownloadCancel(itemID)
if isDownloadCancelled(itemID) {
t.Fatal("expected stale cancel flag to be dropped")
}
// A cancel attached to an active download must survive reset.
ctx := initDownloadCancel(itemID)
cancelDownload(itemID)
resetDownloadCancel(itemID)
if !isDownloadCancelled(itemID) {
t.Fatal("expected active cancel to survive reset")
}
select {
case <-ctx.Done():
default:
t.Fatal("expected cancelled context")
}
clearDownloadCancel(itemID)
}
func TestParseExtensionTrackValueExplicit(t *testing.T) {
vm := goja.New()
value, err := vm.RunString(`({name: "Song", explicit: true})`)
if err != nil {
t.Fatalf("RunString: %v", err)
}
if track := parseExtensionTrackValue(vm, value); !track.Explicit {
t.Fatalf("expected explicit track, got %#v", track)
}
value, err = vm.RunString(`({name: "Song", isExplicit: true})`)
if err != nil {
t.Fatalf("RunString: %v", err)
}
if track := parseExtensionTrackValue(vm, value); !track.Explicit {
t.Fatalf("expected explicit track via isExplicit, got %#v", track)
}
value, err = vm.RunString(`({name: "Song"})`)
if err != nil {
t.Fatalf("RunString: %v", err)
}
if track := parseExtensionTrackValue(vm, value); track.Explicit {
t.Fatalf("expected clean track, got %#v", track)
}
}
func TestDeezerTrackIsExplicit(t *testing.T) {
if deezerTrackIsExplicit(deezerTrack{}) {
t.Fatal("expected clean track by default")
}
if !deezerTrackIsExplicit(deezerTrack{ExplicitLyrics: true}) {
t.Fatal("expected explicit via explicit_lyrics")
}
if !deezerTrackIsExplicit(deezerTrack{ExplicitContentLyrics: 1}) {
t.Fatal("expected explicit via explicit_content_lyrics == 1")
}
if deezerTrackIsExplicit(deezerTrack{ExplicitContentLyrics: 2}) {
t.Fatal("expected unknown (2) to be treated as clean")
}
}
func TestExtensionStoreDiskCacheSurvivesRestart(t *testing.T) {
dir := t.TempDir()
registryURL := "https://registry.example.com/registry.json"
store := &extensionRepo{
registryURL: registryURL,
cacheDir: dir,
cacheTTL: time.Hour,
cache: &repoRegistry{
Version: 1,
Extensions: []repoExtension{{ID: "ext", Name: "ext", Version: "1.0.0"}},
},
cacheTime: time.Now(),
}
store.saveDiskCache()
// Simulates an app restart: a fresh store loads the disk cache, then the
// Dart layer re-applies the same registry URL.
restarted := &extensionRepo{cacheDir: dir, cacheTTL: time.Hour}
restarted.loadDiskCache()
if restarted.getRegistryURL() != registryURL {
t.Fatalf("registry URL after restart = %q", restarted.getRegistryURL())
}
restarted.setRegistryURL(registryURL)
if restarted.cache == nil || len(restarted.cache.Extensions) != 1 {
t.Fatalf("expected cache to survive re-applying the same registry URL, got %#v", restarted.cache)
}
restarted.setRegistryURL("https://other.example.com/registry.json")
if restarted.cache != nil {
t.Fatal("expected cache reset after registry URL change")
}
}
func TestParseRegistryBody(t *testing.T) {
registry, err := parseRegistryBody([]byte(`{"version":1,"extensions":[{"id":"ext","name":"ext","version":"1.0.0"}]}`))
if err != nil || len(registry.Extensions) != 1 {
t.Fatalf("parseRegistryBody = %#v/%v", registry, err)
}
if _, err := parseRegistryBody([]byte("<!DOCTYPE html><html></html>")); err == nil || !strings.Contains(err.Error(), "web page") {
t.Fatalf("expected web page error, got %v", err)
}
if _, err := parseRegistryBody([]byte("not json")); err == nil || !strings.Contains(err.Error(), "failed to parse registry") {
t.Fatalf("expected parse error, got %v", err)
}
}
func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
dir := t.TempDir()
store := &extensionStore{
store := &extensionRepo{
registryURL: "https://registry.example.com/registry.json",
cacheDir: dir,
cacheTTL: time.Hour,
cache: &storeRegistry{
cache: &repoRegistry{
Version: 1,
UpdatedAt: "2026-05-04",
Extensions: []storeExtension{
Extensions: []repoExtension{
{
ID: "coverage-ext",
Name: "coverage-ext",
@@ -216,7 +513,7 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
cacheTime: time.Now(),
}
store.saveDiskCache()
loadedStore := &extensionStore{cacheDir: dir}
loadedStore := &extensionRepo{cacheDir: dir}
loadedStore.loadDiskCache()
if loadedStore.cache == nil || len(loadedStore.cache.Extensions) != 2 {
t.Fatalf("loaded cache = %#v", loadedStore.cache)
@@ -272,9 +569,6 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
if cats := store.getCategories(); len(cats) != 5 {
t.Fatalf("categories = %#v", cats)
}
if !containsIgnoreCase("Hello Metadata", "metadata") || findSubstring("abcdef", "cd") != 2 || containsStr("abc", "z") {
t.Fatal("string helper mismatch")
}
if err := requireHTTPSURL("http://example.com", "registry"); err == nil {
t.Fatal("expected HTTPS validation error")
}
@@ -289,7 +583,7 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
t.Fatal("expected cleared store cache")
}
settingsStore := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
settingsStore := &ExtensionSettingsStore{settings: map[string]map[string]any{}}
if err := settingsStore.SetDataDir(filepath.Join(dir, "settings")); err != nil {
t.Fatalf("SetDataDir: %v", err)
}
@@ -302,7 +596,7 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
if _, err := settingsStore.Get("ext", "missing"); err == nil {
t.Fatal("expected missing setting error")
}
if err := settingsStore.SetAll("ext", map[string]interface{}{"a": float64(1), "_secret": "hidden"}); err != nil {
if err := settingsStore.SetAll("ext", map[string]any{"a": float64(1), "_secret": "hidden"}); err != nil {
t.Fatalf("settings SetAll: %v", err)
}
if all := settingsStore.GetAll("ext"); all["a"] != float64(1) {
@@ -317,17 +611,16 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
if jsonText, err := settingsStore.GetAllExtensionSettingsJSON(); err != nil || jsonText == "" {
t.Fatalf("settings JSON = %q/%v", jsonText, err)
}
reloaded := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
reloaded := &ExtensionSettingsStore{settings: map[string]map[string]any{}}
if err := reloaded.SetDataDir(settingsStore.dataDir); err != nil {
t.Fatalf("reload settings: %v", err)
}
vm := goja.New()
runtime := &extensionRuntime{
extensionID: "storage-ext",
dataDir: filepath.Join(dir, "runtime"),
vm: vm,
storageFlushDelay: time.Hour,
extensionID: "storage-ext",
dataDir: filepath.Join(dir, "runtime"),
vm: vm,
}
if err := os.MkdirAll(runtime.dataDir, 0755); err != nil {
t.Fatal(err)
@@ -335,16 +628,12 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
if got := runtime.storageGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("missing"), vm.ToValue("fallback")}}).String(); got != "fallback" {
t.Fatalf("storage fallback = %q", got)
}
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]any{"nested": "value"})}}); !ok.ToBoolean() {
t.Fatal("storageSet false")
}
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]any{"nested": "value"})}}); !ok.ToBoolean() {
t.Fatal("storageSet equal false")
}
loaded, err := runtime.loadStorage()
if err != nil || loaded["key"] == nil {
t.Fatalf("loadStorage = %#v/%v", loaded, err)
}
if err := runtime.flushStorageNow(); err != nil {
t.Fatalf("flushStorageNow: %v", err)
}
@@ -364,7 +653,7 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
if err := os.MkdirAll(credRuntime.dataDir, 0755); err != nil {
t.Fatal(err)
}
if result := credRuntime.credentialsStore(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token"), vm.ToValue("secret")}}).Export().(map[string]interface{}); result["success"] != true {
if result := credRuntime.credentialsStore(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token"), vm.ToValue("secret")}}).Export().(map[string]any); result["success"] != true {
t.Fatalf("credentialsStore = %#v", result)
}
if got := credRuntime.credentialsGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).String(); got != "secret" {
@@ -438,14 +727,14 @@ func TestExtensionRuntimeHTTPMatchingAndMetadataHelpers(t *testing.T) {
t.Fatalf("expected domain validation error for %s", rawURL)
}
}
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/get"), vm.ToValue(map[string]interface{}{"X-Test": "yes"})}}).Export().(map[string]interface{}); got["status"] != 201 || !strings.Contains(got["body"].(string), "GET") {
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/get"), vm.ToValue(map[string]any{"X-Test": "yes"})}}).Export().(map[string]any); got["status"] != 201 || !strings.Contains(got["body"].(string), "GET") {
t.Fatalf("httpGet = %#v", got)
}
if got := runtime.httpPost(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/post"), vm.ToValue(map[string]interface{}{"a": "b"})}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "POST") {
if got := runtime.httpPost(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/post"), vm.ToValue(map[string]any{"a": "b"})}}).Export().(map[string]any); !strings.Contains(got["body"].(string), "POST") {
t.Fatalf("httpPost = %#v", got)
}
requestOptions := map[string]interface{}{"method": "patch", "body": []interface{}{"x"}, "headers": map[string]interface{}{"X-Req": "1"}}
if got := runtime.httpRequest(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/request"), vm.ToValue(requestOptions)}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "PATCH") {
requestOptions := map[string]any{"method": "patch", "body": []any{"x"}, "headers": map[string]any{"X-Req": "1"}}
if got := runtime.httpRequest(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/request"), vm.ToValue(requestOptions)}}).Export().(map[string]any); !strings.Contains(got["body"].(string), "PATCH") {
t.Fatalf("httpRequest = %#v", got)
}
for _, method := range []struct {
@@ -454,14 +743,14 @@ func TestExtensionRuntimeHTTPMatchingAndMetadataHelpers(t *testing.T) {
args []goja.Value
}{
{name: "PUT", call: runtime.httpPut, args: []goja.Value{vm.ToValue("https://api.example.com/put"), vm.ToValue("body")}},
{name: "DELETE", call: runtime.httpDelete, args: []goja.Value{vm.ToValue("https://api.example.com/delete"), vm.ToValue(map[string]interface{}{"X-Delete": "1"})}},
{name: "PATCH", call: runtime.httpPatch, args: []goja.Value{vm.ToValue("https://api.example.com/patch"), vm.ToValue(map[string]interface{}{"p": "q"})}},
{name: "DELETE", call: runtime.httpDelete, args: []goja.Value{vm.ToValue("https://api.example.com/delete"), vm.ToValue(map[string]any{"X-Delete": "1"})}},
{name: "PATCH", call: runtime.httpPatch, args: []goja.Value{vm.ToValue("https://api.example.com/patch"), vm.ToValue(map[string]any{"p": "q"})}},
} {
if got := method.call(goja.FunctionCall{Arguments: method.args}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), method.name) {
if got := method.call(goja.FunctionCall{Arguments: method.args}).Export().(map[string]any); !strings.Contains(got["body"].(string), method.name) {
t.Fatalf("%s = %#v", method.name, got)
}
}
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/huge")}}).Export().(map[string]interface{}); !strings.Contains(got["error"].(string), "exceeds") {
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/huge")}}).Export().(map[string]any); !strings.Contains(got["error"].(string), "exceeds") {
t.Fatalf("huge response = %#v", got)
}
if !runtime.httpClearCookies(goja.FunctionCall{}).ToBoolean() {
@@ -561,14 +850,14 @@ func TestExtensionRuntimeFileAPIs(t *testing.T) {
t.Fatalf("absolute validatePath = %q/%v", got, err)
}
write := runtime.fileWrite(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt"), vm.ToValue("hello")}}).Export().(map[string]interface{})
write := runtime.fileWrite(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt"), vm.ToValue("hello")}}).Export().(map[string]any)
if write["success"] != true {
t.Fatalf("fileWrite = %#v", write)
}
if !runtime.fileExists(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).ToBoolean() {
t.Fatal("expected written file to exist")
}
read := runtime.fileRead(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).Export().(map[string]interface{})
read := runtime.fileRead(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).Export().(map[string]any)
if read["data"] != "hello" {
t.Fatalf("fileRead = %#v", read)
}
@@ -576,53 +865,53 @@ func TestExtensionRuntimeFileAPIs(t *testing.T) {
writeBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("nested/bytes.bin"),
vm.ToValue("4869"),
vm.ToValue(map[string]interface{}{"encoding": "hex", "truncate": true}),
}}).Export().(map[string]interface{})
vm.ToValue(map[string]any{"encoding": "hex", "truncate": true}),
}}).Export().(map[string]any)
if writeBytes["success"] != true {
t.Fatalf("fileWriteBytes = %#v", writeBytes)
}
appendBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("nested/bytes.bin"),
vm.ToValue([]interface{}{float64('!')}),
vm.ToValue(map[string]interface{}{"append": true}),
}}).Export().(map[string]interface{})
vm.ToValue([]any{float64('!')}),
vm.ToValue(map[string]any{"append": true}),
}}).Export().(map[string]any)
if appendBytes["success"] != true {
t.Fatalf("append fileWriteBytes = %#v", appendBytes)
}
readBytes := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("nested/bytes.bin"),
vm.ToValue(map[string]interface{}{"encoding": "text", "offset": float64(1), "length": float64(2)}),
}}).Export().(map[string]interface{})
vm.ToValue(map[string]any{"encoding": "text", "offset": float64(1), "length": float64(2)}),
}}).Export().(map[string]any)
if readBytes["data"] != "i!" || readBytes["bytes_read"] != 2 {
t.Fatalf("fileReadBytes = %#v", readBytes)
}
if bad := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("nested/bad.bin"),
vm.ToValue("x"),
vm.ToValue(map[string]interface{}{"append": true, "offset": float64(1)}),
}}).Export().(map[string]interface{}); bad["success"] != false {
vm.ToValue(map[string]any{"append": true, "offset": float64(1)}),
}}).Export().(map[string]any); bad["success"] != false {
t.Fatalf("expected append+offset failure, got %#v", bad)
}
if bad := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("nested/bytes.bin"),
vm.ToValue(map[string]interface{}{"encoding": "bad"}),
}}).Export().(map[string]interface{}); bad["success"] != false {
vm.ToValue(map[string]any{"encoding": "bad"}),
}}).Export().(map[string]any); bad["success"] != false {
t.Fatalf("expected bad encoding failure, got %#v", bad)
}
copyResult := runtime.fileCopy(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/bytes.bin"), vm.ToValue("nested/copy.bin")}}).Export().(map[string]interface{})
copyResult := runtime.fileCopy(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/bytes.bin"), vm.ToValue("nested/copy.bin")}}).Export().(map[string]any)
if copyResult["success"] != true {
t.Fatalf("fileCopy = %#v", copyResult)
}
moveResult := runtime.fileMove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/copy.bin"), vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
moveResult := runtime.fileMove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/copy.bin"), vm.ToValue("nested/moved.bin")}}).Export().(map[string]any)
if moveResult["success"] != true {
t.Fatalf("fileMove = %#v", moveResult)
}
sizeResult := runtime.fileGetSize(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
sizeResult := runtime.fileGetSize(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]any)
if sizeResult["success"] != true || sizeResult["size"] != int64(3) {
t.Fatalf("fileGetSize = %#v", sizeResult)
}
deleteResult := runtime.fileDelete(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
deleteResult := runtime.fileDelete(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]any)
if deleteResult["success"] != true {
t.Fatalf("fileDelete = %#v", deleteResult)
}
@@ -630,7 +919,7 @@ func TestExtensionRuntimeFileAPIs(t *testing.T) {
download := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("https://files.example.com/file"),
vm.ToValue("downloads/file.bin"),
}}).Export().(map[string]interface{})
}}).Export().(map[string]any)
if download["success"] != true {
t.Fatalf("fileDownload = %#v", download)
}
@@ -641,8 +930,8 @@ func TestExtensionRuntimeFileAPIs(t *testing.T) {
chunked := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("https://files.example.com/chunk"),
vm.ToValue("downloads/chunk.bin"),
vm.ToValue(map[string]interface{}{"chunked": float64(2), "headers": map[string]interface{}{"X-Test": "yes"}}),
}}).Export().(map[string]interface{})
vm.ToValue(map[string]any{"chunked": float64(2), "headers": map[string]any{"X-Test": "yes"}}),
}}).Export().(map[string]any)
if chunked["success"] != true {
t.Fatalf("chunked fileDownload = %#v", chunked)
}
@@ -650,7 +939,7 @@ func TestExtensionRuntimeFileAPIs(t *testing.T) {
t.Fatalf("chunked data = %q/%v", data, err)
}
if missing := runtime.fileDownload(goja.FunctionCall{}).Export().(map[string]interface{}); missing["success"] != false {
if missing := runtime.fileDownload(goja.FunctionCall{}).Export().(map[string]any); missing["success"] != false {
t.Fatalf("expected missing download args error, got %#v", missing)
}
}
@@ -668,31 +957,31 @@ func TestExtensionRuntimeUtilityAPIs(t *testing.T) {
if runtime.hmacSHA256Base64(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
t.Fatal("expected hmac sha256 base64")
}
if value := runtime.hmacSHA1(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue([]interface{}{float64(1), float64(2)}), vm.ToValue([]interface{}{float64(3)})}}); len(value.Export().([]interface{})) == 0 {
if value := runtime.hmacSHA1(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue([]any{float64(1), float64(2)}), vm.ToValue([]any{float64(3)})}}); len(value.Export().([]any)) == 0 {
t.Fatal("expected hmac sha1 bytes")
}
if !goja.IsUndefined(runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{bad`)}})) {
t.Fatal("expected invalid JSON to return undefined")
}
parsed := runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{"ok":true}`)}}).Export().(map[string]interface{})
parsed := runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{"ok":true}`)}}).Export().(map[string]any)
if parsed["ok"] != true {
t.Fatalf("parseJSON = %#v", parsed)
}
if text := runtime.stringifyJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"ok": true})}}).String(); !strings.Contains(text, "ok") {
if text := runtime.stringifyJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]any{"ok": true})}}).String(); !strings.Contains(text, "ok") {
t.Fatalf("stringifyJSON = %q", text)
}
encrypted := runtime.cryptoEncrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("plain"), vm.ToValue("secret")}}).Export().(map[string]interface{})
encrypted := runtime.cryptoEncrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("plain"), vm.ToValue("secret")}}).Export().(map[string]any)
if encrypted["success"] != true || encrypted["data"] == "" {
t.Fatalf("cryptoEncrypt = %#v", encrypted)
}
decrypted := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(encrypted["data"]), vm.ToValue("secret")}}).Export().(map[string]interface{})
decrypted := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(encrypted["data"]), vm.ToValue("secret")}}).Export().(map[string]any)
if decrypted["success"] != true || decrypted["data"] != "plain" {
t.Fatalf("cryptoDecrypt = %#v", decrypted)
}
if bad := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("bad"), vm.ToValue("secret")}}).Export().(map[string]interface{}); bad["success"] != false {
if bad := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("bad"), vm.ToValue("secret")}}).Export().(map[string]any); bad["success"] != false {
t.Fatalf("expected bad decrypt failure, got %#v", bad)
}
key := runtime.cryptoGenerateKey(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(8))}}).Export().(map[string]interface{})
key := runtime.cryptoGenerateKey(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(8))}}).Export().(map[string]any)
if key["success"] != true || key["key"] == "" || key["hex"] == "" {
t.Fatalf("cryptoGenerateKey = %#v", key)
}
@@ -745,3 +1034,28 @@ func TestExtensionRuntimeUtilityAPIs(t *testing.T) {
t.Fatalf("sanitize wrapper = %q", clean)
}
}
func TestClassifySignedSessionExpiredAsVerification(t *testing.T) {
got := classifyDownloadErrorType("Failed to resolve Deezer download: signed session expired")
if got != "verification_required" {
t.Fatalf("expected verification_required, got %q", got)
}
}
func TestVerificationRequiredNoteConsume(t *testing.T) {
r := &extensionRuntime{}
if got := r.consumeVerificationRequired(); got != "" {
t.Fatalf("expected empty before note, got %q", got)
}
r.noteVerificationRequired("")
if got := r.consumeVerificationRequired(); got != "pending" {
t.Fatalf("expected pending sentinel, got %q", got)
}
if got := r.consumeVerificationRequired(); got != "" {
t.Fatalf("expected cleared after consume, got %q", got)
}
r.noteVerificationRequired("https://x/auth")
if got := r.consumeVerificationRequired(); got != "https://x/auth" {
t.Fatalf("expected auth url, got %q", got)
}
}
+49 -52
View File
@@ -16,6 +16,24 @@ import (
"github.com/dop251/goja"
)
// jsError returns the standard {"success": false, "error": ...} extension
// response.
func (r *extensionRuntime) jsError(format string, args ...any) goja.Value {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf(format, args...),
})
}
// jsSuccess returns kv with "success": true added.
func (r *extensionRuntime) jsSuccess(kv map[string]any) goja.Value {
if kv == nil {
kv = map[string]any{}
}
kv["success"] = true
return r.vm.ToValue(kv)
}
func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@@ -88,7 +106,7 @@ func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
switch k := keyArg.(type) {
case string:
keyBytes = []byte(k)
case []interface{}:
case []any:
keyBytes = make([]byte, len(k))
for i, v := range k {
if num, ok := v.(int64); ok {
@@ -106,7 +124,7 @@ func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
switch m := msgArg.(type) {
case string:
msgBytes = []byte(m)
case []interface{}:
case []any:
msgBytes = make([]byte, len(m))
for i, v := range m {
if num, ok := v.(int64); ok {
@@ -123,7 +141,7 @@ func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
mac.Write(msgBytes)
result := mac.Sum(nil)
jsArray := make([]interface{}, len(result))
jsArray := make([]any, len(result))
for i, b := range result {
jsArray[i] = int(b)
}
@@ -136,7 +154,7 @@ func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
}
input := call.Arguments[0].String()
var result interface{}
var result any
if err := json.Unmarshal([]byte(input), &result); err != nil {
GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err)
return goja.Undefined()
@@ -162,10 +180,7 @@ func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "plaintext and key are required",
})
return r.jsError("plaintext and key are required")
}
plaintext := call.Arguments[0].String()
@@ -175,24 +190,17 @@ func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": base64.StdEncoding.EncodeToString(encrypted),
return r.jsSuccess(map[string]any{
"data": base64.StdEncoding.EncodeToString(encrypted),
})
}
func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "ciphertext and key are required",
})
return r.jsError("ciphertext and key are required")
}
ciphertextB64 := call.Arguments[0].String()
@@ -200,25 +208,18 @@ func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "invalid base64 ciphertext",
})
return r.jsError("invalid base64 ciphertext")
}
keyHash := sha256.Sum256([]byte(keyStr))
decrypted, err := decryptAES(ciphertext, keyHash[:])
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "invalid base64 ciphertext",
})
return r.jsError("invalid base64 ciphertext")
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": string(decrypted),
return r.jsSuccess(map[string]any{
"data": string(decrypted),
})
}
@@ -232,16 +233,12 @@ func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
key := make([]byte, length)
if _, err := rand.Read(key); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return r.jsError("%s", err.Error())
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"key": base64.StdEncoding.EncodeToString(key),
"hex": hex.EncodeToString(key),
return r.jsSuccess(map[string]any{
"key": base64.StdEncoding.EncodeToString(key),
"hex": hex.EncodeToString(key),
})
}
@@ -397,7 +394,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"error": "file path is required",
})
}
@@ -405,12 +402,12 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
filePath := call.Arguments[0].String()
quality, err := GetAudioQuality(filePath)
if err != nil {
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"error": err.Error(),
})
}
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"bitDepth": quality.BitDepth,
"sampleRate": quality.SampleRate,
"totalSamples": quality.TotalSamples,
@@ -421,7 +418,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
obj.Set("getLyricsLRC", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 3 {
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"error": "spotifyID, trackName, and artistName are required",
})
}
@@ -440,19 +437,19 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
lyrics, err := GetLyricsLRC(spotifyID, trackName, artistName, filePath, durationMs)
if err != nil {
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"error": err.Error(),
})
}
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"lyrics": lyrics,
})
})
obj.Set("checkISRCExists", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"error": "outputDir and isrc are required",
})
}
@@ -460,13 +457,13 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
outputDir := strings.TrimSpace(call.Arguments[0].String())
isrc := strings.TrimSpace(call.Arguments[1].String())
if outputDir == "" || isrc == "" {
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"error": "outputDir and isrc are required",
})
}
filePath, exists := checkISRCExistsInternal(outputDir, isrc)
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"exists": exists,
"filePath": filePath,
})
@@ -474,7 +471,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
obj.Set("addToISRCIndex", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 3 {
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"error": "outputDir, isrc, and filePath are required",
})
}
@@ -483,13 +480,13 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
isrc := strings.TrimSpace(call.Arguments[1].String())
filePath := strings.TrimSpace(call.Arguments[2].String())
if outputDir == "" || isrc == "" || filePath == "" {
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"error": "outputDir, isrc, and filePath are required",
})
}
AddToISRCIndex(outputDir, isrc, filePath)
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"success": true,
})
})
@@ -502,7 +499,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
template := call.Arguments[0].String()
metadataObj := call.Arguments[1].Export()
metadata, ok := metadataObj.(map[string]interface{})
metadata, ok := metadataObj.(map[string]any)
if !ok {
return vm.ToValue("")
}
@@ -515,7 +512,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
_, offsetSeconds := now.Zone()
offsetMinutes := offsetSeconds / 60
return vm.ToValue(map[string]interface{}{
return vm.ToValue(map[string]any{
"year": now.Year(),
"month": int(now.Month()),
"day": now.Day(),
+13 -13
View File
@@ -11,7 +11,7 @@ import (
type ExtensionSettingsStore struct {
mu sync.RWMutex
dataDir string
settings map[string]map[string]interface{} // extensionID -> settings
settings map[string]map[string]any // extensionID -> settings
}
var (
@@ -22,7 +22,7 @@ var (
func GetExtensionSettingsStore() *ExtensionSettingsStore {
globalSettingsStoreOnce.Do(func() {
globalSettingsStore = &ExtensionSettingsStore{
settings: make(map[string]map[string]interface{}),
settings: make(map[string]map[string]any),
}
})
return globalSettingsStore
@@ -68,17 +68,17 @@ func (s *ExtensionSettingsStore) loadAllSettings() error {
return nil
}
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]any, error) {
settingsPath := s.getSettingsPath(extensionID)
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]interface{}), nil
return make(map[string]any), nil
}
return nil, err
}
var settings map[string]interface{}
var settings map[string]any
if err := json.Unmarshal(data, &settings); err != nil {
return nil, err
}
@@ -86,7 +86,7 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
return settings, nil
}
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]any) error {
settingsPath := s.getSettingsPath(extensionID)
dir := filepath.Dir(settingsPath)
@@ -102,7 +102,7 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s
return os.WriteFile(settingsPath, data, 0644)
}
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
func (s *ExtensionSettingsStore) Get(extensionID, key string) (any, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -118,28 +118,28 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, erro
return value, nil
}
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]any {
s.mu.RLock()
defer s.mu.RUnlock()
extSettings, exists := s.settings[extensionID]
if !exists {
return make(map[string]interface{})
return make(map[string]any)
}
result := make(map[string]interface{})
result := make(map[string]any)
for k, v := range extSettings {
result[k] = v
}
return result
}
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
func (s *ExtensionSettingsStore) Set(extensionID, key string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.settings[extensionID]; !exists {
s.settings[extensionID] = make(map[string]interface{})
s.settings[extensionID] = make(map[string]any)
}
s.settings[extensionID][key] = value
@@ -147,7 +147,7 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
return s.saveSettings(extensionID, s.settings[extensionID])
}
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]any) error {
s.mu.Lock()
defer s.mu.Unlock()
+714
View File
@@ -0,0 +1,714 @@
package gobackend
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
const signedSessionRefreshSkew = time.Hour
var (
pendingSignedSessionGrants = make(map[string]string)
pendingSignedSessionGrantsMu sync.Mutex
)
type signedSessionRecord struct {
InstallID string `json:"install_id"`
SessionID string `json:"session_id,omitempty"`
SessionSecret string `json:"session_secret,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Namespace string `json:"namespace,omitempty"`
BaseURL string `json:"base_url,omitempty"`
AppVersion string `json:"app_version,omitempty"`
Platform string `json:"platform,omitempty"`
}
type signedSessionExchangeResponse struct {
SessionID string `json:"session_id,omitempty"`
SessionSecret string `json:"session_secret,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
ChallengeID string `json:"challenge_id,omitempty"`
ChallengeURL string `json:"challenge_url,omitempty"`
AuthURL string `json:"auth_url,omitempty"`
}
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
if config == nil {
return SignedSessionConfig{}
}
resolved := *config
if resolved.AppVersion == "" {
resolved.AppVersion = "ext-1.0"
}
if resolved.Platform == "" {
resolved.Platform = "extension"
}
if resolved.CallbackURL == "" {
resolved.CallbackURL = "spotiflac://session-grant"
}
if resolved.SchemeLabel == "" {
resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1"
}
if resolved.HeaderPrefix == "" {
resolved.HeaderPrefix = "X-Sig-"
}
if resolved.TimeWindowSeconds <= 0 {
resolved.TimeWindowSeconds = 300
}
if resolved.Endpoints.Bootstrap == "" {
resolved.Endpoints.Bootstrap = "/bootstrap"
}
if resolved.Endpoints.Challenge == "" {
resolved.Endpoints.Challenge = "/challenge"
}
if resolved.Endpoints.Exchange == "" {
resolved.Endpoints.Exchange = "/session/exchange"
}
return resolved
}
func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) {
namespace := sanitizeSignedSessionNamespace(config.Namespace)
if namespace == "" {
return "", fmt.Errorf("signed session namespace is empty")
}
baseDir := filepath.Dir(r.dataDir)
if baseDir == "." || baseDir == "" {
baseDir = r.dataDir
}
dir := filepath.Join(baseDir, "signed_sessions")
if err := os.MkdirAll(dir, 0700); err != nil {
return "", err
}
scope := strings.Join([]string{
namespace,
strings.TrimSpace(strings.ToLower(config.BaseURL)),
strings.TrimSpace(strings.ToLower(config.AppVersion)),
strings.TrimSpace(strings.ToLower(config.Platform)),
}, "\n")
sum := sha256.Sum256([]byte(scope))
return filepath.Join(dir, namespace+"-"+hex.EncodeToString(sum[:])[:16]+".json"), nil
}
func sanitizeSignedSessionNamespace(namespace string) string {
namespace = strings.TrimSpace(strings.ToLower(namespace))
var b strings.Builder
for _, ch := range namespace {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' {
b.WriteRune(ch)
}
}
return strings.Trim(b.String(), ".-_")
}
func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
path, err := r.signedSessionFilePath(config)
if err != nil {
return nil, err
}
record := &signedSessionRecord{}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, record)
}
if strings.TrimSpace(record.InstallID) == "" {
record.InstallID = randomHex(16)
}
normalizeSignedSessionRecordScope(config, record)
if err := r.saveSignedSession(config, record); err != nil {
return nil, err
}
return record, nil
}
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) {
namespace := sanitizeSignedSessionNamespace(config.Namespace)
baseURL := strings.TrimSpace(config.BaseURL)
appVersion := strings.TrimSpace(config.AppVersion)
platform := strings.TrimSpace(config.Platform)
if record.Namespace == "" && record.BaseURL == "" && record.AppVersion == "" && record.Platform == "" {
record.Namespace = namespace
record.BaseURL = baseURL
record.AppVersion = appVersion
record.Platform = platform
return
}
if record.Namespace != namespace ||
record.BaseURL != baseURL ||
record.AppVersion != appVersion ||
record.Platform != platform {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
}
record.Namespace = namespace
record.BaseURL = baseURL
record.AppVersion = appVersion
record.Platform = platform
}
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
path, err := r.signedSessionFilePath(config)
if err != nil {
return err
}
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func randomHex(bytesLen int) string {
buf := make([]byte, bytesLen)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func parseSignedSessionTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.000Z",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func signedSessionRecordIsUsable(record *signedSessionRecord) bool {
if record == nil || strings.TrimSpace(record.SessionID) == "" || strings.TrimSpace(record.SessionSecret) == "" {
return false
}
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
return time.Now().Before(expiresAt)
}
return true
}
// preflightSignedSession prepares a signed session before download metadata
// enrichment starts. A fresh pending challenge is reused, while bootstrap
// responses that can issue a session silently are accepted without prompting
// the user. Bootstrap failures remain non-fatal to the caller so the normal
// provider path can still surface its more specific error.
func (r *extensionRuntime) preflightSignedSession() (bool, error) {
if r == nil || r.manifest == nil || r.manifest.SignedSession == nil {
return false, nil
}
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return false, fmt.Errorf("signedSession is not configured")
}
record, err := r.loadSignedSession(config)
if err != nil {
return false, err
}
if signedSessionRecordIsUsable(record) {
return false, nil
}
if pending := GetPendingAuthRequest(r.extensionID); pending != nil {
if time.Since(pending.CreatedAt) < pendingAuthRequestTTL &&
strings.TrimSpace(pending.AuthURL) != "" {
return true, nil
}
ClearPendingAuthRequest(r.extensionID)
}
if authURL := r.startSignedSessionVerification(config, "download-preflight"); authURL != "" {
return true, nil
}
// Bootstrap may provision a session directly instead of returning a
// challenge. Reload the record before treating the empty URL as a failure.
record, err = r.loadSignedSession(config)
if err != nil {
return false, err
}
if signedSessionRecordIsUsable(record) {
return false, nil
}
return false, fmt.Errorf("signed-session bootstrap did not return a session or verification challenge")
}
func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return r.vm.ToValue(map[string]any{"authenticated": false, "error": "signedSession is not configured"})
}
record, err := r.loadSignedSession(config)
if err != nil {
return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()})
}
authenticated := signedSessionRecordIsUsable(record)
return r.vm.ToValue(map[string]any{
"authenticated": authenticated,
"expires_at": record.ExpiresAt,
"install_id": record.InstallID,
"session_id": record.SessionID,
"app_version": config.AppVersion,
"platform": config.Platform,
})
}
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
record, err := r.loadSignedSession(config)
if err != nil {
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
}
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
if err := r.saveSignedSession(config, record); err != nil {
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
}
ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]any{"success": true})
}
func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value {
grant := ""
if len(call.Arguments) > 0 {
grant = strings.TrimSpace(call.Arguments[0].String())
}
if grant == "" {
pendingSignedSessionGrantsMu.Lock()
grant = pendingSignedSessionGrants[r.extensionID]
delete(pendingSignedSessionGrants, r.extensionID)
pendingSignedSessionGrantsMu.Unlock()
}
if grant == "" {
return r.vm.ToValue(map[string]any{"success": false, "error": "no pending grant"})
}
if err := r.exchangeSignedSessionGrant(grant); err != nil {
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
}
ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]any{"success": true})
}
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
record, err := r.loadSignedSession(config)
if err != nil {
return err
}
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
if err != nil {
return err
}
payload := map[string]any{
"grant": grant,
"install_id": record.InstallID,
"app_version": config.AppVersion,
"platform": config.Platform,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
resp, err := r.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
}
var exchanged signedSessionExchangeResponse
if err := json.Unmarshal(respBody, &exchanged); err != nil {
return fmt.Errorf("invalid session exchange response: %w", err)
}
if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" {
return fmt.Errorf("session exchange response missing session fields")
}
record.SessionID = exchanged.SessionID
record.SessionSecret = exchanged.SessionSecret
record.ExpiresAt = exchanged.ExpiresAt
return r.saveSignedSession(config, record)
}
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]any{"ok": false, "error": "method and path are required"})
}
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return r.vm.ToValue(map[string]any{"ok": false, "error": "signedSession is not configured"})
}
method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String()))
requestPath := call.Arguments[1].String()
body := []byte{}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
switch v := call.Arguments[2].Export().(type) {
case string:
body = []byte(v)
case map[string]any, []any:
encoded, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
}
body = encoded
default:
body = []byte(call.Arguments[2].String())
}
}
extraHeaders := parseGojaHeaders(call.Argument(3).Export())
record, err := r.ensureSignedSession(config)
if err != nil {
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
return r.signedSessionVerificationRequiredValue(authURL)
}
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
}
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
if err != nil {
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = r.saveSignedSession(config, record)
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
return r.signedSessionVerificationRequiredValue(authURL)
}
}
return r.vm.ToValue(map[string]any{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(respBody),
"headers": respHeaders,
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp),
})
}
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
r.noteVerificationRequired(authURL)
return r.vm.ToValue(map[string]any{
"ok": false,
"needsVerification": true,
"error": "VERIFY_REQUIRED",
"open_auth_url": authURL,
"auth_url": authURL,
})
}
func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
record, err := r.loadSignedSession(config)
if err != nil {
return nil, err
}
if record.SessionID == "" || record.SessionSecret == "" {
return nil, fmt.Errorf("signed session is not authenticated")
}
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
if time.Now().After(expiresAt) {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = r.saveSignedSession(config, record)
return nil, fmt.Errorf("signed session expired")
}
if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew {
_ = r.refreshSignedSession(config, record)
}
}
return record, nil
}
func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
body, _ := json.Marshal(map[string]string{"install_id": record.InstallID})
resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode)
}
var refreshed signedSessionExchangeResponse
if err := json.Unmarshal(respBody, &refreshed); err != nil {
return err
}
changed := false
if refreshed.SessionID != "" {
record.SessionID = refreshed.SessionID
changed = true
}
if refreshed.SessionSecret != "" {
record.SessionSecret = refreshed.SessionSecret
changed = true
}
if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt {
record.ExpiresAt = refreshed.ExpiresAt
changed = true
}
if changed {
return r.saveSignedSession(config, record)
}
return nil
}
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, _ string) string {
record, err := r.loadSignedSession(config)
if err != nil {
return ""
}
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
if err != nil {
return ""
}
parsed, _ := url.Parse(bootstrapURL)
query := parsed.Query()
query.Set("app_version", config.AppVersion)
query.Set("install_id", record.InstallID)
parsed.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
if err != nil {
return ""
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
resp, err := r.httpClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ""
}
var boot signedSessionExchangeResponse
if err := json.Unmarshal(body, &boot); err != nil {
return ""
}
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
record.SessionID = boot.SessionID
record.SessionSecret = boot.SessionSecret
record.ExpiresAt = boot.ExpiresAt
_ = r.saveSignedSession(config, record)
return ""
}
authURL := boot.AuthURL
if authURL == "" && boot.ChallengeURL != "" {
authURL = boot.ChallengeURL
}
if authURL == "" && boot.ChallengeID != "" {
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
}
if authURL != "" {
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: authURL,
CallbackURL: config.CallbackURL,
CreatedAt: time.Now(),
}
pendingAuthRequestsMu.Unlock()
}
return authURL
}
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge)
if err != nil {
return ""
}
parsed, err := url.Parse(challengeURL)
if err != nil {
return ""
}
callback, err := url.Parse(config.CallbackURL)
if err != nil {
return ""
}
q := callback.Query()
q.Set("cb_version", "v2grant")
q.Set("state", r.extensionID)
callback.RawQuery = q.Encode()
query := parsed.Query()
query.Set("id", challengeID)
query.Set("cb", callback.String())
parsed.RawQuery = query.Encode()
return parsed.String()
}
func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) {
base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/")
if err != nil || base.Scheme != "https" || base.Host == "" {
return "", fmt.Errorf("invalid signed session baseUrl")
}
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return "", fmt.Errorf("signed session endpoint is empty")
}
if strings.HasPrefix(endpoint, "https://") {
return endpoint, nil
}
endpoint = strings.TrimLeft(endpoint, "/")
ref, _ := url.Parse(endpoint)
return base.ResolveReference(ref).String(), nil
}
func (r *extensionRuntime) doSignedSessionRequest(
config SignedSessionConfig,
record *signedSessionRecord,
method string,
requestPath string,
body []byte,
extraHeaders map[string]string,
) (*http.Response, []byte, map[string]any, error) {
fullURL, err := signedSessionURL(config, requestPath)
if err != nil {
return nil, nil, nil, err
}
parsed, err := url.Parse(fullURL)
if err != nil {
return nil, nil, nil, err
}
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
nonce := randomHex(12)
bodyHashBytes := sha256.Sum256(body)
bodyHash := hex.EncodeToString(bodyHashBytes[:])
parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts)
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID)
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput)))
signingInput := strings.Join([]string{
config.SchemeLabel,
method,
parsed.EscapedPath(),
"",
bodyHash,
ts,
nonce,
record.SessionID,
config.AppVersion,
config.Platform,
}, "\n")
sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
req, err := http.NewRequest(method, fullURL, bytes.NewReader(body))
if err != nil {
return nil, nil, nil, err
}
req = r.bindDownloadCancelContext(req)
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
prefix := config.HeaderPrefix
req.Header.Set(prefix+"Session", record.SessionID)
req.Header.Set(prefix+"Timestamp", ts)
req.Header.Set(prefix+"Nonce", nonce)
req.Header.Set(prefix+"Body-SHA256", bodyHash)
req.Header.Set(prefix+"Signature", sig)
req.Header.Set(prefix+"App-Version", config.AppVersion)
req.Header.Set(prefix+"Platform", config.Platform)
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, nil, nil, err
}
defer resp.Body.Close()
respBody, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return nil, nil, nil, err
}
headers := make(map[string]any)
for k, v := range resp.Header {
if len(v) == 1 {
headers[k] = v[0]
} else {
headers[k] = v
}
}
return resp, respBody, headers, nil
}
func signedSessionRetryAfterSeconds(resp *http.Response) int {
if resp == nil {
return 0
}
value := strings.TrimSpace(resp.Header.Get("Retry-After"))
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
if seconds < 0 {
return 0
}
return seconds
}
if retryAt, err := http.ParseTime(value); err == nil {
seconds := int(time.Until(retryAt).Seconds())
if seconds < 0 {
return 0
}
return seconds
}
return 0
}
func hmacSHA256Bytes(key, message []byte) []byte {
mac := hmac.New(sha256.New, key)
mac.Write(message)
return mac.Sum(nil)
}
func setPendingSignedSessionGrant(extensionID, grant string) {
extensionID = strings.TrimSpace(extensionID)
grant = strings.TrimSpace(grant)
if extensionID == "" || grant == "" {
return
}
pendingSignedSessionGrantsMu.Lock()
pendingSignedSessionGrants[extensionID] = grant
pendingSignedSessionGrantsMu.Unlock()
}
+930
View File
@@ -0,0 +1,930 @@
package gobackend
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
goruntime "runtime"
"strings"
"testing"
"time"
"github.com/dop251/goja"
)
func TestSanitizeSignedSessionNamespace(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"lowercases", "MyExt", "myext"},
{"trims whitespace", " my-ext ", "my-ext"},
{"keeps allowed punctuation", "my-ext_v1.2", "my-ext_v1.2"},
{"strips spaces and slashes but keeps dots", "my ext/../v1", "myext..v1"},
{"strips leading and trailing punctuation", "..--my-ext__..", "my-ext"},
{"empty stays empty", "", ""},
{"only punctuation collapses to empty", "...", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := sanitizeSignedSessionNamespace(tc.in); got != tc.want {
t.Errorf("sanitizeSignedSessionNamespace(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestSignedSessionConfigWithDefaults(t *testing.T) {
t.Run("nil config yields zero value", func(t *testing.T) {
got := signedSessionConfigWithDefaults(nil)
if got != (SignedSessionConfig{}) {
t.Errorf("expected zero value config, got %+v", got)
}
})
t.Run("fills in defaults without a namespace or baseUrl", func(t *testing.T) {
got := signedSessionConfigWithDefaults(&SignedSessionConfig{})
if got.AppVersion != "ext-1.0" {
t.Errorf("AppVersion = %q, want ext-1.0", got.AppVersion)
}
if got.Platform != "extension" {
t.Errorf("Platform = %q, want extension", got.Platform)
}
if got.CallbackURL != "spotiflac://session-grant" {
t.Errorf("CallbackURL = %q", got.CallbackURL)
}
if got.SchemeLabel != "SPOTIFLAC-HMAC-V1" {
t.Errorf("SchemeLabel = %q", got.SchemeLabel)
}
if got.HeaderPrefix != "X-Sig-" {
t.Errorf("HeaderPrefix = %q", got.HeaderPrefix)
}
if got.TimeWindowSeconds != 300 {
t.Errorf("TimeWindowSeconds = %d, want 300", got.TimeWindowSeconds)
}
if got.Endpoints.Bootstrap != "/bootstrap" || got.Endpoints.Challenge != "/challenge" || got.Endpoints.Exchange != "/session/exchange" {
t.Errorf("Endpoints defaults = %+v", got.Endpoints)
}
})
t.Run("preserves values the manifest already set", func(t *testing.T) {
custom := &SignedSessionConfig{
Namespace: "tidal",
BaseURL: "https://auth.example.com",
AppVersion: "5.0",
Platform: "mobile",
TimeWindowSeconds: 60,
Endpoints: SignedSessionEndpoints{Exchange: "/custom/exchange"},
}
got := signedSessionConfigWithDefaults(custom)
if got.Namespace != "tidal" || got.BaseURL != "https://auth.example.com" {
t.Errorf("namespace/baseUrl were overwritten: %+v", got)
}
if got.AppVersion != "5.0" || got.Platform != "mobile" || got.TimeWindowSeconds != 60 {
t.Errorf("existing scalars were overwritten: %+v", got)
}
if got.Endpoints.Exchange != "/custom/exchange" {
t.Errorf("Endpoints.Exchange overwritten: %q", got.Endpoints.Exchange)
}
// Untouched endpoints still get their defaults filled in.
if got.Endpoints.Bootstrap != "/bootstrap" {
t.Errorf("Endpoints.Bootstrap = %q, want default", got.Endpoints.Bootstrap)
}
})
}
func TestParseSignedSessionTime(t *testing.T) {
cases := []struct {
name string
in string
wantOK bool
wantUTC string
}{
{"RFC3339Nano", "2026-05-04T10:00:00.123456789Z", true, "2026-05-04T10:00:00Z"},
{"RFC3339", "2026-05-04T10:00:00Z", true, "2026-05-04T10:00:00Z"},
{"millisecond layout", "2026-05-04T10:00:00.000Z", true, "2026-05-04T10:00:00Z"},
{"empty", "", false, ""},
{"garbage", "not-a-time", false, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := parseSignedSessionTime(tc.in)
if ok != tc.wantOK {
t.Fatalf("parseSignedSessionTime(%q) ok = %v, want %v", tc.in, ok, tc.wantOK)
}
if ok && got.UTC().Format(time.RFC3339) != tc.wantUTC {
t.Errorf("parseSignedSessionTime(%q) = %v, want %v", tc.in, got.UTC().Format(time.RFC3339), tc.wantUTC)
}
})
}
}
func TestPreflightSignedSession(t *testing.T) {
t.Run("reuses a valid session without network", func(t *testing.T) {
calls := 0
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return nil, fmt.Errorf("unexpected request: %s", req.URL)
})
runtime := newSignedSessionTestRuntime(t, "preflight-valid", transport)
runtime.manifest.SignedSession = &SignedSessionConfig{
Namespace: "preflight-valid",
BaseURL: "https://auth.example.com",
}
config := signedSessionConfigWithDefaults(runtime.manifest.SignedSession)
record, err := runtime.loadSignedSession(config)
if err != nil {
t.Fatalf("load session: %v", err)
}
record.SessionID = "session"
record.SessionSecret = "secret"
record.ExpiresAt = time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
if err := runtime.saveSignedSession(config, record); err != nil {
t.Fatalf("save session: %v", err)
}
verificationRequired, err := runtime.preflightSignedSession()
if err != nil || verificationRequired {
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
}
if calls != 0 {
t.Fatalf("valid session made %d network request(s)", calls)
}
})
t.Run("reuses a fresh pending challenge", func(t *testing.T) {
runtime := newSignedSessionTestRuntime(t, "preflight-pending", roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("unexpected request: %s", req.URL)
}))
runtime.manifest.SignedSession = &SignedSessionConfig{
Namespace: "preflight-pending",
BaseURL: "https://auth.example.com",
}
pendingAuthRequestsMu.Lock()
pendingAuthRequests[runtime.extensionID] = &PendingAuthRequest{
ExtensionID: runtime.extensionID,
AuthURL: "https://auth.example.com/challenge",
CreatedAt: time.Now(),
}
pendingAuthRequestsMu.Unlock()
t.Cleanup(func() { ClearPendingAuthRequest(runtime.extensionID) })
verificationRequired, err := runtime.preflightSignedSession()
if err != nil || !verificationRequired {
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
}
})
t.Run("bootstraps a challenge for an unauthenticated session", func(t *testing.T) {
calls := 0
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
payload, _ := json.Marshal(signedSessionExchangeResponse{
ChallengeURL: "https://auth.example.com/challenge",
})
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(payload))),
Request: req,
}, nil
})
runtime := newSignedSessionTestRuntime(t, "preflight-challenge", transport)
runtime.manifest.SignedSession = &SignedSessionConfig{
Namespace: "preflight-challenge",
BaseURL: "https://auth.example.com",
}
t.Cleanup(func() { ClearPendingAuthRequest(runtime.extensionID) })
verificationRequired, err := runtime.preflightSignedSession()
if err != nil || !verificationRequired {
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
}
if calls != 1 {
t.Fatalf("bootstrap calls = %d, want 1", calls)
}
if pending := GetPendingAuthRequest(runtime.extensionID); pending == nil || pending.AuthURL == "" {
t.Fatalf("pending challenge = %#v", pending)
}
})
t.Run("accepts a session issued directly by bootstrap", func(t *testing.T) {
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
payload, _ := json.Marshal(signedSessionExchangeResponse{
SessionID: "boot-session",
SessionSecret: "boot-secret",
ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
})
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(payload))),
Request: req,
}, nil
})
runtime := newSignedSessionTestRuntime(t, "preflight-direct", transport)
runtime.manifest.SignedSession = &SignedSessionConfig{
Namespace: "preflight-direct",
BaseURL: "https://auth.example.com",
}
verificationRequired, err := runtime.preflightSignedSession()
if err != nil || verificationRequired {
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
}
config := signedSessionConfigWithDefaults(runtime.manifest.SignedSession)
record, err := runtime.loadSignedSession(config)
if err != nil || record.SessionID != "boot-session" {
t.Fatalf("bootstrapped session = %#v error:%v", record, err)
}
})
}
func TestDownloadWithExtensionsPreflightsBeforeMetadataEnrichment(t *testing.T) {
extensionID := "preflight-download"
itemID := "preflight-item"
RemoveItemProgress(itemID)
t.Cleanup(func() { RemoveItemProgress(itemID) })
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
payload, _ := json.Marshal(signedSessionExchangeResponse{
ChallengeURL: "https://auth.example.com/challenge",
})
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(payload))),
Request: req,
}, nil
})
runtime := newSignedSessionTestRuntime(t, extensionID, transport)
manifest := &ExtensionManifest{
Name: extensionID,
Types: []ExtensionType{ExtensionTypeDownloadProvider},
SignedSession: &SignedSessionConfig{
Namespace: extensionID,
BaseURL: "https://auth.example.com",
},
}
runtime.manifest = manifest
ext := &loadedExtension{
ID: extensionID,
Manifest: manifest,
VM: runtime.vm,
runtime: runtime,
initialized: true,
Enabled: true,
DataDir: runtime.dataDir,
}
manager := getExtensionManager()
manager.mu.Lock()
previous, hadPrevious := manager.extensions[extensionID]
manager.extensions[extensionID] = ext
manager.mu.Unlock()
t.Cleanup(func() {
ClearPendingAuthRequest(extensionID)
manager.mu.Lock()
if hadPrevious {
manager.extensions[extensionID] = previous
} else {
delete(manager.extensions, extensionID)
}
manager.mu.Unlock()
})
requestJSON := `{"service":"preflight-download","item_id":"preflight-item","isrc":"USRC17607839"}`
responseJSON, err := DownloadWithExtensionsJSON(requestJSON)
if err != nil {
t.Fatalf("DownloadWithExtensionsJSON: %v", err)
}
var response DownloadResponse
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.ErrorType != "verification_required" || response.Service != extensionID {
t.Fatalf("response = %#v", response)
}
if got := GetItemProgress(itemID); got != "{}" {
t.Fatalf("verification response left stale progress: %s", got)
}
}
func TestSignedSessionURL(t *testing.T) {
base := SignedSessionConfig{BaseURL: "https://auth.example.com/api"}
t.Run("joins a relative endpoint onto the base", func(t *testing.T) {
got, err := signedSessionURL(base, "/session/exchange")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "https://auth.example.com/api/session/exchange"
if got != want {
t.Errorf("signedSessionURL = %q, want %q", got, want)
}
})
t.Run("passes an absolute https endpoint through unchanged", func(t *testing.T) {
got, err := signedSessionURL(base, "https://other.example.com/challenge")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "https://other.example.com/challenge" {
t.Errorf("signedSessionURL = %q", got)
}
})
t.Run("rejects an empty endpoint", func(t *testing.T) {
if _, err := signedSessionURL(base, ""); err == nil {
t.Error("expected error for empty endpoint")
}
})
t.Run("rejects a non-https base URL", func(t *testing.T) {
if _, err := signedSessionURL(SignedSessionConfig{BaseURL: "http://auth.example.com"}, "/x"); err == nil {
t.Error("expected error for http:// base URL")
}
})
t.Run("rejects a base URL with no host", func(t *testing.T) {
if _, err := signedSessionURL(SignedSessionConfig{BaseURL: "https:///no-host"}, "/x"); err == nil {
t.Error("expected error for base URL without a host")
}
})
}
func TestSignedSessionRetryAfterSeconds(t *testing.T) {
t.Run("nil response", func(t *testing.T) {
if got := signedSessionRetryAfterSeconds(nil); got != 0 {
t.Errorf("got %d, want 0", got)
}
})
t.Run("numeric seconds", func(t *testing.T) {
resp := &http.Response{Header: http.Header{"Retry-After": []string{"30"}}}
if got := signedSessionRetryAfterSeconds(resp); got != 30 {
t.Errorf("got %d, want 30", got)
}
})
t.Run("negative numeric seconds clamp to zero", func(t *testing.T) {
resp := &http.Response{Header: http.Header{"Retry-After": []string{"-5"}}}
if got := signedSessionRetryAfterSeconds(resp); got != 0 {
t.Errorf("got %d, want 0", got)
}
})
t.Run("HTTP-date in the future", func(t *testing.T) {
future := time.Now().Add(2 * time.Minute).UTC()
resp := &http.Response{Header: http.Header{"Retry-After": []string{future.Format(http.TimeFormat)}}}
got := signedSessionRetryAfterSeconds(resp)
if got <= 0 || got > 120 {
t.Errorf("got %d, want roughly 120", got)
}
})
t.Run("missing header", func(t *testing.T) {
resp := &http.Response{Header: http.Header{}}
if got := signedSessionRetryAfterSeconds(resp); got != 0 {
t.Errorf("got %d, want 0", got)
}
})
}
func TestNormalizeSignedSessionRecordScope(t *testing.T) {
config := SignedSessionConfig{Namespace: "Tidal", BaseURL: "https://a.example.com", AppVersion: "1.0", Platform: "mobile"}
t.Run("first save just stamps the scope", func(t *testing.T) {
record := &signedSessionRecord{SessionID: "s1", SessionSecret: "secret"}
normalizeSignedSessionRecordScope(config, record)
if record.Namespace != "tidal" || record.BaseURL != config.BaseURL {
t.Errorf("scope not stamped: %+v", record)
}
if record.SessionID != "s1" || record.SessionSecret != "secret" {
t.Errorf("session fields should survive first stamp: %+v", record)
}
})
t.Run("same scope preserves the session", func(t *testing.T) {
record := &signedSessionRecord{
Namespace: "tidal", BaseURL: config.BaseURL, AppVersion: config.AppVersion, Platform: config.Platform,
SessionID: "s1", SessionSecret: "secret", ExpiresAt: "later",
}
normalizeSignedSessionRecordScope(config, record)
if record.SessionID != "s1" || record.SessionSecret != "secret" || record.ExpiresAt != "later" {
t.Errorf("unexpected wipe on matching scope: %+v", record)
}
})
t.Run("changed scope wipes the session secret", func(t *testing.T) {
record := &signedSessionRecord{
Namespace: "tidal", BaseURL: "https://old.example.com", AppVersion: config.AppVersion, Platform: config.Platform,
SessionID: "s1", SessionSecret: "secret", ExpiresAt: "later",
}
normalizeSignedSessionRecordScope(config, record)
if record.SessionID != "" || record.SessionSecret != "" || record.ExpiresAt != "" {
t.Errorf("expected session fields to be wiped after scope change: %+v", record)
}
if record.BaseURL != config.BaseURL {
t.Errorf("BaseURL not updated to new scope: %q", record.BaseURL)
}
})
}
func newSignedSessionTestRuntime(t *testing.T, extensionID string, transport roundTripFunc) *extensionRuntime {
t.Helper()
dataDir := t.TempDir()
return &extensionRuntime{
extensionID: extensionID,
manifest: &ExtensionManifest{Name: extensionID},
dataDir: dataDir,
vm: goja.New(),
httpClient: &http.Client{Transport: transport},
}
}
func TestSignedSessionFilePathDeterminism(t *testing.T) {
runtime := newSignedSessionTestRuntime(t, "tidal-ext", nil)
configA := SignedSessionConfig{Namespace: "tidal", BaseURL: "https://a.example.com"}
configB := SignedSessionConfig{Namespace: "tidal", BaseURL: "https://b.example.com"}
pathA1, err := runtime.signedSessionFilePath(configA)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
pathA2, err := runtime.signedSessionFilePath(configA)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pathA1 != pathA2 {
t.Errorf("same config produced different paths: %q vs %q", pathA1, pathA2)
}
pathB, err := runtime.signedSessionFilePath(configB)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pathA1 == pathB {
t.Errorf("different baseUrl scopes collided on the same file: %q", pathA1)
}
if _, err := runtime.signedSessionFilePath(SignedSessionConfig{Namespace: ""}); err == nil {
t.Error("expected error for empty namespace")
}
}
func TestLoadAndSaveSignedSessionRoundTrip(t *testing.T) {
runtime := newSignedSessionTestRuntime(t, "tidal-ext", nil)
config := SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
record, err := runtime.loadSignedSession(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if record.InstallID == "" {
t.Fatal("expected a generated install_id on first load")
}
path, err := runtime.signedSessionFilePath(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("expected session file to be persisted: %v", err)
}
// Windows does not preserve Unix permission bits, so only assert on POSIX.
if goruntime.GOOS != "windows" {
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("session file perm = %o, want 0600", perm)
}
}
record.SessionID = "sess-1"
record.SessionSecret = "top-secret"
record.ExpiresAt = "2030-01-01T00:00:00Z"
if err := runtime.saveSignedSession(config, record); err != nil {
t.Fatalf("unexpected error saving: %v", err)
}
reloaded, err := runtime.loadSignedSession(config)
if err != nil {
t.Fatalf("unexpected error reloading: %v", err)
}
if reloaded.InstallID != record.InstallID {
t.Errorf("install_id changed across reload: %q vs %q", reloaded.InstallID, record.InstallID)
}
if reloaded.SessionID != "sess-1" || reloaded.SessionSecret != "top-secret" || reloaded.ExpiresAt != "2030-01-01T00:00:00Z" {
t.Errorf("session fields did not round-trip: %+v", reloaded)
}
}
func TestSignedSessionStatusAndClear(t *testing.T) {
runtime := newSignedSessionTestRuntime(t, "tidal-ext", nil)
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
readStatus := func() map[string]any {
v := runtime.signedSessionStatus(goja.FunctionCall{})
return v.Export().(map[string]any)
}
if status := readStatus(); status["authenticated"] != false {
t.Fatalf("expected unauthenticated before any grant, got %+v", status)
}
config := signedSessionConfigWithDefaults(runtime.manifest.SignedSession)
record, err := runtime.loadSignedSession(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
record.SessionID = "sess-1"
record.SessionSecret = "secret"
record.ExpiresAt = time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
if err := runtime.saveSignedSession(config, record); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status := readStatus(); status["authenticated"] != true {
t.Fatalf("expected authenticated after saving a live session, got %+v", status)
}
record.ExpiresAt = time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
if err := runtime.saveSignedSession(config, record); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status := readStatus(); status["authenticated"] != false {
t.Fatalf("expected expired session to report unauthenticated, got %+v", status)
}
// Restore a live session, then confirm clear() wipes it.
record.ExpiresAt = time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
if err := runtime.saveSignedSession(config, record); err != nil {
t.Fatalf("unexpected error: %v", err)
}
clearResult := runtime.signedSessionClear(goja.FunctionCall{}).Export().(map[string]any)
if clearResult["success"] != true {
t.Fatalf("expected clear to succeed, got %+v", clearResult)
}
if status := readStatus(); status["authenticated"] != false {
t.Fatalf("expected unauthenticated after clear, got %+v", status)
}
}
// TestDoSignedSessionRequestSignature is the highest-value test in this file:
// it recomputes the HMAC-SHA256 rolling-key signature server-side from the
// headers the client actually sent, the same way a real backend would, to
// guard against silent regressions in the signing scheme (field order,
// rolling-key derivation, or header names).
func TestDoSignedSessionRequestSignature(t *testing.T) {
const sessionSecret = "shhh-its-a-secret"
const sessionID = "sess-42"
var capturedErr string
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
config := signedSessionConfigWithDefaults(&SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"})
prefix := config.HeaderPrefix
ts := req.Header.Get(prefix + "Timestamp")
nonce := req.Header.Get(prefix + "Nonce")
bodyHash := req.Header.Get(prefix + "Body-SHA256")
gotSig := req.Header.Get(prefix + "Signature")
gotSession := req.Header.Get(prefix + "Session")
if gotSession != sessionID {
capturedErr = "unexpected session id header: " + gotSession
}
bodyBytes, _ := io.ReadAll(req.Body)
wantBodyHashBytes := sha256.Sum256(bodyBytes)
wantBodyHash := hex.EncodeToString(wantBodyHashBytes[:])
if bodyHash != wantBodyHash {
capturedErr = "body hash mismatch"
}
parsedTs, err := time.Parse("2006-01-02T15:04:05.000Z", ts)
if err != nil {
capturedErr = "bad timestamp: " + err.Error()
}
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
rollingInput := fmt.Sprintf("%d:%s", window, sessionID)
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(sessionSecret), []byte(rollingInput)))
signingInput := strings.Join([]string{
config.SchemeLabel,
req.Method,
req.URL.EscapedPath(),
"",
bodyHash,
ts,
nonce,
sessionID,
config.AppVersion,
config.Platform,
}, "\n")
wantSig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
if !hmac.Equal([]byte(gotSig), []byte(wantSig)) {
capturedErr = "signature mismatch: got " + gotSig + " want " + wantSig
}
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
Request: req,
}, nil
})
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
config := signedSessionConfigWithDefaults(&SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"})
record := &signedSessionRecord{InstallID: "install-1", SessionID: sessionID, SessionSecret: sessionSecret}
resp, body, _, err := runtime.doSignedSessionRequest(config, record, http.MethodPost, "/tracks/search", []byte(`{"q":"test"}`), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedErr != "" {
t.Fatalf("signature verification failed: %s", capturedErr)
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if string(body) != `{"ok":true}` {
t.Errorf("body = %q", body)
}
}
func TestSignedSessionFetchUnauthenticatedTriggersVerification(t *testing.T) {
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/bootstrap") {
payload := signedSessionExchangeResponse{AuthURL: "https://auth.example.com/login?state=abc"}
body, _ := json.Marshal(payload)
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(body))),
Request: req,
}, nil
}
t.Fatalf("unexpected request to %s", req.URL.String())
return nil, nil
})
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
call := goja.FunctionCall{Arguments: []goja.Value{runtime.vm.ToValue("GET"), runtime.vm.ToValue("/tracks/search")}}
result := runtime.signedSessionFetch(call).Export().(map[string]any)
if result["ok"] != false {
t.Fatalf("expected ok=false when unauthenticated, got %+v", result)
}
if result["needsVerification"] != true {
t.Fatalf("expected needsVerification=true, got %+v", result)
}
if result["auth_url"] != "https://auth.example.com/login?state=abc" {
t.Fatalf("unexpected auth_url: %+v", result)
}
}
func TestSignedSessionFetchRevokesSessionOn401(t *testing.T) {
calls := 0
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
switch {
case strings.Contains(req.URL.Path, "/bootstrap"):
payload := signedSessionExchangeResponse{AuthURL: "https://auth.example.com/login"}
body, _ := json.Marshal(payload)
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
default:
return &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
config := SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
runtime.manifest.SignedSession = &config
resolved := signedSessionConfigWithDefaults(&config)
record, err := runtime.loadSignedSession(resolved)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
record.SessionID = "sess-1"
record.SessionSecret = "secret"
record.ExpiresAt = time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
if err := runtime.saveSignedSession(resolved, record); err != nil {
t.Fatalf("unexpected error: %v", err)
}
call := goja.FunctionCall{Arguments: []goja.Value{runtime.vm.ToValue("GET"), runtime.vm.ToValue("/tracks/search")}}
result := runtime.signedSessionFetch(call).Export().(map[string]any)
if result["ok"] != false || result["needsVerification"] != true {
t.Fatalf("expected a verification-required response after 401, got %+v", result)
}
reloaded, err := runtime.loadSignedSession(resolved)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reloaded.SessionID != "" || reloaded.SessionSecret != "" {
t.Fatalf("expected session to be wiped after 401, got %+v", reloaded)
}
}
func TestExchangeSignedSessionGrant(t *testing.T) {
t.Run("success stores the exchanged session", func(t *testing.T) {
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.HasSuffix(req.URL.Path, "/session/exchange") {
t.Fatalf("unexpected path: %s", req.URL.Path)
}
payload := signedSessionExchangeResponse{SessionID: "sess-9", SessionSecret: "secret-9", ExpiresAt: "2030-01-01T00:00:00Z"}
body, _ := json.Marshal(payload)
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
})
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
if err := runtime.exchangeSignedSessionGrant("grant-token"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
config := signedSessionConfigWithDefaults(runtime.manifest.SignedSession)
record, err := runtime.loadSignedSession(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if record.SessionID != "sess-9" || record.SessionSecret != "secret-9" {
t.Fatalf("session was not persisted after exchange: %+v", record)
}
})
t.Run("non-2xx response is surfaced as an error", func(t *testing.T) {
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 400, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
})
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
if err := runtime.exchangeSignedSessionGrant("bad-grant"); err == nil {
t.Fatal("expected an error for a non-2xx exchange response")
}
})
}
func TestSetPendingSignedSessionGrant(t *testing.T) {
pendingSignedSessionGrantsMu.Lock()
pendingSignedSessionGrants = make(map[string]string)
pendingSignedSessionGrantsMu.Unlock()
setPendingSignedSessionGrant(" ext-a ", " grant-1 ")
pendingSignedSessionGrantsMu.Lock()
got := pendingSignedSessionGrants["ext-a"]
pendingSignedSessionGrantsMu.Unlock()
if got != "grant-1" {
t.Fatalf("expected trimmed grant to be stored, got %q", got)
}
setPendingSignedSessionGrant("", "grant-2")
setPendingSignedSessionGrant("ext-b", "")
pendingSignedSessionGrantsMu.Lock()
_, hasEmptyExt := pendingSignedSessionGrants[""]
_, hasEmptyGrant := pendingSignedSessionGrants["ext-b"]
pendingSignedSessionGrantsMu.Unlock()
if hasEmptyExt || hasEmptyGrant {
t.Fatal("expected empty extensionID/grant pairs to be ignored")
}
}
func TestRefreshSignedSession(t *testing.T) {
t.Run("updates changed fields and persists them", func(t *testing.T) {
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.HasSuffix(req.URL.Path, "/session/refresh") {
t.Fatalf("unexpected path: %s", req.URL.Path)
}
payload := signedSessionExchangeResponse{SessionSecret: "rotated-secret", ExpiresAt: "2031-01-01T00:00:00Z"}
body, _ := json.Marshal(payload)
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
})
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
config := signedSessionConfigWithDefaults(&SignedSessionConfig{
Namespace: "tidal", BaseURL: "https://auth.example.com",
Endpoints: SignedSessionEndpoints{Refresh: "/session/refresh"},
})
record := &signedSessionRecord{InstallID: "install-1", SessionID: "sess-1", SessionSecret: "old-secret", ExpiresAt: "2030-01-01T00:00:00Z"}
if err := runtime.refreshSignedSession(config, record); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if record.SessionSecret != "rotated-secret" || record.ExpiresAt != "2031-01-01T00:00:00Z" {
t.Fatalf("refreshed fields not applied in-memory: %+v", record)
}
if record.SessionID != "sess-1" {
t.Fatalf("session id should be untouched when the response omits it: %q", record.SessionID)
}
reloaded, err := runtime.loadSignedSession(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reloaded.SessionSecret != "rotated-secret" {
t.Fatalf("refresh was not persisted to disk: %+v", reloaded)
}
})
t.Run("non-2xx response is surfaced as an error", func(t *testing.T) {
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
})
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
config := signedSessionConfigWithDefaults(&SignedSessionConfig{
Namespace: "tidal", BaseURL: "https://auth.example.com",
Endpoints: SignedSessionEndpoints{Refresh: "/session/refresh"},
})
record := &signedSessionRecord{InstallID: "install-1", SessionID: "sess-1", SessionSecret: "old-secret"}
if err := runtime.refreshSignedSession(config, record); err == nil {
t.Fatal("expected an error for a non-2xx refresh response")
}
})
}
func TestSignedSessionCompleteGrant(t *testing.T) {
t.Run("uses the grant argument when provided", func(t *testing.T) {
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
payload := signedSessionExchangeResponse{SessionID: "sess-arg", SessionSecret: "secret-arg", ExpiresAt: "2030-01-01T00:00:00Z"}
body, _ := json.Marshal(payload)
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
})
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
call := goja.FunctionCall{Arguments: []goja.Value{runtime.vm.ToValue("grant-from-arg")}}
result := runtime.signedSessionCompleteGrant(call).Export().(map[string]any)
if result["success"] != true {
t.Fatalf("expected success, got %+v", result)
}
})
t.Run("falls back to a pending grant registered out of band", func(t *testing.T) {
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
payload := signedSessionExchangeResponse{SessionID: "sess-pending", SessionSecret: "secret-pending", ExpiresAt: "2030-01-01T00:00:00Z"}
body, _ := json.Marshal(payload)
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
})
runtime := newSignedSessionTestRuntime(t, "tidal-ext-pending", transport)
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
setPendingSignedSessionGrant(runtime.extensionID, "pending-grant")
result := runtime.signedSessionCompleteGrant(goja.FunctionCall{}).Export().(map[string]any)
if result["success"] != true {
t.Fatalf("expected success, got %+v", result)
}
pendingSignedSessionGrantsMu.Lock()
_, stillPending := pendingSignedSessionGrants[runtime.extensionID]
pendingSignedSessionGrantsMu.Unlock()
if stillPending {
t.Fatal("expected the pending grant to be consumed after use")
}
})
t.Run("no grant available reports failure", func(t *testing.T) {
runtime := newSignedSessionTestRuntime(t, "tidal-ext-none", nil)
result := runtime.signedSessionCompleteGrant(goja.FunctionCall{}).Export().(map[string]any)
if result["success"] != false {
t.Fatalf("expected failure without a grant, got %+v", result)
}
})
}
func TestBuildSignedSessionChallengeURL(t *testing.T) {
config := signedSessionConfigWithDefaults(&SignedSessionConfig{
Namespace: "tidal",
BaseURL: "https://auth.example.com",
CallbackURL: "spotiflac://session-grant",
})
runtime := newSignedSessionTestRuntime(t, "tidal-ext", nil)
got := runtime.buildSignedSessionChallengeURL(config, "chal-123")
if !strings.HasPrefix(got, "https://auth.example.com/challenge?") {
t.Fatalf("unexpected base URL: %q", got)
}
if !strings.Contains(got, "id=chal-123") {
t.Fatalf("expected challenge id in query: %q", got)
}
if !strings.Contains(got, "cb=spotiflac%3A%2F%2Fsession-grant") {
t.Fatalf("expected encoded callback URL in query: %q", got)
}
}
+108
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"path/filepath"
"strconv"
"testing"
"time"
@@ -76,6 +77,43 @@ func TestParseManifest_MissingName(t *testing.T) {
}
}
func TestParseManifestRejectsUnsafeExtensionIDs(t *testing.T) {
for _, name := range []string{"../escape", `..\\escape`, "/absolute", "UpperCase", "."} {
manifest := `{"name":` + strconv.Quote(name) + `,"version":"1.0.0","description":"test","type":["metadata_provider"]}`
if _, err := ParseManifest([]byte(manifest)); err == nil {
t.Fatalf("expected unsafe extension ID %q to be rejected", name)
}
}
}
func TestManifestPrivilegedCapabilitiesRequirePermissions(t *testing.T) {
rawFFmpeg := &ExtensionManifest{
Name: "raw-ffmpeg",
Version: "1.0.0",
Description: "test",
Types: []ExtensionType{ExtensionTypeDownloadProvider},
Capabilities: map[string]any{"rawFfmpeg": true},
}
if err := rawFFmpeg.Validate(); err == nil {
t.Fatal("expected rawFfmpeg without file permission to be rejected")
}
signedSession := &ExtensionManifest{
Name: "signed-session",
Version: "1.0.0",
Description: "test",
Types: []ExtensionType{ExtensionTypeDownloadProvider},
Permissions: ExtensionPermissions{Network: []string{"api.example.com"}},
SignedSession: &SignedSessionConfig{
Namespace: "test",
BaseURL: "https://api.example.com",
},
}
if err := signedSession.Validate(); err == nil {
t.Fatal("expected signedSession without storage permission to be rejected")
}
}
func TestParseManifest_MissingType(t *testing.T) {
invalidManifest := `{
"name": "test-provider",
@@ -329,6 +367,7 @@ func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
initDownloadCancel("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
@@ -340,6 +379,12 @@ func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
}
req = runtime.bindDownloadCancelContext(req)
cancelMu.Lock()
refs := cancelMap["test-item"].refs
cancelMu.Unlock()
if refs != 1 {
t.Fatalf("binding a request leaked a cancellation reference: %d", refs)
}
cancelDownload("test-item")
select {
@@ -365,6 +410,7 @@ func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
initDownloadCancel("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
@@ -415,12 +461,19 @@ func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) {
runtime.setActiveRequestID(requestID)
defer runtime.clearActiveRequestID()
initExtensionRequestCancel(requestID)
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
extensionRequestCancelMu.Lock()
refs := extensionRequestCancelMap[requestID].refs
extensionRequestCancelMu.Unlock()
if refs != 1 {
t.Fatalf("binding a request leaked a cancellation reference: %d", refs)
}
cancelExtensionRequest(requestID)
select {
@@ -466,6 +519,61 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
}
}
func TestExtensionRuntimeAPIsRequireDeclaredPermissions(t *testing.T) {
withoutPermissions := &loadedExtension{
ID: "no-permissions",
Manifest: &ExtensionManifest{Name: "no-permissions"},
DataDir: t.TempDir(),
}
vm := goja.New()
newExtensionRuntime(withoutPermissions).RegisterAPIs(vm)
for _, api := range []string{"storage", "credentials", "auth", "session", "file", "ffmpeg"} {
if value := vm.Get(api); value != nil && !goja.IsUndefined(value) {
t.Fatalf("%s API was exposed without permission", api)
}
}
withPermissions := &loadedExtension{
ID: "with-permissions",
Manifest: &ExtensionManifest{
Name: "with-permissions",
Permissions: ExtensionPermissions{Storage: true, File: true},
Capabilities: map[string]any{"rawFfmpeg": true},
},
DataDir: t.TempDir(),
}
vm = goja.New()
newExtensionRuntime(withPermissions).RegisterAPIs(vm)
for _, api := range []string{"storage", "credentials", "auth", "file", "ffmpeg"} {
if value := vm.Get(api); value == nil || goja.IsUndefined(value) {
t.Fatalf("%s API was not exposed with permission", api)
}
}
}
func TestValidatePostProcessResultRestrictsReplacementTargets(t *testing.T) {
workDir := t.TempDir()
input := PostProcessInput{Path: filepath.Join(workDir, "input.flac"), URI: "content://input"}
ext := &loadedExtension{
ID: "post-process",
Manifest: &ExtensionManifest{Name: "post-process"},
DataDir: t.TempDir(),
}
if err := validatePostProcessResult(ext, input, &PostProcessResult{NewFilePath: input.Path, NewFileURI: input.URI}); err != nil {
t.Fatalf("unchanged target should be accepted: %v", err)
}
if err := validatePostProcessResult(ext, input, &PostProcessResult{NewFilePath: filepath.Join(workDir, "output.flac")}); err == nil {
t.Fatal("replacement path should require file permission")
}
ext.Manifest.Permissions.File = true
if err := validatePostProcessResult(ext, input, &PostProcessResult{NewFilePath: filepath.Join(workDir, "output.flac")}); err != nil {
t.Fatalf("sibling replacement should be accepted with file permission: %v", err)
}
if err := validatePostProcessResult(ext, input, &PostProcessResult{NewFileURI: "content://other"}); err == nil {
t.Fatal("replacement URI should be rejected")
}
}
func TestIsPrivateIP(t *testing.T) {
tests := []struct {
host string
+48 -14
View File
@@ -11,22 +11,35 @@ import (
)
type JSExecutionError struct {
Message string
IsTimeout bool
Message string
IsTimeout bool
RuntimeUnsafe bool
Cause error
}
func (e *JSExecutionError) Error() string {
return e.Message
}
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
return RunWithTimeoutContext(context.Background(), vm, script, timeout)
func (e *JSExecutionError) Unwrap() error {
return e.Cause
}
var jsInterruptGracePeriod = 5 * time.Second
func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
return runGojaCallWithTimeoutContext(ctx, vm, func() (goja.Value, error) {
return vm.RunString(script)
}, timeout)
}
func runGojaCallWithTimeoutContext(ctx context.Context, vm *goja.Runtime, call func() (goja.Value, error), timeout time.Duration) (goja.Value, error) {
if vm == nil {
return nil, fmt.Errorf("extension runtime unavailable")
}
if call == nil {
return nil, fmt.Errorf("extension call unavailable")
}
if timeout <= 0 {
timeout = DefaultJSTimeout
@@ -66,7 +79,7 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
}
}()
val, err := vm.RunString(script)
val, err := call()
resultCh <- result{val, err}
}()
@@ -91,27 +104,29 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
// caller will access the VM concurrently and crash with a nil
// pointer dereference.
select {
case res := <-resultCh:
case <-resultCh:
if cancelled {
return nil, ErrExtensionRequestCancelled
}
if res.err != nil {
return nil, res.err
}
return nil, &JSExecutionError{
Message: "execution timeout exceeded",
IsTimeout: true,
}
case <-time.After(60 * time.Second):
case <-time.After(jsInterruptGracePeriod):
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
// Log a warning — the VM should NOT be reused after this.
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
message := "execution timeout exceeded (runtime quarantined)"
var cause error
if cancelled {
return nil, ErrExtensionRequestCancelled
message = "extension request cancelled (runtime quarantined)"
cause = ErrExtensionRequestCancelled
}
return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)",
IsTimeout: true,
Message: message,
IsTimeout: !cancelled,
RuntimeUnsafe: true,
Cause: cause,
}
}
}
@@ -126,13 +141,32 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura
func RunWithTimeoutContextAndRecover(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
result, err := RunWithTimeoutContext(ctx, vm, script, timeout)
if vm != nil {
if vm != nil && !IsRuntimeUnsafeError(err) {
vm.ClearInterrupt()
}
return result, err
}
func runGojaCallWithTimeoutAndRecover(vm *goja.Runtime, call func() (goja.Value, error), timeout time.Duration) (goja.Value, error) {
return runGojaCallWithTimeoutContextAndRecover(context.Background(), vm, call, timeout)
}
func runGojaCallWithTimeoutContextAndRecover(ctx context.Context, vm *goja.Runtime, call func() (goja.Value, error), timeout time.Duration) (goja.Value, error) {
result, err := runGojaCallWithTimeoutContext(ctx, vm, call, timeout)
if vm != nil && !IsRuntimeUnsafeError(err) {
vm.ClearInterrupt()
}
return result, err
}
func IsRuntimeUnsafeError(err error) bool {
jsErr, ok := err.(*JSExecutionError)
return ok && jsErr.RuntimeUnsafe
}
func IsTimeoutError(err error) bool {
if jsErr, ok := err.(*JSExecutionError); ok {
return jsErr.IsTimeout
+59 -18
View File
@@ -13,11 +13,13 @@ import (
var (
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
multiUnderscore = regexp.MustCompile(`_+`)
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}`)
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
yearPattern = regexp.MustCompile(`\d{4}`)
)
const maxSanitizedFilenameBytes = 200
func sanitizeFilename(filename string) string {
sanitized := strings.ReplaceAll(filename, "/", " ")
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
@@ -47,8 +49,8 @@ func sanitizeFilename(filename string) string {
sanitized = strings.ToValidUTF8(sanitized, "_")
}
if len(sanitized) > 200 {
sanitized = truncateUTF8Bytes(sanitized, 200)
if len(sanitized) > maxSanitizedFilenameBytes {
sanitized = truncateUTF8Bytes(sanitized, maxSanitizedFilenameBytes)
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
sanitized = strings.Trim(sanitized, "_ ")
}
@@ -60,6 +62,29 @@ func sanitizeFilename(filename string) string {
return sanitized
}
func sanitizeFilenamePreservingToken(filename string, token string) string {
sanitized := sanitizeFilename(filename)
token = strings.TrimSpace(token)
if token == "" || !strings.Contains(filename, token) || strings.Contains(sanitized, token) {
return sanitized
}
safeToken := sanitizeFilename(token)
suffix := " - " + safeToken
prefixLimit := maxSanitizedFilenameBytes - len(suffix)
if prefixLimit <= 0 {
return truncateUTF8Bytes(safeToken, maxSanitizedFilenameBytes)
}
rawPrefix := strings.Trim(strings.ReplaceAll(filename, token, ""), " _-")
prefix := sanitizeFilename(rawPrefix)
prefix = truncateUTF8Bytes(prefix, prefixLimit)
prefix = strings.TrimSpace(strings.Trim(prefix, ". _-"))
if prefix == "" || prefix == "Unknown" {
return safeToken
}
return prefix + suffix
}
func truncateUTF8Bytes(value string, maxBytes int) string {
if maxBytes <= 0 || len(value) <= maxBytes {
return value
@@ -79,7 +104,7 @@ func truncateUTF8Bytes(value string, maxBytes int) string {
return value
}
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
func buildFilenameFromTemplate(template string, metadata map[string]any) string {
if template == "" {
template = "{artist} - {title}"
}
@@ -94,15 +119,22 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
}
placeholders := map[string]string{
"{title}": getString(metadata, "title"),
"{artist}": getString(metadata, "artist"),
"{album}": getString(metadata, "album"),
"{track}": formatTrackNumber(getInt(metadata, "track")),
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
"{year}": yearValue,
"{date}": dateValue,
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
"{title}": getString(metadata, "title"),
"{artist}": getString(metadata, "artist"),
"{album}": getString(metadata, "album"),
"{track}": formatTrackNumber(getInt(metadata, "track")),
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
"{playlist_position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlist position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlistPosition}": formatTrackNumber(getPlaylistPosition(metadata)),
"{position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlist_position_raw}": formatRawNumber(getPlaylistPosition(metadata)),
"{year}": yearValue,
"{date}": dateValue,
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
"{quality}": getString(metadata, "quality"),
"{quality_variant}": getString(metadata, "quality_variant"),
}
for placeholder, value := range placeholders {
@@ -112,7 +144,7 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
return result
}
func replaceFormattedNumberPlaceholders(template string, metadata map[string]interface{}) string {
func replaceFormattedNumberPlaceholders(template string, metadata map[string]any) string {
return formattedNumberPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
parts := formattedNumberPlaceholderExpr.FindStringSubmatch(match)
if len(parts) != 3 {
@@ -120,6 +152,9 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]int
}
number := getInt(metadata, parts[1])
if parts[1] == "playlist_position" || parts[1] == "playlistPosition" || parts[1] == "position" {
number = getPlaylistPosition(metadata)
}
width, err := strconv.Atoi(parts[2])
if err != nil {
return ""
@@ -129,7 +164,7 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]int
})
}
func replaceDateFormatPlaceholders(template string, metadata map[string]interface{}) string {
func replaceDateFormatPlaceholders(template string, metadata map[string]any) string {
return dateFormatPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
parts := dateFormatPlaceholderExpr.FindStringSubmatch(match)
if len(parts) != 2 {
@@ -140,7 +175,7 @@ func replaceDateFormatPlaceholders(template string, metadata map[string]interfac
})
}
func getDateValue(metadata map[string]interface{}) string {
func getDateValue(metadata map[string]any) string {
date := getString(metadata, "date")
if date != "" {
return date
@@ -154,7 +189,7 @@ func getDateValue(metadata map[string]interface{}) string {
return getString(metadata, "year")
}
func getString(m map[string]interface{}, key string) string {
func getString(m map[string]any, key string) string {
if v, ok := m[key]; ok {
switch value := v.(type) {
case string:
@@ -170,13 +205,15 @@ func getString(m map[string]interface{}, key string) string {
return ""
}
func getInt(m map[string]interface{}, key string) int {
func getInt(m map[string]any, key string) int {
candidateKeys := []string{key}
switch key {
case "track":
candidateKeys = append(candidateKeys, "track_number")
case "disc":
candidateKeys = append(candidateKeys, "disc_number")
case "playlist_position", "playlistPosition", "playlist position", "position":
candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position")
}
for _, candidate := range candidateKeys {
@@ -200,6 +237,10 @@ func getInt(m map[string]interface{}, key string) int {
return 0
}
func getPlaylistPosition(metadata map[string]any) int {
return getInt(metadata, "playlist_position")
}
func formatTrackNumber(n int) string {
if n <= 0 {
return ""
+85 -5
View File
@@ -7,7 +7,7 @@ import (
)
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
metadata := map[string]interface{}{
metadata := map[string]any{
"title": "Song Name",
"artist": "Artist Name",
"album": "Album Name",
@@ -28,7 +28,7 @@ func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
}
func TestBuildFilenameFromTemplate_RawPlaceholdersEmptyWhenZero(t *testing.T) {
metadata := map[string]interface{}{
metadata := map[string]any{
"title": "Song Name",
"artist": "Artist Name",
"track": 0,
@@ -43,7 +43,7 @@ func TestBuildFilenameFromTemplate_RawPlaceholdersEmptyWhenZero(t *testing.T) {
}
func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
metadata := map[string]interface{}{
metadata := map[string]any{
"track": 3,
"disc": 2,
}
@@ -55,8 +55,88 @@ func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
}
}
func TestBuildFilenameFromTemplate_PlaylistPositionFormatting(t *testing.T) {
metadata := map[string]any{
"playlist_position": 4,
"artist": "Artist Name",
"title": "Song Name",
}
formatted := buildFilenameFromTemplate(
"{playlist_position:02} - {artist} - {title}",
metadata,
)
expected := "04 - Artist Name - Song Name"
if formatted != expected {
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestBuildFilenameFromTemplate_QualityVariant(t *testing.T) {
metadata := map[string]any{
"artist": "Artist Name",
"title": "Song Name",
"quality": "HI_RES_LOSSLESS",
}
formatted := buildFilenameFromTemplate(
"{artist} - {title} - {quality}",
metadata,
)
if formatted != "Artist Name - Song Name - HI_RES_LOSSLESS" {
t.Fatalf("unexpected quality filename: %q", formatted)
}
}
func TestBuildFilenameFromTemplate_QualityVariantStagingToken(t *testing.T) {
metadata := map[string]any{
"artist": "Artist Name",
"title": "Song Name",
"quality_variant": "qv_12345678",
}
formatted := buildFilenameFromTemplate(
"{artist} - {title} - {quality_variant}",
metadata,
)
if formatted != "Artist Name - Song Name - qv_12345678" {
t.Fatalf("unexpected quality variant filename: %q", formatted)
}
}
func TestBuildDownloadFilename_ProvidesRequestedQuality(t *testing.T) {
filename := buildDownloadFilename(DownloadRequest{
TrackName: "Song Name",
ArtistName: "Artist Name",
FilenameFormat: "{artist} - {title} - {quality}",
Quality: "LOSSLESS",
OutputExt: ".flac",
})
if filename != "Artist Name - Song Name - LOSSLESS.flac" {
t.Fatalf("unexpected download filename: %q", filename)
}
}
func TestBuildDownloadFilename_PreservesVariantTokenWhenTruncated(t *testing.T) {
filename := buildDownloadFilename(DownloadRequest{
TrackName: strings.Repeat("Very Long Song ", 30),
ArtistName: "Artist Name",
FilenameFormat: "{artist} - {title} - {quality_variant}",
QualityVariant: "qv_12345678",
OutputExt: ".flac",
})
if !strings.Contains(filename, "qv_12345678") {
t.Fatalf("quality variant token was truncated: %q", filename)
}
if len(strings.TrimSuffix(filename, ".flac")) > maxSanitizedFilenameBytes {
t.Fatalf("filename base exceeds limit: %d bytes", len(filename))
}
}
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
metadata := map[string]interface{}{
metadata := map[string]any{
"artist": "Artist Name",
"title": "Song Name",
"release_date": "2024-03-09",
@@ -75,7 +155,7 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
}
func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing.T) {
metadata := map[string]interface{}{
metadata := map[string]any{
"artist": "Artist Name",
"title": "Song Name",
"date": "2019",
+69
View File
@@ -0,0 +1,69 @@
package gobackend
import (
"fmt"
"os"
"path/filepath"
"strings"
flac "github.com/go-flac/go-flac/v2"
)
// saveFlacFile persists an edited FLAC crash-safely. go-flac's Save(samePath)
// rewrites the file in place by shifting the audio body within the same inode,
// so a process kill or power loss mid-save destroys the file with no recovery
// copy. Instead, stream the whole edited file to a sibling temp, fsync it, and
// rename it over the target: an interruption at any point leaves either the
// old intact file or the new complete one.
//
// fd-backed targets (/proc/self/fd/N) have no directory entry to rename over,
// so those keep the library's in-place path.
func saveFlacFile(f *flac.File, filePath string) error {
if strings.HasPrefix(filePath, "/proc/self/fd/") {
return f.Save(filePath)
}
// The ".partial" suffix keeps the temp invisible to library scans and
// extension duplicate checks; the extra ".tag" avoids colliding with the
// download staging sibling of the same final path.
tmpPath := filePath + ".tag.partial"
os.Remove(tmpPath)
tmp, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("failed to create temp tag file: %w", err)
}
// WriteTo streams the audio frames from the still-open source handle and
// closes it when done, so the rename below can replace the source even on
// Windows.
if _, err := f.WriteTo(tmp); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("failed to sync temp tag file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, filePath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to publish tagged file: %w", err)
}
syncDir(filepath.Dir(filePath))
return nil
}
// syncDir best-effort fsyncs a directory so a just-renamed entry survives
// power loss. Unsupported on some platforms/filesystems; errors are ignored.
func syncDir(dir string) {
d, err := os.Open(dir)
if err != nil {
return
}
_ = d.Sync()
_ = d.Close()
}
+17 -16
View File
@@ -1,29 +1,30 @@
module github.com/zarz/spotiflac_android/go_backend
go 1.25.0
toolchain go1.25.9
// Needs >= 1.26.3: fixes the cgo "bulkBarrierPreWrite" crash (golang/go#46893)
// without the 1.26.0-1.26.2 arm32 SIGSYS regression (golang/go#78936).
// Full patch version here because gomobile's bind module inherits it.
go 1.26.5
require (
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d
github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347
github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2
golang.org/x/crypto v0.53.0
golang.org/x/mobile v0.0.0-20260602190626-68735029466e
golang.org/x/net v0.56.0
golang.org/x/text v0.38.0
golang.org/x/crypto v0.54.0
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5
golang.org/x/net v0.57.0
golang.org/x/text v0.40.0
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/tools v0.45.0 // indirect
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect
github.com/klauspost/compress v1.19.1 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/tools v0.48.0 // indirect
)
+26 -26
View File
@@ -1,13 +1,13 @@
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347 h1:RZr+96+PKQjn444QL1K9MtncwJ/PwfE+3TJLCYJL8es=
github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
@@ -20,10 +20,10 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
@@ -32,21 +32,21 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e h1:YxPXu/HWDTcSSrzSX+sCltsfcNCa/ZYVG43oslMouNU=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
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-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
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/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+228 -135
View File
@@ -1,7 +1,10 @@
package gobackend
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
@@ -61,16 +64,27 @@ var (
networkCompatibilityOptions NetworkCompatibilityOptions
)
var transportDialer = &net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}
func transportDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
return dialWithDoHFallback(ctx, transportDialer, network, addr)
}
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
DialContext: transportDialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
// Downloads ride this transport; some extension providers prepare the
// file server-side before the first byte, so give TTFB more headroom
// than the API/metadata transports. The 60s stall watchdog still bounds
// dead transfers.
ResponseHeaderTimeout: 120 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
@@ -81,15 +95,13 @@ var sharedTransport = &http.Transport{
}
var extensionAPITransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
DialContext: transportDialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 45 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
@@ -100,21 +112,21 @@ var extensionAPITransport = &http.Transport{
}
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
DialContext: transportDialContext,
MaxIdleConns: 30,
MaxIdleConnsPerHost: 5,
MaxConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 45 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 32 * 1024,
ReadBufferSize: 32 * 1024,
DisableCompression: true,
// Metadata responses are JSON; transparent gzip cuts transfer size several
// times over. Downloads stay on sharedTransport with compression disabled.
DisableCompression: false,
TLSClientConfig: newTLSCompatibilityConfig(false),
}
@@ -123,11 +135,6 @@ var sharedClient = &http.Client{
Timeout: DefaultTimeout,
}
var downloadClient = &http.Client{
Transport: newCompatibilityTransport(sharedTransport),
Timeout: DownloadTimeout,
}
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{
Transport: newCompatibilityTransport(sharedTransport),
@@ -142,18 +149,11 @@ func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
}
}
func GetSharedClient() *http.Client {
return sharedClient
}
func GetDownloadClient() *http.Client {
return downloadClient
}
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
extensionAPITransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
closeUTLSIdleConnections()
}
func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
@@ -287,14 +287,16 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
if err != nil {
lastErr = err
if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") {
if isHardConnectivityBlock(err) {
return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP")
}
if attempt < config.MaxRetries {
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
attempt+1, config.MaxRetries+1, err, delay)
time.Sleep(delay)
if err := sleepRetry(req.Context(), delay); err != nil {
return nil, err
}
delay = calculateNextDelay(delay, config)
}
continue
@@ -313,7 +315,9 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
lastErr = fmt.Errorf("rate limited (429)")
if attempt < config.MaxRetries {
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
time.Sleep(delay)
if err := sleepRetry(req.Context(), delay); err != nil {
return nil, err
}
delay = calculateNextDelay(delay, config)
}
continue
@@ -338,14 +342,23 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
}
}
// No blocking marker: hand the caller back a readable body in
// place of the one consumed by the scan above.
resp.Body = io.NopCloser(bytes.NewReader(body))
}
if resp.StatusCode >= 500 {
resp.Body.Close()
if retryAfter := getRetryAfterDuration(resp); retryAfter > 0 {
delay = retryAfter
}
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
if attempt < config.MaxRetries {
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
time.Sleep(delay)
if err := sleepRetry(req.Context(), delay); err != nil {
return nil, err
}
delay = calculateNextDelay(delay, config)
}
continue
@@ -357,11 +370,39 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
}
// sleepRetry waits out a retry delay, aborting early when the request context
// is cancelled so a cancelled download never sits in a backoff sleep.
func sleepRetry(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// jitterFloat returns a fraction in [0,1); overridable in tests for
// deterministic backoff assertions.
var jitterFloat = rand.Float64
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
return min(nextDelay, config.MaxDelay)
capped := min(nextDelay, config.MaxDelay)
// Full jitter: spread retries between InitialDelay and the capped
// exponential ceiling to avoid synchronized thundering-herd retries.
if capped <= config.InitialDelay {
return capped
}
span := capped - config.InitialDelay
return config.InitialDelay + time.Duration(jitterFloat()*float64(span))
}
// maxRetryAfterDelay caps honored Retry-After values so a hostile or
// misconfigured server cannot park a retry loop for an hour.
const maxRetryAfterDelay = 2 * time.Minute
// Returns 0 if the header is missing or invalid so callers can keep their
// normal exponential backoff instead of stalling for an arbitrary minute.
func getRetryAfterDuration(resp *http.Response) time.Duration {
@@ -371,13 +412,13 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
}
if seconds, err := strconv.Atoi(retryAfter); err == nil {
return time.Duration(seconds) * time.Second
return min(time.Duration(seconds)*time.Second, maxRetryAfterDelay)
}
if t, err := http.ParseTime(retryAfter); err == nil {
duration := time.Until(t)
if duration > 0 {
return duration
return min(duration, maxRetryAfterDelay)
}
}
@@ -401,32 +442,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
return body, nil
}
func ValidateResponse(resp *http.Response) error {
if resp == nil {
return fmt.Errorf("response is nil")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}
return nil
}
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
msg := fmt.Sprintf("API %s failed", apiURL)
if statusCode > 0 {
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
}
if responsePreview != "" {
if len(responsePreview) > 100 {
responsePreview = responsePreview[:100] + "..."
}
msg += fmt.Sprintf(": %s", responsePreview)
}
return msg
}
type ISPBlockingError struct {
Domain string
Reason string
@@ -437,101 +452,179 @@ func (e *ISPBlockingError) Error() string {
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
}
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
// isTransientNetworkError reports retryable transport failures such as
// timeouts and temporary DNS errors. Permanent DNS misses are excluded.
func isTransientNetworkError(err error) bool {
if err == nil {
return nil
return false
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
var netErr net.Error
return errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary())
}
// isConnectivityFailure reports DNS, dial, timeout, TLS, or truncated transport
// errors. Application-level API messages are excluded.
func isConnectivityFailure(err error) bool {
return connectivityFailureReason(err) != ""
}
func connectivityFailureReason(err error) string {
if err == nil {
return ""
}
if errors.Is(err, context.DeadlineExceeded) {
return "Request timed out - ISP may be throttling"
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return "Connection closed unexpectedly - ISP may be blocking"
}
domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error())
var urlErr *url.Error
if errors.As(err, &urlErr) {
if urlErr.Timeout() {
return "Connection timed out - ISP may be blocking access"
}
if urlErr.Err != nil {
if reason := connectivityFailureReason(urlErr.Err); reason != "" {
return reason
}
}
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTemporary {
return &ISPBlockingError{
Domain: domain,
Reason: "DNS resolution failed - domain may be blocked by ISP",
OriginalErr: err,
}
if dnsErr.IsNotFound || dnsErr.IsTimeout || dnsErr.IsTemporary {
return "DNS resolution failed - domain may be blocked by ISP"
}
}
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Op == "dial" {
var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
switch syscallErr {
case syscall.ECONNREFUSED:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection refused - port may be blocked by ISP/firewall",
OriginalErr: err,
}
case syscall.ECONNRESET:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection reset - ISP may be intercepting traffic",
OriginalErr: err,
}
case syscall.ETIMEDOUT:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection timed out - ISP may be blocking access",
OriginalErr: err,
}
case syscall.ENETUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Network unreachable - ISP may be blocking route",
OriginalErr: err,
}
case syscall.EHOSTUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Host unreachable - ISP may be blocking destination",
OriginalErr: err,
}
}
if opErr.Timeout() {
return "Connection timed out - ISP may be blocking access"
}
var errno syscall.Errno
if errors.As(opErr.Err, &errno) {
switch errno {
case syscall.ECONNREFUSED:
return "Connection refused - port may be blocked by ISP/firewall"
case syscall.ECONNRESET:
return "Connection reset - ISP may be intercepting traffic"
case syscall.ETIMEDOUT:
return "Connection timed out - ISP may be blocking access"
case syscall.ENETUNREACH:
return "Network unreachable - ISP may be blocking route"
case syscall.EHOSTUNREACH:
return "Host unreachable - ISP may be blocking destination"
}
}
}
var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) {
return &ISPBlockingError{
Domain: domain,
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
OriginalErr: err,
return "TLS handshake failed - ISP may be intercepting HTTPS traffic"
}
var certErr x509.CertificateInvalidError
if errors.As(err, &certErr) {
return "Certificate error - ISP may be using MITM proxy"
}
var hostnameErr x509.HostnameError
if errors.As(err, &hostnameErr) {
return "Certificate error - ISP may be using MITM proxy"
}
var unknownAuth x509.UnknownAuthorityError
if errors.As(err, &unknownAuth) {
return "Certificate error - ISP may be using MITM proxy"
}
return ""
}
// isTLSHandshakeOrResetError reports TLS handshake/cert failures and TCP resets
// that should trigger a Chrome fingerprint retry.
func isTLSHandshakeOrResetError(err error) bool {
if err == nil {
return false
}
var recordErr *tls.RecordHeaderError
if errors.As(err, &recordErr) {
return true
}
var certErr x509.CertificateInvalidError
if errors.As(err, &certErr) {
return true
}
var hostnameErr x509.HostnameError
if errors.As(err, &hostnameErr) {
return true
}
var unknownAuth x509.UnknownAuthorityError
if errors.As(err, &unknownAuth) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) {
var errno syscall.Errno
if errors.As(opErr.Err, &errno) && errno == syscall.ECONNRESET {
return true
}
}
return false
}
blockingPatterns := []struct {
pattern string
reason string
}{
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
{"connection refused", "Connection refused - port may be blocked"},
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
{"certificate", "Certificate error - ISP may be using MITM proxy"},
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
// isHardConnectivityBlock reports transport failures that signal an active
// block (DNS not found, connection refused/reset, TLS/cert MITM) and should
// abort retries immediately. Timeouts and deadline-exceeded are treated as
// transient and excluded so they fall through to normal retry backoff.
func isHardConnectivityBlock(err error) bool {
if err == nil {
return false
}
for _, bp := range blockingPatterns {
if strings.Contains(errStr, bp.pattern) {
return &ISPBlockingError{
Domain: domain,
Reason: bp.reason,
OriginalErr: err,
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Timeout() {
return false
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return dnsErr.IsNotFound
}
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Timeout() {
return false
}
var errno syscall.Errno
if errors.As(opErr.Err, &errno) {
switch errno {
case syscall.ECONNREFUSED, syscall.ECONNRESET:
return true
}
}
}
return nil
return isTLSHandshakeOrResetError(err)
}
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil {
return nil
}
reason := connectivityFailureReason(err)
if reason == "" {
return nil
}
return &ISPBlockingError{
Domain: extractDomain(requestURL),
Reason: reason,
OriginalErr: err,
}
}
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
+2
View File
@@ -10,6 +10,8 @@ func GetCloudflareBypassClient() *http.Client {
return sharedClient
}
func closeUTLSIdleConnections() {}
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := sharedClient.Do(req)
+51
View File
@@ -0,0 +1,51 @@
package gobackend
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
)
func TestRetryHardening(t *testing.T) {
resp := &http.Response{Header: http.Header{"Retry-After": []string{"3600"}}}
if d := getRetryAfterDuration(resp); d != maxRetryAfterDelay {
t.Fatalf("Retry-After 3600s clamped to %v, want %v", d, maxRetryAfterDelay)
}
// A 403 without ISP-blocking markers must reach the caller with a
// readable body even though the marker scan consumed the original.
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 403,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("quota exceeded")),
Request: req,
}, nil
})}
got, err := DoRequestWithRetry(client, mustNewRequest(t, "https://example.com/x"), DefaultRetryConfig())
if err != nil || got.StatusCode != 403 {
t.Fatalf("DoRequestWithRetry = %#v/%v", got, err)
}
body, err := io.ReadAll(got.Body)
got.Body.Close()
if err != nil || string(body) != "quota exceeded" {
t.Fatalf("403 body = %q/%v, want restored body", body, err)
}
// Backoff sleeps must abort on context cancellation.
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()
start := time.Now()
if err := sleepRetry(ctx, 5*time.Second); err == nil {
t.Fatal("sleepRetry ignored cancellation")
}
if time.Since(start) > time.Second {
t.Fatal("sleepRetry did not abort promptly on cancel")
}
}
+18 -22
View File
@@ -1,13 +1,15 @@
package gobackend
import (
"context"
"crypto/x509"
"encoding/pem"
"errors"
"io"
"net"
"net/http"
"net/url"
"strings"
"syscall"
"testing"
"time"
)
@@ -24,9 +26,6 @@ func TestHTTPUtilityHelpers(t *testing.T) {
if NewHTTPClientWithTimeout(time.Second).Timeout != time.Second || NewMetadataHTTPClient(time.Second).Timeout != time.Second {
t.Fatal("client timeout mismatch")
}
if GetSharedClient() == nil || GetDownloadClient() == nil {
t.Fatal("expected shared clients")
}
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.RootCAs == nil {
t.Fatal("expected supplemental TLS root pool")
}
@@ -113,33 +112,33 @@ func TestHTTPUtilityHelpers(t *testing.T) {
if body, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader("ok"))}); err != nil || string(body) != "ok" {
t.Fatalf("ReadResponseBody = %q/%v", body, err)
}
if err := ValidateResponse(nil); err == nil {
t.Fatal("expected nil response validation error")
}
if err := ValidateResponse(&http.Response{StatusCode: 404, Status: "404 Not Found"}); err == nil {
t.Fatal("expected bad status validation error")
}
if err := ValidateResponse(&http.Response{StatusCode: 200}); err != nil {
t.Fatalf("ValidateResponse: %v", err)
}
if msg := BuildErrorMessage("api", 500, strings.Repeat("x", 120)); !strings.Contains(msg, "...") {
t.Fatalf("BuildErrorMessage = %q", msg)
}
origJitter := jitterFloat
jitterFloat = func() float64 { return 1 }
if calculateNextDelay(10*time.Millisecond, RetryConfig{BackoffFactor: 3, MaxDelay: 20 * time.Millisecond}) != 20*time.Millisecond {
t.Fatal("calculateNextDelay mismatch")
}
jitterFloat = origJitter
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
t.Fatal("invalid retry-after should be zero")
}
if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
resetErr := &net.OpError{Op: "read", Err: syscall.ECONNRESET}
if isp := IsISPBlocking(resetErr, "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
t.Fatalf("IsISPBlocking = %#v", isp)
}
if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") {
timeoutErr := &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT}
if !CheckAndLogISPBlocking(timeoutErr, "https://timeout.example/x", "test") {
t.Fatal("expected logged ISP blocking")
}
if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
refusedErr := &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}
if wrapped := WrapErrorWithISPCheck(refusedErr, "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
}
if !isTransientNetworkError(context.DeadlineExceeded) || isTransientNetworkError(&net.DNSError{IsNotFound: true}) {
t.Fatal("isTransientNetworkError mismatch")
}
if !isConnectivityFailure(&net.DNSError{IsNotFound: true}) || !isConnectivityFailure(context.DeadlineExceeded) {
t.Fatal("isConnectivityFailure mismatch")
}
if WrapErrorWithISPCheck(nil, "", "test") != nil {
t.Fatal("nil wrap should stay nil")
}
@@ -159,9 +158,6 @@ func TestRateLimiterHelpers(t *testing.T) {
if limiter.Available() != 0 {
t.Fatalf("available after acquire = %d", limiter.Available())
}
if GetSongLinkRateLimiter() == nil {
t.Fatal("expected global limiter")
}
}
func mustNewRequest(t *testing.T, rawURL string) *http.Request {
+129 -30
View File
@@ -4,27 +4,38 @@ package gobackend
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
utls "github.com/refraction-networking/utls"
"golang.org/x/net/http2"
)
// utlsSessionCache is shared by every uTLS handshake so TLS 1.3 tickets enable
// resumption (fewer round-trips) across requests and hosts.
var utlsSessionCache = utls.NewLRUClientSessionCache(0)
// utlsTransport dials with a Chrome TLS fingerprint and pools one healthy HTTP/2
// connection per host, re-dialing when it dies (e.g. after a network switch).
type utlsTransport struct {
dialer *net.Dialer
h2 *http2.Transport
mu sync.Mutex
conns map[string]*http2.ClientConn
}
func newUTLSTransport() *utlsTransport {
return &utlsTransport{
dialer: &net.Dialer{
Timeout: 30 * Second,
Timeout: 10 * Second,
KeepAlive: 30 * Second,
},
h2: &http2.Transport{},
conns: make(map[string]*http2.ClientConn),
}
}
@@ -34,48 +45,142 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
}
host := req.URL.Hostname()
port := t.getPort(req.URL)
addr := net.JoinHostPort(host, port)
addr := net.JoinHostPort(host, t.getPort(req.URL))
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
if cc := t.cachedConn(addr); cc != nil {
resp, err := cc.RoundTrip(req)
if err == nil {
return resp, nil
}
// A pooled conn can be silently dead after a network switch. Drop it
// and, when the request is safely repeatable, fall through to a fresh
// dial instead of failing where the old dial-per-request code would
// have succeeded.
t.invalidate(addr, cc)
retryReq, ok := rewindRequestBody(req)
if !ok {
return nil, err
}
req = retryReq
}
tlsConn, proto, err := t.dial(req.Context(), host, addr)
if err != nil {
return nil, err
}
if proto != "h2" {
// HTTP/1.1: single-use conn closed once the body is drained.
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return tlsConn, nil
},
DisableKeepAlives: true,
}
return transport.RoundTrip(req)
}
cc, err := t.h2.NewClientConn(tlsConn)
if err != nil {
tlsConn.Close()
return nil, err
}
cc = t.storeConn(addr, cc)
return cc.RoundTrip(req)
}
// rewindRequestBody returns a request whose body can be sent again after a
// failed attempt on a pooled connection: bodyless requests as-is, requests
// with GetBody with a rebuilt body, anything else not ok.
func rewindRequestBody(req *http.Request) (*http.Request, bool) {
if req.Body == nil {
return req, true
}
if req.GetBody == nil {
return nil, false
}
body, err := req.GetBody()
if err != nil {
return nil, false
}
retryReq := req.Clone(req.Context())
retryReq.Body = body
return retryReq, true
}
// dial opens a TCP connection and completes the Chrome-fingerprint TLS handshake,
// returning the connection and negotiated ALPN protocol.
func (t *utlsTransport) dial(ctx context.Context, host, addr string) (*utls.UConn, string, error) {
conn, err := dialWithDoHFallback(ctx, t.dialer, "tcp", addr)
if err != nil {
return nil, "", err
}
opts := GetNetworkCompatibilityOptions()
tlsConn := utls.UClient(conn, &utls.Config{
RootCAs: supplementalRootCAs(),
InsecureSkipVerify: opts.InsecureTLS,
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
ClientSessionCache: utlsSessionCache,
}, utls.HelloChrome_Auto)
if err := tlsConn.Handshake(); err != nil {
conn.Close()
return nil, err
return nil, "", err
}
return tlsConn, tlsConn.ConnectionState().NegotiatedProtocol, nil
}
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
if negotiatedProto == "h2" {
h2Transport := &http2.Transport{
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
return tlsConn, nil
},
AllowHTTP: false,
DisableCompression: false,
}
return h2Transport.RoundTrip(req)
func (t *utlsTransport) cachedConn(addr string) *http2.ClientConn {
t.mu.Lock()
defer t.mu.Unlock()
if cc := t.conns[addr]; cc != nil && cc.CanTakeNewRequest() {
return cc
}
return nil
}
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return tlsConn, nil
},
DisableKeepAlives: true,
func (t *utlsTransport) invalidate(addr string, cc *http2.ClientConn) {
t.mu.Lock()
if t.conns[addr] == cc {
delete(t.conns, addr)
}
t.mu.Unlock()
}
return transport.RoundTrip(req)
// storeConn caches cc, but if a concurrent dial already cached a healthy conn for
// addr it discards the freshly built cc (no in-flight requests) and returns the
// existing one, avoiding a leaked connection.
func (t *utlsTransport) storeConn(addr string, cc *http2.ClientConn) *http2.ClientConn {
t.mu.Lock()
defer t.mu.Unlock()
if existing := t.conns[addr]; existing != nil && existing.CanTakeNewRequest() {
cc.Close()
return existing
}
t.conns[addr] = cc
return cc
}
// closeIdleConnections drops every pooled conn so the next request re-dials —
// needed after a network switch, where pooled conns are silently dead and the
// first request would otherwise hang on one until its timeout. Conns are shut
// down gracefully so in-flight streams finish (or fail) before the close.
func (t *utlsTransport) closeIdleConnections() {
t.mu.Lock()
conns := t.conns
t.conns = make(map[string]*http2.ClientConn)
t.mu.Unlock()
for _, cc := range conns {
go cc.Shutdown(context.Background())
}
}
// closeUTLSIdleConnections lets platform-neutral code (CloseIdleConnections)
// reach the uTLS pool; the ios build provides a no-op stub.
func closeUTLSIdleConnections() {
cloudflareBypassTransport.closeIdleConnections()
}
func (t *utlsTransport) getPort(u *url.URL) string {
@@ -144,13 +249,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
return resp, nil
}
errStr := strings.ToLower(err.Error())
tlsRelated := strings.Contains(errStr, "tls") ||
strings.Contains(errStr, "handshake") ||
strings.Contains(errStr, "certificate") ||
strings.Contains(errStr, "connection reset")
if tlsRelated {
if isTLSHandshakeOrResetError(err) {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context())
+13
View File
@@ -0,0 +1,13 @@
package gobackend
import "encoding/json"
// marshalJSONString marshals v and returns it as a string, the shape every
// gomobile export returns.
func marshalJSONString(v any) (string, error) {
jsonBytes, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
+295 -89
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@@ -85,6 +86,7 @@ var supportedAudioFormats = map[string]bool{
type libraryAudioFileInfo struct {
path string
modTime int64
size int64
}
type scannedCueFileInfo struct {
@@ -92,6 +94,18 @@ type scannedCueFileInfo struct {
audioPath string
}
type libraryScanTask struct {
index int
info libraryAudioFileInfo
}
type libraryScanTaskResult struct {
index int
path string
results []LibraryScanResult
err error
}
func isLibraryStagingFile(path string) bool {
name := strings.ToLower(filepath.Base(path))
if strings.HasSuffix(name, ".partial") {
@@ -139,6 +153,7 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
files = append(files, libraryAudioFileInfo{
path: path,
modTime: info.ModTime().UnixMilli(),
size: info.Size(),
})
return nil
})
@@ -150,6 +165,145 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
return files, nil
}
func libraryAudioCoverCacheKey(info libraryAudioFileInfo) string {
return fmt.Sprintf("%s|%d|%d", info.path, info.size, info.modTime)
}
func libraryScanWorkerCount(taskCount int) int {
if taskCount < 16 {
return 1
}
workers := runtime.NumCPU()
if workers > 4 {
workers = 4
}
if workers < 2 {
workers = 2
}
if workers > taskCount {
workers = taskCount
}
return workers
}
func updateLibraryScanProgress(scannedFiles, totalFiles int, currentPath string) {
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = scannedFiles
libraryScanProgress.CurrentFile = filepath.Base(currentPath)
if totalFiles > 0 {
libraryScanProgress.ProgressPct = float64(scannedFiles) / float64(totalFiles) * 100
}
libraryScanProgressMu.Unlock()
}
func scanLibraryAudioTasksParallel(tasks []libraryScanTask, scanTime string, cancelCh <-chan struct{}, totalFiles int, completed *int) (map[int][]LibraryScanResult, int, error) {
resultsByIndex := make(map[int][]LibraryScanResult, len(tasks))
if len(tasks) == 0 {
return resultsByIndex, 0, nil
}
workers := libraryScanWorkerCount(len(tasks))
if workers <= 1 {
errorCount := 0
for _, task := range tasks {
select {
case <-cancelCh:
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
default:
}
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
task.info.path,
"",
libraryAudioCoverCacheKey(task.info),
scanTime,
task.info.modTime,
)
*completed++
updateLibraryScanProgress(*completed, totalFiles, task.info.path)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", task.info.path, err)
continue
}
resultsByIndex[task.index] = []LibraryScanResult{*result}
}
return resultsByIndex, errorCount, nil
}
taskCh := make(chan libraryScanTask)
resultCh := make(chan libraryScanTaskResult, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range taskCh {
select {
case <-cancelCh:
return
default:
}
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
task.info.path,
"",
libraryAudioCoverCacheKey(task.info),
scanTime,
task.info.modTime,
)
taskResult := libraryScanTaskResult{
index: task.index,
path: task.info.path,
err: err,
}
if err == nil && result != nil {
taskResult.results = []LibraryScanResult{*result}
}
select {
case <-cancelCh:
return
case resultCh <- taskResult:
}
}
}()
}
go func() {
defer close(taskCh)
for _, task := range tasks {
select {
case <-cancelCh:
return
case taskCh <- task:
}
}
}()
go func() {
wg.Wait()
close(resultCh)
}()
errorCount := 0
for taskResult := range resultCh {
*completed++
updateLibraryScanProgress(*completed, totalFiles, taskResult.path)
if taskResult.err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", taskResult.path, taskResult.err)
continue
}
resultsByIndex[taskResult.index] = taskResult.results
}
select {
case <-cancelCh:
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
default:
}
return resultsByIndex, errorCount, nil
}
func SetLibraryCoverCacheDir(cacheDir string) {
libraryCoverCacheMu.Lock()
libraryCoverCacheDir = cacheDir
@@ -225,6 +379,10 @@ func ScanLibraryFolder(folderPath string) (string, error) {
}
}
resultsByIndex := make(map[int][]LibraryScanResult, totalFiles)
audioTasks := make([]libraryScanTask, 0, totalFiles)
completedFiles := 0
for i, fileInfo := range audioFileInfos {
filePath := fileInfo.path
select {
@@ -233,12 +391,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = i + 1
libraryScanProgress.CurrentFile = filepath.Base(filePath)
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".cue" {
@@ -260,26 +412,44 @@ func ScanLibraryFolder(folderPath string) (string, error) {
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
continue
}
results = append(results, cueResults...)
resultsByIndex[i] = cueResults
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
continue
}
if cueReferencedAudioFiles[filePath] {
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
}
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
continue
}
audioTasks = append(audioTasks, libraryScanTask{index: i, info: fileInfo})
}
results = append(results, *result)
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
audioTasks,
scanTime,
cancelCh,
totalFiles,
&completedFiles,
)
if err != nil {
return "[]", err
}
errorCount += audioErrors
for index, scanResults := range audioResults {
resultsByIndex[index] = scanResults
}
for i := range audioFileInfos {
results = append(results, resultsByIndex[i]...)
}
libraryScanProgressMu.Lock()
@@ -320,6 +490,12 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
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,
@@ -333,10 +509,6 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
}
switch ext {
case ".flac":
return scanFLACFile(filePath, result, displayNameHint)
case ".m4a", ".mp4", ".aac":
return scanM4AFile(filePath, result, displayNameHint)
case ".mp3":
return scanMP3File(filePath, result, displayNameHint)
case ".opus", ".ogg":
@@ -352,6 +524,29 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
}
}
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 != "" {
@@ -381,10 +576,16 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
}
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath)
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
@@ -401,7 +602,7 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetAudioQuality(filePath)
quality, err := audioQualityFromParsedFlac(f)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
@@ -409,6 +610,14 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
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)
@@ -416,32 +625,37 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
}
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadM4ATags(filePath)
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 {
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
if result.ReleaseDate == "" {
result.ReleaseDate = metadata.Year
}
result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
applyAudioMetadataToScan(metadata, result)
}
quality, err := GetM4AQuality(filePath)
quality, err := m4aQualityFromFile(f, fileSize)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
@@ -456,6 +670,16 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
}
}
}
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)
@@ -500,24 +724,7 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
return scanFromFilename(filePath, displayNameHint, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.Genre = metadata.Genre
if metadata.Date != "" {
result.ReleaseDate = metadata.Date
} else {
result.ReleaseDate = metadata.Year
}
result.ISRC = metadata.ISRC
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
applyAudioMetadataToScan(metadata, result)
quality, err := GetMP3Quality(filePath)
if err == nil {
@@ -583,24 +790,7 @@ func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint str
return scanFromFilename(filePath, displayNameHint, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.Genre = metadata.Genre
if metadata.Date != "" {
result.ReleaseDate = metadata.Date
} else {
result.ReleaseDate = metadata.Year
}
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
applyAudioMetadataToScan(metadata, result)
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
@@ -874,6 +1064,10 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
}
}
resultsByIndex := make(map[int][]LibraryScanResult, len(filesToScan))
audioTasks := make([]libraryScanTask, 0, len(filesToScan))
completedFiles := skippedCount
for i, f := range filesToScan {
select {
case <-cancelCh:
@@ -881,12 +1075,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = skippedCount + i + 1
libraryScanProgress.CurrentFile = filepath.Base(f.path)
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
ext := strings.ToLower(filepath.Ext(f.path))
if ext == ".cue" {
@@ -908,24 +1096,42 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
results = append(results, cueResults...)
resultsByIndex[i] = cueResults
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
if cueReferencedAudioFilesInc[f.path] {
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
continue
}
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
}
results = append(results, *result)
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
audioTasks,
scanTime,
cancelCh,
totalFiles,
&completedFiles,
)
if err != nil {
return "{}", err
}
errorCount += audioErrors
for index, scanResults := range audioResults {
resultsByIndex[index] = scanResults
}
for i := range filesToScan {
results = append(results, resultsByIndex[i]...)
}
libraryScanProgressMu.Lock()

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