Compare commits

..

38 Commits

Author SHA1 Message Date
zarzet 2bbcda3320 fix: patch device_info_plus iOS build for older Xcode SDKs 2026-04-04 15:49:34 +07:00
zarzet a7622676dd feat: add additional search/metadata API with separate rate limiting 2026-04-04 13:54:55 +07:00
zarzet 5779f910a2 perf: incremental download queue lookup updates, async cover cleanup, and native JSON decoding on iOS
- Embed DownloadQueueLookup into DownloadQueueState; add updatedForIndices() for O(changed) incremental updates during frequent progress ticks instead of full O(n) rebuild
- downloadQueueLookupProvider now reads pre-computed lookup from state directly
- Replace sync file deletion in DownloadedEmbeddedCoverResolver with unawaited async cleanup to avoid blocking the main thread
- Parse JSON payloads on iOS native side (parseJsonPayload) so event sinks and method channel responses return native objects, avoiding redundant Dart-side JSON decode
- Use .cast<String, dynamic>() instead of Map.from() in _decodeMapResult for zero-copy map handling
2026-04-03 23:03:11 +07:00
zarzet 030f44a444 perf: reduce UI jank via memoization, compute isolates, SQL-backed playlist picker, and viewport-aware image caching
- Move explore JSON decode/encode to compute() isolate to avoid blocking main thread
- Memoize search sort results (artists/albums/playlists/tracks) in HomeTab; invalidate on new query
- Extract _DownloadedOrRemoteCover StatefulWidget with proper embedded-cover lifecycle management
- Replace O(playlists x tracks) in-memory playlist picker check with SQL loadPlaylistPickerSummaries query
- Add FutureProvider.family (libraryPlaylistPickerSummariesProvider) invalidated on all playlist mutations
- Memoize _buildQueueHistoryStats, localPathMatchKeys, and localSingleItems in QueueTab
- Add coverCacheWidthForViewport util; apply memCacheWidth/cacheWidth based on real DPR across all album/playlist/track screens
- Convert sync file ops in TrackMetadataScreen to async; use mtime+size as validation token
- Fetch Deezer album nb_tracks in parallel via fetchAlbumTrackCounts
2026-04-03 22:31:04 +07:00
zarzet 1248270fb4 fix: route Qobuz API calls through authenticated gateway to resolve 401 errors 2026-04-03 21:35:47 +07:00
zarzet 413e3b0686 refactor: consolidate FLAC/MP3/Opus metadata embedding into unified _embedMetadataToFile 2026-04-03 03:22:33 +07:00
zarzet ac711efadc feat: add skipLyrics manifest field for extensions to opt out of lyrics fetching 2026-04-03 03:14:51 +07:00
zarzet 59f2fe880a chore: remove redundant comments and update donor list 2026-04-03 02:21:40 +07:00
zarzet 355f2eba2a fix: resolve missing track/disc numbers from search downloads and suppress FFmpeg log noise
- Tidal: use actual API track_number/disc_number when request values are 0
  (fixes search/popular downloads having no track position in metadata)
- Extension enrichment: copy TrackNumber/DiscNumber back from enriched results
- Extension fallback download: add request metadata fallback for non-source
  extensions (Album, AlbumArtist, ReleaseDate, ISRC, TrackNumber, DiscNumber)
- FFmpeg: add -v error -hide_banner to all commands (embed, convert, CUE split)
  to eliminate banner, build config, and full metadata/lyrics dump in logcat
- ebur128: add framelog=quiet to suppress per-frame loudness measurements
  while keeping the summary needed for ReplayGain parsing
- Track metadata screen: separate embedded lyrics check from online fetch,
  show file-only state with manual online fetch button
2026-04-03 00:56:09 +07:00
zarzet f2f45fa31d fix: improve extension runtime safety, HTTP response URL, SongLink parsing, and recommended service for extensions
- Add 'url' field (final URL after redirects) to all extension HTTP responses and fix fetch polyfill to return final URL instead of original request URL
- Fix RunWithTimeout race condition: increase force-timeout from 1s to 60s to prevent concurrent VM access crashes, add nil guards
- Use lockReadyVM() for thread-safe VM access in GetPlaylistWithExtensionJSON and InvokeAction
- Handle mixed JSON types (string, null, array) in SongLink resolve API SongUrls field
- Fix recommended download service not showing for extension-based searches in download picker
2026-04-02 23:16:37 +07:00
zarzet 042937a8ed fix: resolve label and copyright from file metadata on info screen
The info screen was not reading label/copyright from the actual file metadata, so these fields were always empty for local library items and download history items that lacked them in-memory. Now _resolveAudioMetadata() extracts and displays them without requiring a manual save first.
2026-04-02 19:44:37 +07:00
zarzet 674e9af3d0 fix: validate ISRC in track metadata screen to prevent ID leakage
Sanitize the isrc getter to only return valid ISRC codes (12-char format per ISO 3901). Invalid values such as Spotify/Deezer/Tidal IDs that may leak into the ISRC field are now silently discarded, preventing them from being displayed or embedded into file tags.
2026-04-02 15:29:42 +07:00
zarzet 76d50fab3a fix: correct track/disc defaults, forward extension metadata, and fix service ID display
- Default track/disc number to 0 (unknown) instead of 1, letting the
  backend use the service-provided value or skip the field entirely
- Add releaseDate to ExploreItem so explore downloads carry release info
- Pass discNumber and releaseDate from extension album/playlist tracks
- Fix isDeezer detection using service field instead of substring match
- Add _displayServiceTrackId() to properly strip prefixes for all services
2026-04-02 15:13:11 +07:00
zarzet 81e25d7dab chore: bump version to 4.2.0 (build 121) 2026-04-02 03:20:56 +07:00
zarzet 26f26f792a feat: add ReplayGain scanning, APEv2 tag support, and fix metadata bugs
ReplayGain (track + album):
- Scan track loudness via FFmpeg ebur128 filter (-18 LUFS reference)
- Duration-weighted power-mean for album gain computation
- Support for FLAC (native Vorbis), MP3 (ID3v2 TXXX), Opus, M4A
- Album RG auto-finalizes when all album tracks complete
- Retryable gate: blocks finalization while failed/skipped items exist
- SAF support: lossy album RG writes via temp file + writeTempToSaf
- New embedReplayGain setting (off by default) with UI toggle

APEv2 tag support:
- Full APEv2 reader/writer with header+items+footer format
- Merge-based editing with override keys for explicit deletions
- Binary cover art embedding (Cover Art (Front) item)
- Library scanner support for .ape/.wv/.mpc files
- ReplayGain fields in APE read/write/edit pipeline

Bug fixes (26):
- setArtistComments wiping fields on empty string value
- APEv2 rewrite corrupting files with ID3v1 trailer
- APE edit replacing entire tag instead of merging
- ReplayGain lost on manual MP3/Opus/M4A metadata edit
- Editor metadata save losing custom tags (preserveMetadata)
- Album RG accumulator not cleaned on queue mutation
- Album gain using unweighted mean instead of power-mean
- writeAlbumReplayGainTags return value silently ignored
- SAF album RG writing to deleted temp path
- Cancelled tracks polluting album gain computation
- APE ReplayGain not wired end-to-end
- APE field deletion not working in merge
- APE cover edit was a no-op
- Album RG duplicate entries on retry
- APE apeKeysFromFields missing track/disc/lyrics mappings
- Album RG entries purged by removeItem before computation
- FFmpeg converters discarding empty metadata values
- _appendVorbisArtistEntries skipping empty value (null vs empty)
- Album RG write-back fails for SAF lossy files
- Album RG partial finalization on failed tracks
- FLAC ClearEmpty flag destroying tags on partial callers
- clearCompleted not retriggering album RG checks
- ReadFileMetadata MP3/Ogg missing label and copyright
- Cover embed on CUE split destroying split artist tags
- Album RG gain format inconsistent (missing + prefix)
- FLAC reader/editor missing tag aliases (ALBUMARTIST, LABEL, etc.)
- dart:math log shadowed by logger.dart export
2026-04-02 03:15:01 +07:00
zarzet 4dfa76b49e fix: remove deleted local library item from provider state after file deletion
When deleting a non-CUE local library track from the metadata screen,
only the file was removed but the library database entry and provider
state were left untouched, causing the track to persist in the library UI.
Now calls removeItem() on localLibraryProvider after deleteFile().
2026-04-01 21:04:42 +07:00
zarzet f511f30ad0 feat: add resolve API with SongLink fallback, fix multi-artist tags (#288), and cleanup
Resolve API (api.zarz.moe):
- Refactor songlink.go: Spotify URLs use resolve API, non-Spotify uses SongLink API
- Add SongLink fallback when resolve API fails for Spotify (two-layer resilience)
- Remove dead code: page parser, XOR-obfuscated keys, legacy helpers

Multi-artist tag fix (#288):
- Add RewriteSplitArtistTags() in Go to rewrite ARTIST/ALBUMARTIST as split Vorbis comments
- Wire method channel handler in Android (MainActivity.kt) and iOS (AppDelegate.swift)
- Add PlatformBridge.rewriteSplitArtistTags() in Dart
- Call native FLAC rewriter after FFmpeg embed when split_vorbis mode is active
- Extract deezerTrackArtistDisplay() helper to use Contributors in album/playlist tracks

Code cleanup:
- Remove unused imports, dead code, and redundant comments across Go and Dart
- Fix build: remove stale getQobuzDebugKey() reference in deezer_download.go
2026-04-01 02:49:19 +07:00
zarzet a1aa1319ce feat: add separate filename format for singles and EPs (#271)
Add singleFilenameFormat setting so singles/EPs can use a different filename template than albums. The format editor is reused with custom title/description. Dart selects the correct format based on track.isSingle before passing to Go, so no backend changes needed. Also fix isSingle getter to include all EPs regardless of totalTracks. Closes #271
2026-03-31 18:55:48 +07:00
zarzet c936bd7dd0 fix: match system navigation bar color with app theme
Set systemNavigationBarColor to surfaceContainer (matching the in-app
NavigationBar) via AppBarTheme.systemOverlayStyle. Handles light, dark,
AMOLED and dynamic color schemes automatically.

Closes zarzet/SpotiFLAC-Mobile#284
2026-03-31 18:36:28 +07:00
zarzet 3a60ea2f4e feat: add field selection dialog for bulk re-enrich metadata
Add a bottom sheet dialog that lets users choose which metadata field
groups to update during bulk re-enrich (cover, lyrics, album/album
artist, track/disc number, date/ISRC, genre/label/copyright).

Backend (Go):
- Filter FLAC Metadata struct and FFmpeg metadata map by selected
  update_fields so non-selected groups preserve existing file values
- Guard Deezer extended metadata fetch with shouldUpdateField(extra)
- Title/Artist are never overwritten by re-enrich (search keys only)
- enrichedMeta response only includes selected field groups

Frontend (Dart):
- New re_enrich_field_dialog.dart bottom sheet with checkboxes
- FFmpegService embed methods gain preserveMetadata param that uses
  -map_metadata 0 instead of -1 to preserve non-selected tags
- Hide selection overlay/bar before showing dialog, restore on cancel
- Fix setState-after-dispose guard in cancel branches

Cleanup:
- Remove dead code in library_tracks_folder_screen.dart
- Fix use_build_context_synchronously in main_shell.dart
- Suppress false-positive use_null_aware_elements lints
- Update l10n label from 'Title, Artist, Album' to 'Album, Album Artist'
2026-03-31 18:21:45 +07:00
zarzet 7dba938299 fix: prefer local file for cover/lyrics save and update build dependencies
- Cover art: extract from downloaded file first, fall back to URL download
- Lyrics: check embedded lyrics/sidecar LRC before fetching online
- Add audioFilePath param to FetchAndSaveLyrics (Go, Kotlin, Swift, Dart)
- Handle SAF content:// URIs for lyrics extraction in Kotlin bridge
- Update Go 1.25.7 -> 1.25.8, Gradle 9.3.1 -> 9.4.1, Kotlin 2.2.21 -> 2.3.20
- Update NDK r27d -> r28b, Flutter FVM 3.41.4 -> 3.41.5
- Upgrade all Flutter and Go module dependencies to latest
2026-03-31 17:25:30 +07:00
zarzet 93e77aeb84 refactor: remove legacy API clients, Yoinkify fallback, and unused lyrics provider
- Delete dead metadata client and extract shared types to metadata_types.go
- Remove Yoinkify download fallback from Deezer, use MusicDL only
- Clean up retired settings fields and metadataSource
- Remove dead l10n keys for retired provider
- Add migration to strip retired provider from existing users' lyrics config
2026-03-30 23:26:37 +07:00
zarzet dd750b95ca chore: bump version to 4.1.3 (build 120) 2026-03-30 18:25:42 +07:00
zarzet e42e44f28b fix: Samsung SAF library scan, Qobuz album cover, M4A metadata save and log improvements
- Fix M4A/ALAC scan silently failing on Samsung by adding proper fallback
  to scanFromFilename when ReadM4ATags fails (consistent with MP3/FLAC/Ogg)
- Propagate displayNameHint to all format scanners so fd numbers (214, 207)
  no longer appear as track names when /proc/self/fd/ paths are used
- Cache /proc/self/fd/ readability in Kotlin to skip failed attempts after
  first failure, reducing error log noise and improving scan speed on Samsung
- Fix Qobuz download returning wrong album cover when track exists on
  multiple albums by preferring req.CoverURL over API default
- Fix FFmpeg M4A metadata save failing with 'codec not currently supported
  in container' by forcing mp4 muxer instead of ipod when cover art present
- Clean up FLAC SAF temp file after metadata write-back (was leaking)
- Update LRC lyrics tag to credit Paxsenix API
- Remove log message truncation, defer to UI preview truncation instead
2026-03-30 18:12:20 +07:00
zarzet 67daefdf60 feat: add artist tag mode setting with split Vorbis support and improve library scan progress
- Add artist_tag_mode setting (joined / split_vorbis) for FLAC/Opus multi-artist tags
- Split 'Artist A, Artist B' into separate ARTIST= Vorbis comments when split mode is enabled
- Join repeated ARTIST/ALBUMARTIST Vorbis comments when reading metadata
- Propagate artistTagMode through download pipeline, re-enrich, and metadata editor
- Improve library scan progress: separate polling intervals, finalizing state, indeterminate progress
- Add initial progress snapshot on library scan stream connect
- Use req.ArtistName consistently for Qobuz downloads instead of track.Performer.Name
- Add l10n keys for artist tag mode, library files unit, and scan finalizing status
2026-03-30 12:38:42 +07:00
zarzet fabaf0a3ff feat: add stable cover cache keys, Qobuz album-search fallback, metadata filters and extended sort options
- Introduce coverCacheKey parameter through Go backend and Kotlin bridge for stable SAF cover caching
- Add MetadataFromFilename flag to skip filename-only metadata and retry via temp-file copy
- Add Qobuz album-search fallback between API search and store scraping
- Extract buildReEnrichFFmpegMetadata to skip empty metadata fields
- Add metadata completeness filter (complete, missing year/genre/album artist)
- Add sort modes: artist, album, release date, genre (asc/desc)
- Prune stale library cover cache files after full scan
- Skip empty values and zero track/disc numbers in FFmpeg metadata
- Add new l10n keys for metadata filter and sort options
2026-03-30 11:41:11 +07:00
zarzet fb90c73f42 fix: use Tidal quality options as fallback instead of DEFAULT for extensions 2026-03-29 18:57:13 +07:00
zarzet c6cf65f075 fix: normalize DEFAULT quality to prevent Tidal/Qobuz API failures 2026-03-29 18:49:57 +07:00
zarzet 25de009ebc feat: replace batch operation snackbars with progress dialog
Add reusable BatchProgressDialog widget with circular/linear progress
indicators, cancel support, and track detail display. Uses ValueNotifier
pattern to communicate progress from caller to dialog across navigator
routes.
2026-03-29 18:04:38 +07:00
zarzet 8918d74bb5 refactor: extract and improve ReEnrich track selection with scoring-based matching 2026-03-29 17:45:51 +07:00
zarzet f9de8d45d9 fix: add attached_pic disposition to ALAC cover art embedding 2026-03-29 17:41:43 +07:00
zarzet 48eef0853d i18n: extract hardcoded strings into l10n keys
Move hardcoded UI strings across multiple screens and the notification
service into ARB-backed l10n keys so they can be translated via Crowdin.
Adds 62 new keys covering sort labels, dialog copy, metadata error
snackbars, folder-picker errors, home-tab error states, extensions home
feed selector, and all notification titles/bodies. NotificationService
now caches an AppLocalizations instance (injected from MainShell via
didChangeDependencies) and falls back to English literals when no locale
is available.
2026-03-29 17:02:12 +07:00
zarzet fc70a912bf refactor: route spotify URLs through extensions 2026-03-29 16:35:16 +07:00
zarzet cd3e5b4b28 chore: bump version to 4.1.2+119 2026-03-29 15:40:24 +07:00
zarzet 482ca82eb4 feat: improve track matching 2026-03-29 15:34:44 +07:00
zarzet 6d87ae5484 feat: add haptic feedback when swiping library tabs 2026-03-29 01:56:22 +07:00
zarzet bd3e2b999b feat: add play button to playlist/library track tiles
Show a play IconButton (matching local album style) next to the
more-options button when a track has a local file available.
Uses PlaybackController.playTrackList to resolve and open the file.
2026-03-29 01:54:27 +07:00
zarzet 186196e12b fix: use START_NOT_STICKY for DownloadService to prevent auto-restart
Prevents Android from automatically recreating the download service
after it is killed, avoiding duplicate or orphaned download processes.
2026-03-29 01:37:24 +07:00
321 changed files with 37806 additions and 158208 deletions
+8 -20
View File
@@ -66,7 +66,7 @@ jobs:
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "25"
java-version: "17"
- name: Setup Go
uses: actions/setup-go@v6
@@ -164,18 +164,13 @@ jobs:
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
build-ios:
runs-on: macos-15
runs-on: macos-latest
needs: get-version # Only depends on version, NOT android build!
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Select Xcode 26.1.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.1.app
xcodebuild -version
- name: Setup Go
uses: actions/setup-go@v6
with:
@@ -257,15 +252,6 @@ jobs:
- name: Get Flutter dependencies
run: flutter pub get
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
run: |
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
while IFS= read -r -d '' f; do
perl -pi -e 's/\r$//' "$f"
chmod +x "$f"
echo "Normalized line endings: $f"
done
- name: Generate app icons
run: dart run flutter_launcher_icons
@@ -388,6 +374,8 @@ 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:"
@@ -397,7 +385,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
body_path: /tmp/release_body.txt
files: ./release/*
draft: false
@@ -563,7 +551,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 Mobile ${VERSION} - arm64 (recommended)"
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
fi
# Upload arm32 APK to channel
@@ -572,7 +560,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 Mobile ${VERSION} - arm32"
-F caption="SpotiFLAC ${VERSION} - arm32"
fi
# Upload iOS IPA to channel
@@ -582,7 +570,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 Mobile ${VERSION} - iOS (unsigned, sideload required)"
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
fi
echo "Telegram notification sent!"
+2 -8
View File
@@ -44,7 +44,6 @@ go_backend/*.xcframework/
# Android
android/.gradle/
android/app/libs/gobackend.aar
android/app/libs/gobackend-sources.jar
android/local.properties
android/*.iml
android/key.properties
@@ -58,22 +57,17 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
# Extension folder
extension/*
extension/v2/
extension/v2/**
extension/
# Agent instructions
AGENTS.md
# Temp/misc
.tmp/
nul
NUL
network_requests.txt
*.bak
/AndroidManifest.xml
# Log files
*.log
Binary file not shown.
+1 -1
View File
@@ -86,7 +86,7 @@ Translation files are located in `lib/l10n/arb/`.
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Use FVM (Flutter Version: 3.41.5)**
3. **Use FVM (Flutter Version: 3.38.1)**
```bash
fvm use
```
+18 -14
View File
@@ -1,14 +1,14 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
</picture>
<p align="center">
<a href="https://trendshift.io/repositories/25971" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/25971" alt="spotiflacapp%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
<a href="https://trendshift.io/repositories/17247">
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
</a>
</p>
@@ -28,10 +28,10 @@
## Screenshots
<p align="center">
<img src="assets/readme/1.jpg?v=2" width="200" />
<img src="assets/readme/2.jpg?v=2" width="200" />
<img src="assets/readme/3.jpg?v=2" width="200" />
<img src="assets/readme/4.jpg?v=2" width="200" />
<img src="assets/images/1.jpg?v=2" width="200" />
<img src="assets/images/2.jpg?v=2" width="200" />
<img src="assets/images/3.jpg?v=2" width="200" />
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
---
@@ -59,7 +59,7 @@ Extensions let the community add new music sources and features without waiting
## Related Projects
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
@@ -80,7 +80,7 @@ Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository
<summary><b>Why is my download failing with "Song not found"?</b></summary>
<br>
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
</details>
@@ -88,7 +88,10 @@ The track may not be available from your enabled providers. Try enabling more pr
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
<br>
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
- **Tidal** up to 24-bit/192kHz
- **Qobuz** up to 24-bit/192kHz
- **Deezer** up to 16-bit/44.1kHz
</details>
@@ -163,8 +166,9 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
| | | | | |
|---|---|---|---|---|
| [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) |
| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | |
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
---
+7 -7
View File
@@ -9,9 +9,6 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
plugins:
riverpod_lint: 3.1.4-dev.3
analyzer:
exclude:
- build/**
@@ -22,6 +19,9 @@ analyzer:
strict-casts: true
strict-inference: true
strict-raw-types: true
plugins:
- custom_lint
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
@@ -36,13 +36,13 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
always_declare_return_types: true
avoid_dynamic_calls: true
avoid_types_as_parameter_names: true
strict_top_level_inference: true
type_annotate_public_apis: true
cancel_subscriptions: true
close_sinks: true
custom_lint:
rules:
- avoid_public_notifier_properties
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+71
View File
@@ -0,0 +1,71 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
namespace "com.zarz.spotiflac"
compileSdk flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "com.zarz.spotiflac"
minSdkVersion flutter.minSdkVersion
targetSdk flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
release {
signingConfig signingConfigs.debug
minifyEnabled false
shrinkResources false
}
}
}
flutter {
source '../..'
}
dependencies {
// Go backend library (gomobile generated)
implementation fileTree(dir: 'libs', include: ['*.aar'])
// Kotlin coroutines for async Go backend calls
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
}
+8 -30
View File
@@ -17,22 +17,18 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "com.zarz.spotiflac"
compileSdk = 37
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
buildFeatures {
buildConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
@@ -50,7 +46,7 @@ android {
defaultConfig {
applicationId = "com.zarz.spotiflac"
minSdk = flutter.minSdkVersion
targetSdk = 37
targetSdk = 36
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
@@ -61,20 +57,6 @@ android {
}
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
ndk {
debugSymbolLevel = "FULL"
}
}
getByName("profile") {
ndk {
debugSymbolLevel = "FULL"
}
}
release {
// For local builds: use release signing if key.properties exists
// For CI builds: APK is signed by GitHub Action after build
@@ -89,9 +71,6 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
ndk {
debugSymbolLevel = "FULL"
}
}
}
@@ -122,9 +101,8 @@ dependencies {
// Include all AAR and JAR files from libs folder
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("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.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")
implementation("androidx.activity:activity-ktx:1.12.3")
}
+4 -27
View File
@@ -18,7 +18,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="SpotiFLAC Mobile"
android:label="SpotiFLAC"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"
@@ -86,26 +86,6 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="music.youtube.com" />
</intent-filter>
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="spotify-callback" />
</intent-filter>
<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 -->
@@ -114,15 +94,16 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Audio playback service for media notification / background audio -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
android:enabled="true">
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
@@ -147,10 +128,6 @@
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"
@@ -0,0 +1,5 @@
package com.example.temp_project
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,496 +0,0 @@
package com.zarz.spotiflac
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import org.json.JSONObject
import java.io.File
import java.util.Locale
/**
* Shared SAF download wrapper for foreground activity calls and service-owned
* native workers.
*/
object SafDownloadHandler {
private val safDirLock = Any()
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
val req = JSONObject(requestJson)
val storageMode = req.optString("storage_mode", "")
val treeUriStr = req.optString("saf_tree_uri", "")
if (storageMode != "saf" || treeUriStr.isBlank()) {
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)
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
val existingDir = findDocumentDir(context, treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
if (useStagedOutput || deferSafPublish) {
deleteStaleStagedFiles(existingDir, fileName, outputExt)
}
val obj = JSONObject()
obj.put("success", true)
obj.put("message", "File already exists")
obj.put("file_path", existing.uri.toString())
obj.put("file_name", existing.name ?: fileName)
obj.put("already_exists", true)
return obj.toString()
}
}
val targetDir = ensureDocumentDir(context, treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory")
if (deferSafPublish) {
deleteStaleStagedFiles(targetDir, fileName, outputExt)
val workingExt = outputExt.ifBlank { ".tmp" }
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
return try {
req.put("output_path", workingFile.absolutePath)
req.put("output_ext", outputExt)
req.remove("output_fd")
val response = downloader(req.toString())
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
val reportedPath = respObj.optString("file_path", "").trim()
if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) {
respObj.put("file_path", workingFile.absolutePath)
} else if (reportedPath != workingFile.absolutePath) {
workingFile.delete()
}
respObj.put("file_name", respObj.optString("file_name", "").ifBlank { fileName })
respObj.put("saf_deferred_publish", true)
respObj.put("saf_final_file_name", fileName)
respObj.put("saf_relative_dir", relativeDir)
respObj.put("saf_tree_uri", treeUriStr)
respObj.put("saf_output_ext", outputExt)
respObj.put("saf_final_mime_type", mimeType)
} else {
workingFile.delete()
}
respObj.toString()
} catch (e: Exception) {
workingFile.delete()
errorJson("SAF deferred download failed: ${e.message}")
}
}
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
?: return errorJson("Failed to create SAF file")
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
?: return errorJson("Failed to open SAF file")
var detachedFd: Int? = null
try {
detachedFd = pfd.detachFd()
req.put("output_path", "")
req.put("output_fd", detachedFd)
req.put("output_ext", outputExt)
val response = downloader(req.toString())
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
!goFilePath.startsWith("/proc/self/fd/")
) {
try {
val srcFile = File(goFilePath)
if (!srcFile.exists() || srcFile.length() <= 0) {
throw IllegalStateException("extension output missing or empty: $goFilePath")
}
val actualExt = normalizeExt(srcFile.extension)
if (actualExt.isNotBlank()) {
respObj.put("actual_extension", actualExt)
}
if (actualExt.isNotBlank() && actualExt != outputExt) {
val actualFileName = buildSafFileName(req, actualExt)
val actualStagedFileName = if (useStagedOutput) {
buildStagedSafFileName(actualFileName)
} else {
actualFileName
}
val actualMimeType = mimeTypeForExt(actualExt)
val replacement = createOrReuseDocumentFile(
targetDir,
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
actualStagedFileName
) ?: throw IllegalStateException(
"failed to create SAF output with actual extension"
)
if (replacement.uri != document.uri) {
document.delete()
document = replacement
}
}
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: throw IllegalStateException("failed to open SAF output stream")
srcFile.delete()
} catch (e: Exception) {
document.delete()
android.util.Log.w(
"SpotiFLAC",
"Failed to copy extension output to SAF: ${e.message}"
)
return errorJson("Failed to copy extension output to SAF: ${e.message}")
}
}
respObj.put("file_path", document.uri.toString())
respObj.put("file_name", document.name ?: fileName)
if (useStagedOutput) {
respObj.put("saf_staged_output", true)
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
}
} else {
document.delete()
}
return respObj.toString()
} catch (e: Exception) {
document.delete()
return errorJson("SAF download failed: ${e.message}")
} finally {
if (detachedFd == null) {
try {
pfd.close()
} catch (_: Exception) {
}
}
}
}
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
return try {
val uri = Uri.parse(uriStr)
val extension = DocumentFile.fromSingleUri(context, uri)
?.name
?.substringAfterLast('.', "")
?.takeIf { it.isNotBlank() }
?.let { ".$it" }
?: ".tmp"
val temp = File.createTempFile("native_saf_", extension, context.cacheDir)
context.contentResolver.openInputStream(uri)?.use { input ->
temp.outputStream().use { output ->
input.copyTo(output)
}
} ?: return null
temp.absolutePath
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to copy SAF URI to temp: ${e.message}")
null
}
}
fun writeFileToSaf(
context: Context,
treeUriStr: String,
relativeDir: String,
fileName: String,
mimeType: 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)
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
?: return null
stagedDocument = document
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
if (outputStream == null) {
document.delete()
stagedDocument = null
return null
}
outputStream.use { output ->
File(srcPath).inputStream().use { input ->
input.copyTo(output)
}
}
val existingFinal = targetDir.findFile(finalName)
if (existingFinal != null && existingFinal.uri != document.uri) {
existingFinal.delete()
}
if (!document.renameTo(finalName)) {
document.delete()
return null
}
stagedDocument = null
targetDir.findFile(finalName)?.uri?.toString() ?: document.uri.toString()
} catch (e: Exception) {
stagedDocument?.delete()
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
null
}
}
fun deleteContentUri(context: Context, uriStr: String): Boolean {
return try {
DocumentFile.fromSingleUri(context, Uri.parse(uriStr))?.delete() == true
} catch (_: Exception) {
false
}
}
private fun normalizeExt(ext: String?): String {
if (ext.isNullOrBlank()) return ""
return if (ext.startsWith(".")) {
ext.lowercase(Locale.ROOT)
} else {
".${ext.lowercase(Locale.ROOT)}"
}
}
private fun mimeTypeForExt(ext: String?): String {
return when (normalizeExt(ext)) {
".m4a", ".mp4" -> "audio/mp4"
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".flac" -> "audio/flac"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
}
private fun forceFilenameExt(name: String, outputExt: String): String {
val normalizedExt = normalizeExt(outputExt)
if (normalizedExt.isBlank()) return sanitizeFilename(name)
val safeName = sanitizeFilename(name)
val lower = safeName.lowercase(Locale.ROOT)
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".lrc")
for (knownExt in knownExts) {
if (lower.endsWith(knownExt)) {
return safeName.dropLast(knownExt.length) + normalizedExt
}
}
return safeName + normalizedExt
}
private fun buildStagedSafFileName(fileName: String): String {
val safeName = sanitizeFilename(fileName)
return "$safeName.partial"
}
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
val safeName = sanitizeFilename(fileName)
val ext = normalizeExt(outputExt)
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
}
val dot = safeName.lastIndexOf('.')
if (dot > 0 && dot < safeName.lastIndex) {
return safeName.substring(0, dot).trimEnd('.', ' ') +
".partial" +
safeName.substring(dot)
}
return "$safeName.partial"
}
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
val stagedNames = linkedSetOf(
buildStagedSafFileName(fileName),
buildLegacyStagedSafFileName(fileName, outputExt)
)
for (stagedName in stagedNames) {
try {
parent.findFile(stagedName)?.delete()
} catch (_: Exception) {
}
}
}
private fun sanitizeFilename(name: String): String {
var sanitized = name
.replace("/", " ")
.replace(Regex("[\\\\:*?\"<>|]"), " ")
.filter { ch ->
val code = ch.code
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
code == 0x7F ||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
}
.trim()
.trim('.', ' ')
sanitized = sanitized
.replace(Regex("\\s+"), " ")
.replace(Regex("_+"), "_")
.trim('_', ' ')
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
return if (sanitized.isBlank()) "Unknown" else sanitized
}
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
val dotIndex = name.lastIndexOf('.')
val ext = if (
dotIndex > 0 &&
dotIndex < name.length - 1 &&
name.length - dotIndex <= 10
) {
name.substring(dotIndex)
} else {
""
}
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
}
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
val builder = StringBuilder()
var usedBytes = 0
var index = 0
while (index < value.length) {
val codePoint = value.codePointAt(index)
val char = String(Character.toChars(codePoint))
val charBytes = char.toByteArray(Charsets.UTF_8).size
if (usedBytes + charBytes > maxBytes) break
builder.append(char)
usedBytes += charBytes
index += Character.charCount(codePoint)
}
return builder.toString()
}
private fun sanitizeRelativeDir(relativeDir: String): String {
if (relativeDir.isBlank()) return ""
return relativeDir
.split("/")
.map { sanitizeFilename(it) }
.filter { it.isNotBlank() && it != "." && it != ".." }
.joinToString("/")
}
private fun ensureDocumentDir(
context: Context,
treeUri: Uri,
relativeDir: String
): DocumentFile? {
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
if (safeRelativeDir.isBlank()) {
return DocumentFile.fromTreeUri(context, treeUri)
}
synchronized(safDirLock) {
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
current = if (existing != null && existing.isDirectory) {
existing
} else {
val created = current.createDirectory(part) ?: return null
val createdName = created.name ?: part
if (createdName != part) {
created.delete()
current.findFile(part) ?: return null
} else {
created
}
}
}
return current
}
}
private fun findDocumentDir(
context: Context,
treeUri: Uri,
relativeDir: String
): DocumentFile? {
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
if (safeRelativeDir.isBlank()) return current
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
if (existing == null || !existing.isDirectory) return null
current = existing
}
return current
}
private fun createOrReuseDocumentFile(
parent: DocumentFile,
mimeType: String,
fileName: String
): DocumentFile? {
val safeFileName = sanitizeFilename(fileName)
if (safeFileName.isBlank()) return null
synchronized(safDirLock) {
val existing = parent.findFile(safeFileName)
if (existing != null && existing.isFile) {
return existing
}
val created = parent.createFile(mimeType, safeFileName) ?: return null
val createdName = created.name ?: safeFileName
if (createdName == safeFileName) {
return created
}
val winner = parent.findFile(safeFileName)
if (winner != null && winner.isFile) {
if (winner.uri != created.uri) {
try {
created.delete()
} catch (_: Exception) {
}
}
return winner
}
return created
}
}
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
val trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "")
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
return forceFilenameExt(baseName, outputExt)
}
private fun errorJson(message: String): String {
val obj = JSONObject()
obj.put("success", false)
obj.put("error", message)
obj.put("message", message)
return obj.toString()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,4 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

@@ -1,4 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -6,9 +6,4 @@
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
<monochrome>
<inset
android:drawable="@drawable/ic_launcher_monochrome"
android:inset="16%" />
</monochrome>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,8 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
@@ -1,8 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
+3 -3
View File
@@ -11,8 +11,8 @@ subprojects {
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// 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_25)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
}
+1 -5
View File
@@ -1,6 +1,2 @@
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -5
View File
@@ -1,9 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
+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 "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
+7 -7
View File
@@ -1,18 +1,18 @@
{
"name": "SpotiFLAC Mobile Source",
"name": "SpotiFLAC Source",
"identifier": "com.zarzet.spotiflac.source",
"subtitle": "FLAC Downloader for iOS",
"apps": [
{
"name": "SpotiFLAC Mobile",
"name": "SpotiFLAC",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"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.",
"version": "3.9.0",
"versionDate": "2026-03-25",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 37455821
"size": 34477323
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Before

Width:  |  Height:  |  Size: 811 KiB

After

Width:  |  Height:  |  Size: 811 KiB

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 70 KiB

+4 -8
View File
@@ -3,21 +3,17 @@ files:
translation: /lib/l10n/arb/app_%locale%.arb
languages_mapping:
locale:
# Keys MUST be the project's Crowdin language ids; values are the
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
# gen-l10n parses them — hyphenated filenames break gen-l10n).
ar: ar
# Short codes for single-variant languages
de: de
es-ES: es_ES
es: es
fr: fr
hi: hi
id: id
ja: ja
ko: ko
nl: nl
pt-PT: pt_PT
pt: pt
ru: ru
tr: tr
uk: uk
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
-322
View File
@@ -1,322 +0,0 @@
package gobackend
import (
"encoding/binary"
"fmt"
"os"
)
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
type mp4Box struct {
offset int64
size int64
hdr int64
typ string
}
func (b mp4Box) body() int64 { return b.offset + b.hdr }
func (b mp4Box) end() int64 { return b.offset + b.size }
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
n := int64(len(data))
if pos < 0 || pos+8 > n {
return mp4Box{}, false
}
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
typ := string(data[pos+4 : pos+8])
hdr := int64(8)
if size == 1 {
if pos+16 > n {
return mp4Box{}, false
}
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
hdr = 16
} else if size == 0 {
size = n - pos
}
if size < hdr || pos+size > n {
return mp4Box{}, false
}
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
}
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
pos := start
for pos+8 <= end {
b, ok := readMP4Box(data, pos)
if !ok {
return mp4Box{}, false
}
if b.typ == typ {
return b, true
}
pos = b.end()
}
return mp4Box{}, false
}
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
pos := start
for pos+8 <= end {
b, ok := readMP4Box(data, pos)
if !ok {
return
}
if b.typ == typ && !fn(b) {
return
}
pos = b.end()
}
}
// findBoxBySignature scans [start,end) for a box of the given type, matching the
// 4-byte type tag and validating the preceding size field. Used to locate dac4
// which may be nested inside an encrypted (enca) sample entry.
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
if len(typ) != 4 {
return mp4Box{}, false
}
for i := start; i+8 <= end; i++ {
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
return b, true
}
}
}
return mp4Box{}, false
}
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
// entry header (from the box body start) before child boxes begin. ok is false
// for malformed/truncated entries whose declared header is not fully present.
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
base := entry.body()
if base+10 > entry.end() {
return 0, false
}
version := binary.BigEndian.Uint16(data[base+8 : base+10])
hdrLen = 8 + 20
switch version {
case 1:
hdrLen += 16
case 2:
hdrLen += 36
}
if base+hdrLen > entry.end() {
return 0, false
}
return hdrLen, true
}
type ac4Location struct {
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
entry mp4Box // the ac-4 sample entry
}
func locateAC4Entry(data []byte) (ac4Location, bool) {
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
if !ok {
return ac4Location{}, false
}
var found ac4Location
var ok2 bool
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
if !ok {
return true
}
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
if !ok {
return true
}
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
if !ok {
return true
}
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
if !ok {
return true
}
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
if !ok {
return true
}
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
ok2 = true
return false
})
return found, ok2
}
func growBoxSize(data []byte, b mp4Box, delta int64) {
if b.hdr == 16 {
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
} else {
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
}
}
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
// inserted into moov.
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
if !ok {
return true
}
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
if !ok {
return true
}
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
if !ok {
return true
}
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
base := stco.body() + 4
if base+4 <= stco.end() {
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
p := base + 4
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
if v >= insertPos {
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
}
p += 4
}
}
}
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
base := co64.body() + 4
if base+4 <= co64.end() {
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
p := base + 4
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
if v >= insertPos {
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
}
p += 8
}
}
}
return true
})
}
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
// Windows Media Foundation (and other strict parsers) reject the QuickTime
// flavor for AC-4 even when dac4 is present.
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
if ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp"); ok {
if ftyp.body()+4 <= int64(len(data)) {
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
}
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
if string(data[p:p+4]) == "qt " {
copy(data[p:p+4], []byte("isom"))
}
}
}
loc, ok := locateAC4Entry(data)
if !ok {
return data
}
entry := loc.entry
verPos := entry.body() + 8
if verPos+2 > entry.end() {
return data
}
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
return data // already v0 (or v2, left untouched)
}
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
extStart := entry.body() + 8 + 20
extEnd := extStart + 16
if extEnd > entry.end() {
return data
}
delta := int64(-16)
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
shiftChunkOffsets(data, loc.chain[0], extStart, delta)
for _, b := range loc.chain {
growBoxSize(data, b, delta)
}
growBoxSize(data, entry, delta)
out := make([]byte, 0, len(data)-16)
out = append(out, data[:extStart]...)
out = append(out, data[extEnd:]...)
return out
}
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
// moov still carries it). No-op when the file has no AC-4 track.
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
dst, err := os.ReadFile(decryptedPath)
if err != nil {
return err
}
if _, ok := locateAC4Entry(dst); !ok {
return nil // not an AC-4 file; nothing to do
}
dst = normalizeQuickTimeAudioToMP4(dst)
loc, ok := locateAC4Entry(dst)
if !ok {
return nil
}
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
if !ok {
return fmt.Errorf("malformed ac-4 sample entry")
}
childStart := loc.entry.body() + hdrLen
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
// Already has dac4; still persist any normalization changes.
return os.WriteFile(decryptedPath, dst, 0o644)
}
src, err := os.ReadFile(sourcePath)
if err != nil {
return err
}
srcMoov, ok := findChildMP4(src, 0, int64(len(src)), "moov")
if !ok {
return fmt.Errorf("source has no moov")
}
dac4Box, ok := findBoxBySignature(src, srcMoov.body(), srcMoov.end(), "dac4")
if !ok {
return fmt.Errorf("dac4 not found in source")
}
dac4 := append([]byte{}, src[dac4Box.offset:dac4Box.end()]...)
insertPos := childStart
delta := int64(len(dac4))
shiftChunkOffsets(dst, loc.chain[0], insertPos, delta)
for _, b := range loc.chain {
growBoxSize(dst, b, delta)
}
growBoxSize(dst, loc.entry, delta)
out := make([]byte, 0, len(dst)+len(dac4))
out = append(out, dst[:insertPos]...)
out = append(out, dac4...)
out = append(out, dst[insertPos:]...)
return os.WriteFile(decryptedPath, out, 0o644)
}
-76
View File
@@ -1,76 +0,0 @@
package gobackend
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func mp4TestBox(typ string, body []byte) []byte {
out := make([]byte, 8+len(body))
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
copy(out[4:8], typ)
copy(out[8:], body)
return out
}
func mp4TestAC4Tree(entryBody []byte) []byte {
entry := mp4TestBox("ac-4", entryBody)
stsdBody := append([]byte{
0, 0, 0, 0, // version/flags
0, 0, 0, 1, // entry_count
}, entry...)
stsd := mp4TestBox("stsd", stsdBody)
stbl := mp4TestBox("stbl", stsd)
minf := mp4TestBox("minf", stbl)
mdia := mp4TestBox("mdia", minf)
trak := mp4TestBox("trak", mdia)
moov := mp4TestBox("moov", trak)
return moov
}
func shortAC4SampleEntryBody(version uint16) []byte {
body := make([]byte, 10)
binary.BigEndian.PutUint16(body[8:10], version)
return body
}
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
defer func() {
if r := recover(); r != nil {
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
}
}()
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
if !bytes.Equal(got, input) {
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
}
}
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
dir := t.TempDir()
decryptedPath := filepath.Join(dir, "decrypted.mp4")
sourcePath := filepath.Join(dir, "source.mp4")
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
t.Fatal(err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
}
}()
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
t.Fatal("expected malformed AC-4 sample entry error")
}
}
-182
View File
@@ -1,182 +0,0 @@
package gobackend
import (
"encoding/binary"
"encoding/json"
"os"
"strconv"
"strings"
)
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
// fields are strings because they arrive as a JSON-encoded map of strings.
type ac4Metadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumArtist string `json:"albumArtist"`
Date string `json:"date"`
Genre string `json:"genre"`
Composer string `json:"composer"`
TrackNumber string `json:"trackNumber"`
TotalTracks string `json:"totalTracks"`
DiscNumber string `json:"discNumber"`
TotalDiscs string `json:"totalDiscs"`
ISRC string `json:"isrc"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Lyrics string `json:"lyrics"`
}
func atoiSafe(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0
}
return n
}
func itunesTextTag(atomType, value string) []byte {
data := make([]byte, 8+len(value))
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
copy(data[8:], []byte(value))
return buildM4AAtom(atomType, buildM4AAtom("data", data))
}
func itunesNumberPairTag(atomType string, number, total int) []byte {
payload := make([]byte, 8)
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
data := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
copy(data[8:], payload)
return buildM4AAtom(atomType, buildM4AAtom("data", data))
}
func itunesCoverTag(image []byte) []byte {
typeCode := uint32(13) // JPEG
if len(image) >= 8 &&
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
typeCode = 14 // PNG
}
data := make([]byte, 8+len(image))
binary.BigEndian.PutUint32(data[0:4], typeCode)
copy(data[8:], image)
return buildM4AAtom("covr", buildM4AAtom("data", data))
}
func itunesMetadataHandler() []byte {
payload := make([]byte, 0, 25)
payload = append(payload, 0, 0, 0, 0) // version + flags
payload = append(payload, 0, 0, 0, 0) // pre_defined
payload = append(payload, []byte("mdir")...) // handler type
payload = append(payload, []byte("appl")...) // reserved[0]
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
payload = append(payload, 0) // empty name
return buildM4AAtom("hdlr", payload)
}
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
ilst := make([]byte, 0, 256)
add := func(atomType, value string) {
if strings.TrimSpace(value) != "" {
ilst = append(ilst, itunesTextTag(atomType, value)...)
}
}
add("\xa9nam", md.Title)
add("\xa9ART", md.Artist)
add("\xa9alb", md.Album)
add("aART", md.AlbumArtist)
add("\xa9day", md.Date)
add("\xa9gen", md.Genre)
add("\xa9wrt", md.Composer)
if tn := atoiSafe(md.TrackNumber); tn > 0 {
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
}
if dn := atoiSafe(md.DiscNumber); dn > 0 {
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
}
if strings.TrimSpace(md.ISRC) != "" {
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
}
if strings.TrimSpace(md.Label) != "" {
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
}
if strings.TrimSpace(md.Copyright) != "" {
add("cprt", md.Copyright)
}
if strings.TrimSpace(md.Lyrics) != "" {
add("\xa9lyr", md.Lyrics)
}
if len(cover) > 0 {
ilst = append(ilst, itunesCoverTag(cover)...)
}
ilstBox := buildM4AAtom("ilst", ilst)
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
metaPayload = append(metaPayload, ilstBox...)
meta := buildM4AAtom("meta", metaPayload)
return buildM4AAtom("udta", meta)
}
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
// the moov of an MP4 buffer and returns the rewritten bytes.
func writeMP4iTunesMetadata(data []byte, md ac4Metadata, cover []byte) []byte {
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
if !ok {
return data
}
newUdta := buildITunesUdta(md, cover)
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
delta := int64(len(newUdta)) - udta.size
shiftChunkOffsets(data, moov, udta.offset, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:udta.offset]...)
out = append(out, newUdta...)
out = append(out, data[udta.end():]...)
return out
}
delta := int64(len(newUdta))
insertPos := moov.end()
shiftChunkOffsets(data, moov, insertPos, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:insertPos]...)
out = append(out, newUdta...)
out = append(out, data[insertPos:]...)
return out
}
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
// true when the file was an AC-4 track and metadata was written; false when the
// file is not AC-4 (the caller should fall back to its normal metadata path).
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
data, err := os.ReadFile(decryptedPath)
if err != nil {
return false, err
}
if _, ok := locateAC4Entry(data); !ok {
return false, nil
}
var md ac4Metadata
if strings.TrimSpace(metadataJSON) != "" {
_ = json.Unmarshal([]byte(metadataJSON), &md)
}
var cover []byte
if strings.TrimSpace(coverPath) != "" {
if b, err := os.ReadFile(coverPath); err == nil {
cover = b
}
}
out := writeMP4iTunesMetadata(data, md, cover)
if err := os.WriteFile(decryptedPath, out, 0o644); err != nil {
return false, err
}
return true, nil
}
+15 -12
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"strconv"
"strings"
)
@@ -56,6 +57,7 @@ func ReadAPETags(filePath string) (*APETag, error) {
return nil, fmt.Errorf("file too small for APE tag")
}
// Try to find APE tag footer at the end of file.
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
if err == nil {
@@ -254,6 +256,7 @@ func findExistingAPETagSize(filePath string) (int64, error) {
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
// Check if there's also a header (tagSize only covers items + footer)
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
totalSize := tagSize
if hasHeader {
@@ -314,6 +317,7 @@ 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...)
@@ -363,9 +367,12 @@ func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
case "DATE":
metadata.Date = value
case "TRACK", "TRACKNUMBER":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
// APE track format can be "3" or "3/12"
trackNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
metadata.TrackNumber = trackNum
case "DISC", "DISCNUMBER":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
discNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
metadata.DiscNumber = discNum
case "ISRC":
metadata.ISRC = value
case "LYRICS", "UNSYNCEDLYRICS":
@@ -418,10 +425,10 @@ func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
addItem("Year", metadata.Year)
}
if metadata.TrackNumber > 0 {
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
addItem("Track", strconv.Itoa(metadata.TrackNumber))
}
if metadata.DiscNumber > 0 {
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
addItem("Disc", strconv.Itoa(metadata.DiscNumber))
}
addItem("ISRC", metadata.ISRC)
addItem("Lyrics", metadata.Lyrics)
@@ -446,7 +453,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
"artist": "ARTIST",
"album": "ALBUM",
"album_artist": "ALBUM ARTIST",
"date": "DATE",
"date": "YEAR",
"genre": "GENRE",
"track_number": "TRACK",
"disc_number": "DISC",
@@ -468,7 +475,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
}
}
// Some fields have reader aliases that must also be cleared when the
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
// canonical key is updated (e.g. "Year" writer ↔ DATE/YEAR reader,
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
if _, present := fields["date"]; present {
@@ -477,15 +484,9 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
if _, present := fields["disc_number"]; present {
result["DISCNUMBER"] = struct{}{}
}
if _, present := fields["disc_total"]; present {
result["DISCNUMBER"] = struct{}{}
}
if _, present := fields["track_number"]; present {
result["TRACKNUMBER"] = struct{}{}
}
if _, present := fields["track_total"]; present {
result["TRACKNUMBER"] = struct{}{}
}
if _, present := fields["album_artist"]; present {
result["ALBUMARTIST"] = struct{}{}
}
@@ -508,6 +509,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
// deletion: the caller sends an empty value which is not serialized into
// newItems, but the old value must still be dropped.
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
// Build a set of keys being updated (upper-case for case-insensitive match)
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
for k := range overrideKeys {
combined[strings.ToUpper(k)] = struct{}{}
@@ -535,6 +537,7 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
return nil, fmt.Errorf("file too small for APE tag")
}
// Try footer at end of file
footer := make([]byte, apeTagHeaderSize)
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
return nil, fmt.Errorf("failed to read APE footer: %w", err)
-121
View File
@@ -1,121 +0,0 @@
package gobackend
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sample.ape")
if err := os.WriteFile(path, []byte("audio-data"), 0600); err != nil {
t.Fatalf("write sample: %v", err)
}
metadata := &AudioMetadata{
Title: "Song",
Artist: "Artist",
Album: "Album",
AlbumArtist: "Album Artist",
Genre: "Pop",
Date: "2026",
TrackNumber: 3,
TotalTracks: 12,
DiscNumber: 1,
TotalDiscs: 2,
ISRC: "USRC17607839",
Lyrics: "lyrics",
Label: "Label",
Copyright: "Copyright",
Composer: "Composer",
Comment: "Comment",
ReplayGainTrackGain: "-6.50 dB",
ReplayGainTrackPeak: "0.98",
ReplayGainAlbumGain: "-5.00 dB",
ReplayGainAlbumPeak: "0.99",
}
items := AudioMetadataToAPEItems(metadata)
if len(items) == 0 {
t.Fatal("expected APE items")
}
tag := &APETag{Items: append(items, APETagItem{Key: "Custom", Value: "Keep"})}
if err := WriteAPETags(path, tag); err != nil {
t.Fatalf("WriteAPETags: %v", err)
}
readTag, err := ReadAPETags(path)
if err != nil {
t.Fatalf("ReadAPETags: %v", err)
}
if readTag.Version != apeTagVersion2 {
t.Fatalf("version = %d", readTag.Version)
}
readMetadata := APETagToAudioMetadata(readTag)
if readMetadata.Title != "Song" || readMetadata.TrackNumber != 3 || readMetadata.TotalTracks != 12 {
t.Fatalf("metadata = %#v", readMetadata)
}
readerTag, err := ReadAPETagsFromReader(bytes.NewReader(mustReadFile(t, path)), int64(len(mustReadFile(t, path))))
if err != nil {
t.Fatalf("ReadAPETagsFromReader: %v", err)
}
if len(readerTag.Items) != len(readTag.Items) {
t.Fatalf("reader items = %d, file items = %d", len(readerTag.Items), len(readTag.Items))
}
override := apeKeysFromFields(map[string]string{"title": "", "lyrics": "", "disc_total": ""})
merged := MergeAPEItems(readTag.Items, []APETagItem{{Key: "Title", Value: "New Song"}}, override)
mergedMeta := APETagToAudioMetadata(&APETag{Items: merged})
if mergedMeta.Title != "New Song" {
t.Fatalf("merged title = %q", mergedMeta.Title)
}
if mergedMeta.Lyrics != "" {
t.Fatalf("expected lyrics cleared, got %q", mergedMeta.Lyrics)
}
if err := WriteAPETags(path, &APETag{Items: []APETagItem{{Key: "Title", Value: "Replacement"}}}); err != nil {
t.Fatalf("replace APE tags: %v", err)
}
replaced, err := ReadAPETags(path)
if err != nil {
t.Fatalf("read replacement: %v", err)
}
if got := APETagToAudioMetadata(replaced).Title; got != "Replacement" {
t.Fatalf("replacement title = %q", got)
}
if _, err := marshalAPETag(nil); err == nil {
t.Fatal("expected empty tag error")
}
if _, err := ReadAPETags(filepath.Join(dir, "missing.ape")); err == nil {
t.Fatal("expected missing file error")
}
if _, err := ReadAPETagsFromReader(bytes.NewReader([]byte("short")), 5); err == nil {
t.Fatal("expected small reader error")
}
}
func TestAPETagInvalidFooterBranches(t *testing.T) {
footer := buildAPEHeaderFooter(9999, apeTagHeaderSize, 1, 0)
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
t.Fatal("expected unsupported version")
}
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize-1, 1, 0)
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
t.Fatal("expected small tag size")
}
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1001, 0)
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
t.Fatal("expected too many items")
}
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1, apeTagFlagHeader)
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
t.Fatal("expected header flag error")
}
}
+9 -28
View File
@@ -21,9 +21,7 @@ type AudioMetadata struct {
Year string
Date string
TrackNumber int
TotalTracks int
DiscNumber int
TotalDiscs int
ISRC string
Lyrics string
Label string
@@ -175,9 +173,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
case "TCO":
metadata.Genre = cleanGenre(value)
case "TRK":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
metadata.TrackNumber = parseTrackNumber(value)
case "TPA":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
metadata.DiscNumber = parseTrackNumber(value)
case "TCM":
metadata.Composer = value
case "TPB":
@@ -294,9 +292,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
case "TCON":
metadata.Genre = cleanGenre(value)
case "TRCK":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
metadata.TrackNumber = parseTrackNumber(value)
case "TPOS":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
metadata.DiscNumber = parseTrackNumber(value)
case "TSRC":
metadata.ISRC = value
case "TCOM":
@@ -582,26 +580,12 @@ func cleanGenre(genre string) string {
}
func parseTrackNumber(s string) int {
num, _ := parseIndexPair(s)
return num
}
func parseIndexPair(s string) (int, int) {
s = strings.TrimSpace(s)
if s == "" {
return 0, 0
}
first := s
second := ""
if idx := strings.Index(s, "/"); idx > 0 {
first = s[:idx]
second = s[idx+1:]
s = s[:idx]
}
num, _ := strconv.Atoi(strings.TrimSpace(first))
total, _ := strconv.Atoi(strings.TrimSpace(second))
return num, total
num, _ := strconv.Atoi(s)
return num
}
func removeUnsync(data []byte) []byte {
@@ -1053,9 +1037,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
case "GENRE":
metadata.Genre = value
case "TRACKNUMBER", "TRACK":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
metadata.TrackNumber = parseTrackNumber(value)
case "DISCNUMBER", "DISC":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
metadata.DiscNumber = parseTrackNumber(value)
case "ISRC":
metadata.ISRC = value
case "COMPOSER":
@@ -1624,9 +1608,6 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
}
return data, mimeType, nil
case ".wav", ".aiff", ".aif", ".aifc":
return extractWAVAIFFCover(filePath)
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
}
@@ -1,517 +0,0 @@
package gobackend
import (
"bytes"
"encoding/base64"
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAudioMetadataID3ParsingBranches(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tagged.mp3")
tag := buildID3v23Tag(
id3TextFrame("TIT2", "Title"),
id3TextFrame("TPE1", "Artist"),
id3TextFrame("TPE2", "Album Artist"),
id3TextFrame("TALB", "Album"),
id3TextFrame("TDRC", "2026-05-04"),
id3TextFrame("TCON", "(13)Pop"),
id3TextFrame("TRCK", "4/12"),
id3TextFrame("TPOS", "1/2"),
id3TextFrame("TSRC", "USRC17607839"),
id3TextFrame("TCOM", "Composer"),
id3TextFrame("TPUB", "Label"),
id3TextFrame("TCOP", "Copyright"),
id3CommentFrame("COMM", "Comment"),
id3CommentFrame("USLT", "Lyrics"),
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.50 dB"),
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_PEAK", "0.98"),
)
if err := os.WriteFile(path, append(tag, []byte("audio")...), 0600); err != nil {
t.Fatalf("write ID3v2: %v", err)
}
meta, err := ReadID3Tags(path)
if err != nil {
t.Fatalf("ReadID3Tags: %v", err)
}
if meta.Title != "Title" || meta.TrackNumber != 4 || meta.TotalTracks != 12 || meta.Genre != "Pop" {
t.Fatalf("metadata = %#v", meta)
}
if meta.Comment != "Comment" || meta.Lyrics != "Lyrics" || meta.ReplayGainTrackGain == "" {
t.Fatalf("metadata comments/lyrics/replaygain = %#v", meta)
}
id3v1Path := filepath.Join(dir, "id3v1.mp3")
if err := os.WriteFile(id3v1Path, append([]byte("audio"), buildID3v1Tag("V1 Title", "V1 Artist", "V1 Album", "1999", 7, 13)...), 0600); err != nil {
t.Fatalf("write ID3v1: %v", err)
}
v1, err := ReadID3Tags(id3v1Path)
if err != nil {
t.Fatalf("ReadID3Tags v1: %v", err)
}
if v1.Title != "V1 Title" || v1.Artist != "V1 Artist" || v1.Genre == "" {
t.Fatalf("v1 = %#v", v1)
}
v22Path := filepath.Join(dir, "id3v22.mp3")
v22 := buildID3v22Tag(
id3v22TextFrame("TT2", "V22 Title"),
id3v22TextFrame("TP1", "V22 Artist"),
id3v22TextFrame("TRK", "2/5"),
id3v22CommentFrame("ULT", "V22 Lyrics"),
)
if err := os.WriteFile(v22Path, append(v22, []byte("audio")...), 0600); err != nil {
t.Fatalf("write ID3v2.2: %v", err)
}
v22Meta, err := ReadID3Tags(v22Path)
if err != nil {
t.Fatalf("ReadID3Tags v2.2: %v", err)
}
if v22Meta.Title != "V22 Title" || v22Meta.Artist != "V22 Artist" || v22Meta.Lyrics != "V22 Lyrics" {
t.Fatalf("v22 = %#v", v22Meta)
}
if got := decodeUTF16([]byte{0xff, 0xfe, 'H', 0, 'i', 0}); got != "Hi" {
t.Fatalf("decodeUTF16 = %q", got)
}
if got := decodeUTF16BE([]byte{0, 'O', 0, 'K'}); got != "OK" {
t.Fatalf("decodeUTF16BE = %q", got)
}
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)
}
if got := extendedHeaderSize([]byte{0, 0, 0, 6, 0, 0, 0, 0, 0, 0}, 3); got != 10 {
t.Fatalf("extendedHeaderSize = %d", got)
}
if got := syncsafeToInt([]byte{0, 0, 2, 0}); got != 256 {
t.Fatalf("syncsafe = %d", got)
}
}
func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0}
if detectCoverMIME("cover.jpg", png) != "image/png" || detectCoverMIME("cover.webp", []byte("RIFFxxxxWEBPdata")) != "image/webp" {
t.Fatal("cover MIME detection mismatch")
}
if _, err := buildPictureBlock("", nil); err == nil {
t.Fatal("expected empty picture block error")
}
apic := append([]byte{3}, []byte("image/png\x00")...)
apic = append(apic, 3, 0)
apic = append(apic, png...)
image, mime := parseAPICFrame(apic, 3)
if mime != "image/png" || !bytes.Equal(image, png) {
t.Fatalf("APIC = %s/%v", mime, image)
}
pic := append([]byte{0}, []byte("PNG")...)
pic = append(pic, 3, 0)
pic = append(pic, png...)
image, mime = parseAPICFrame(pic, 2)
if mime != "image/png" || !bytes.Equal(image, png) {
t.Fatalf("PIC = %s/%v", mime, image)
}
frame := make([]byte, 10)
copy(frame[:4], "APIC")
binary.BigEndian.PutUint32(frame[4:8], uint32(len(apic)))
tag := append(frame, apic...)
header := []byte{'I', 'D', '3', 3, 0, 0, byte(len(tag) >> 21), byte(len(tag) >> 14), byte(len(tag) >> 7), byte(len(tag))}
mp3CoverPath := filepath.Join(t.TempDir(), "cover.mp3")
if err := os.WriteFile(mp3CoverPath, append(append(header, tag...), []byte("audio")...), 0600); err != nil {
t.Fatal(err)
}
extracted, extractedMIME, err := extractMP3CoverArt(mp3CoverPath)
if err != nil || extractedMIME != "image/png" || !bytes.Equal(extracted, png) {
t.Fatalf("extractMP3CoverArt = %s/%v/%v", extractedMIME, extracted, err)
}
var picture bytes.Buffer
binary.Write(&picture, binary.BigEndian, uint32(3))
binary.Write(&picture, binary.BigEndian, uint32(len("image/png")))
picture.WriteString("image/png")
binary.Write(&picture, binary.BigEndian, uint32(0))
binary.Write(&picture, binary.BigEndian, uint32(1))
binary.Write(&picture, binary.BigEndian, uint32(1))
binary.Write(&picture, binary.BigEndian, uint32(32))
binary.Write(&picture, binary.BigEndian, uint32(0))
binary.Write(&picture, binary.BigEndian, uint32(len(png)))
picture.Write(png)
flacImage, flacMIME := parseFLACPictureBlock(picture.Bytes())
if flacMIME != "image/png" || !bytes.Equal(flacImage, png) {
t.Fatalf("FLAC picture = %s/%v", flacMIME, flacImage)
}
comment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture.Bytes())
var vorbis bytes.Buffer
binary.Write(&vorbis, binary.LittleEndian, uint32(6))
vorbis.WriteString("vendor")
binary.Write(&vorbis, binary.LittleEndian, uint32(1))
binary.Write(&vorbis, binary.LittleEndian, uint32(len(comment)))
vorbis.WriteString(comment)
commentImage, commentMIME := extractPictureFromVorbisComments(vorbis.Bytes())
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")
}
if detectOggStreamType([][]byte{append([]byte{1}, []byte("vorbisxxxx")...)}) != oggStreamVorbis {
t.Fatal("expected vorbis stream")
}
mp3Path := filepath.Join(t.TempDir(), "quality.mp3")
audio := append([]byte{0xFF, 0xFB, 0x90, 0x64}, bytes.Repeat([]byte{0}, 2000)...)
if err := os.WriteFile(mp3Path, audio, 0600); err != nil {
t.Fatal(err)
}
quality, err := GetMP3Quality(mp3Path)
if err != nil || quality.SampleRate != 44100 || quality.Bitrate != 128000 {
t.Fatalf("MP3 quality = %#v/%v", quality, err)
}
if _, _, err := extractMP3CoverArt(filepath.Join(t.TempDir(), "missing.mp3")); err == nil {
t.Fatal("expected missing MP3 cover error")
}
}
func TestM4AMetadataAtomHelpers(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tagged.m4a")
cover := []byte{0xFF, 0xD8, 0xFF, 0x00}
ilstPayload := []byte{}
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9nam", "M4A Title")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9ART", "M4A Artist")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9alb", "M4A Album")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("aART", "Album Artist")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9day", "2026")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9gen", "Pop")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9wrt", "Composer")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9cmt", "[ti:Comment Lyrics]")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("cprt", "Copyright")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9lyr", "[00:00.00]M4A Lyrics")...)
ilstPayload = append(ilstPayload, buildM4AIndexTag("trkn", 3, 12)...)
ilstPayload = append(ilstPayload, buildM4AIndexTag("disk", 1, 2)...)
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("ISRC", "USRC17607839")...)
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("LABEL", "Label")...)
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("REPLAYGAIN_TRACK_GAIN", "-6.50 dB")...)
ilstPayload = append(ilstPayload, buildM4AAtom("covr", buildM4AAtom("data", append([]byte{0, 0, 0, 13, 0, 0, 0, 0}, cover...)))...)
fileData := buildM4AFileWithIlst(ilstPayload, true)
if err := os.WriteFile(path, fileData, 0600); err != nil {
t.Fatal(err)
}
meta, err := ReadM4ATags(path)
if err != nil {
t.Fatalf("ReadM4ATags: %v", err)
}
if meta.Title != "M4A Title" || meta.Artist != "M4A Artist" || meta.TrackNumber != 3 || meta.TotalTracks != 12 || meta.ISRC != "USRC17607839" {
t.Fatalf("M4A metadata = %#v", meta)
}
if lyrics, err := extractLyricsFromM4A(path); err != nil || !strings.Contains(lyrics, "M4A Lyrics") {
t.Fatalf("extractLyricsFromM4A = %q/%v", lyrics, err)
}
if image, err := extractCoverFromM4A(path); err != nil || !bytes.Equal(image, cover) {
t.Fatalf("extractCoverFromM4A = %#v/%v", image, err)
}
if pathInfo, err := func() (m4aMetadataPath, error) {
f, err := os.Open(path)
if err != nil {
return m4aMetadataPath{}, err
}
defer f.Close()
info, _ := f.Stat()
return findM4AMetadataPath(f, info.Size())
}(); err != nil || pathInfo.udta == nil {
t.Fatalf("findM4AMetadataPath = %#v/%v", pathInfo, err)
}
if err := EditM4AReplayGain(path, map[string]string{"replaygain_track_gain": "-5.00 dB", "replaygain_track_peak": "0.98"}); err != nil {
t.Fatalf("EditM4AReplayGain: %v", err)
}
edited, err := ReadM4ATags(path)
if err != nil || edited.ReplayGainTrackGain != "-5.00 dB" || edited.ReplayGainTrackPeak != "0.98" {
t.Fatalf("edited M4A = %#v/%v", edited, err)
}
noUdtaPath := filepath.Join(dir, "noudta.m4a")
if err := os.WriteFile(noUdtaPath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "No Udta"), false), 0600); err != nil {
t.Fatal(err)
}
if meta, err := ReadM4ATags(noUdtaPath); err != nil || meta.Title != "No Udta" {
t.Fatalf("ReadM4ATags no udta = %#v/%v", meta, err)
}
if _, err := ReadM4ATags(filepath.Join(dir, "missing.m4a")); err == nil {
t.Fatal("expected missing M4A error")
}
emptyM4A := filepath.Join(dir, "empty.m4a")
if err := os.WriteFile(emptyM4A, buildM4AFileWithIlst(nil, true), 0600); err != nil {
t.Fatal(err)
}
if _, err := ReadM4ATags(emptyM4A); err == nil {
t.Fatal("expected empty M4A tags error")
}
if _, err := extractCoverFromM4A(emptyM4A); err == nil {
t.Fatal("expected missing M4A cover error")
}
if _, err := extractLyricsFromM4A(emptyM4A); err == nil {
t.Fatal("expected missing M4A lyrics error")
}
sidecarAudio := filepath.Join(dir, "sidecar.mp3")
if err := os.WriteFile(sidecarAudio, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte(" [00:00.00]Sidecar "), 0600); err != nil {
t.Fatal(err)
}
if lyrics, err := extractLyricsFromSidecarLRC(sidecarAudio); err != nil || !strings.Contains(lyrics, "Sidecar") {
t.Fatalf("sidecar lyrics = %q/%v", lyrics, err)
}
if !looksLikeEmbeddedLyrics("[ti:Song]") || !looksLikeEmbeddedLyrics("[00:00.00]Line\n[00:01.00]Next") || looksLikeEmbeddedLyrics("plain") {
t.Fatal("embedded lyric heuristic mismatch")
}
if formatIndexValue(3, 12) != "3/12" || formatIndexValue(3, 0) != "3" || formatIndexValue(0, 12) != "" {
t.Fatal("formatIndexValue mismatch")
}
if parsePositiveInt(" 42 ") != 42 || parsePositiveInt("bad") != 0 {
t.Fatal("parsePositiveInt mismatch")
}
if !hasMapKey(map[string]string{"x": "y"}, "x") {
t.Fatal("expected map key")
}
if _, ok := parseReplayGainDb("-6.50 dB"); !ok {
t.Fatal("expected ReplayGain dB parse")
}
if _, ok := parseReplayGainPeak("0.98"); !ok {
t.Fatal("expected ReplayGain peak parse")
}
if norm := buildITunNORMTag("-6.50 dB", "0.98"); norm == "" {
t.Fatal("expected iTunNORM")
}
if fields := collectM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-6 dB", "replaygain_track_peak": "0.9"}); fields["iTunNORM"] == "" {
t.Fatalf("ReplayGain fields = %#v", fields)
}
qualityPath := filepath.Join(dir, "quality-alac.m4a")
mvhd := make([]byte, 20)
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
sampleEntry := make([]byte, 32)
copy(sampleEntry[0:4], "alac")
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
sampleEntry[28] = 0xAC
sampleEntry[29] = 0x44
alacConfig := make([]byte, 24)
alacConfig[5] = 24
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
t.Fatal(err)
}
if quality, err := GetM4AQuality(qualityPath); err != nil || quality.BitDepth != 24 || quality.SampleRate != 44100 || quality.Duration != 180 {
t.Fatalf("GetM4AQuality = %#v/%v", quality, err)
}
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
}
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
copy(sampleEntry[0:4], "mp4a")
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
t.Fatal(err)
}
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
}
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
zeroMvhd := make([]byte, 20)
eac3SampleEntry := make([]byte, 32)
copy(eac3SampleEntry[0:4], "ec-3")
eac3SampleEntry[28] = 0xBB
eac3SampleEntry[29] = 0x80
mdhd := make([]byte, 20)
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
eac3QualityFile := append(
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
buildM4AAtom("moov", append(
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
eac3SampleEntry...,
))...,
)
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
t.Fatal(err)
}
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
}
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
t.Fatal("short ALAC config should not parse")
}
alac := make([]byte, 24)
alac[5] = 16
binary.BigEndian.PutUint32(alac[20:24], 48000)
if depth, rate, ok := parseALACSpecificConfig(alac); !ok || depth != 16 || rate != 48000 {
t.Fatalf("ALAC config = %d/%d/%v", depth, rate, ok)
}
}
func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
dir := t.TempDir()
opusHead := make([]byte, 19)
copy(opusHead[0:8], "OpusHead")
binary.LittleEndian.PutUint16(opusHead[10:12], 312)
binary.LittleEndian.PutUint32(opusHead[12:16], 48000)
var comments bytes.Buffer
binary.Write(&comments, binary.LittleEndian, uint32(6))
comments.WriteString("vendor")
entries := []string{
"TITLE=Ogg Title",
"ARTIST=Artist",
"ALBUMARTIST=Album Artist",
"TRACKNUMBER=2/9",
"DISCNUMBER=1/2",
"LYRICS=[00:00.00]Ogg Lyrics",
}
binary.Write(&comments, binary.LittleEndian, uint32(len(entries)))
for _, entry := range entries {
binary.Write(&comments, binary.LittleEndian, uint32(len(entry)))
comments.WriteString(entry)
}
opusTags := append([]byte("OpusTags"), comments.Bytes()...)
oggPath := filepath.Join(dir, "tagged.opus")
oggData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, opusTags)...)
if err := os.WriteFile(oggPath, oggData, 0600); err != nil {
t.Fatal(err)
}
quality, err := GetOggQuality(oggPath)
if err != nil || quality.SampleRate != 48000 || quality.Duration != 1 {
t.Fatalf("GetOggQuality = %#v/%v", quality, err)
}
meta, err := ReadOggVorbisComments(oggPath)
if err != nil || meta.Title != "Ogg Title" || meta.TrackNumber != 2 || meta.TotalTracks != 9 {
t.Fatalf("ReadOggVorbisComments = %#v/%v", meta, err)
}
picture := buildTestFLACPictureBlock([]byte{0x89, 0x50, 0x4E, 0x47}, "image/png")
pictureComment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture)
var coverComments bytes.Buffer
binary.Write(&coverComments, binary.LittleEndian, uint32(6))
coverComments.WriteString("vendor")
binary.Write(&coverComments, binary.LittleEndian, uint32(1))
binary.Write(&coverComments, binary.LittleEndian, uint32(len(pictureComment)))
coverComments.WriteString(pictureComment)
coverPath := filepath.Join(dir, "cover.opus")
coverData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, append([]byte("OpusTags"), coverComments.Bytes()...))...)
if err := os.WriteFile(coverPath, coverData, 0600); err != nil {
t.Fatal(err)
}
if image, mime, err := extractOggCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
t.Fatalf("extractOggCoverArt = %s/%#v/%v", mime, image, err)
}
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)
}
if data := mustReadFile(t, extractedCoverPath); len(data) == 0 {
t.Fatal("expected extracted cover data")
}
cachePath, err := SaveCoverToCacheWithHintAndKey(coverPath, "cover.opus", dir, "key")
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")
}
badPath := filepath.Join(dir, "bad.ogg")
if err := os.WriteFile(badPath, []byte("bad"), 0600); err != nil {
t.Fatal(err)
}
if _, err := GetOggQuality(badPath); err == nil {
t.Fatal("expected invalid Ogg quality error")
}
}
func buildM4ADataPayload(payload []byte) []byte {
return append([]byte{0, 0, 0, 1, 0, 0, 0, 0}, payload...)
}
func buildM4ATextTag(atomType, value string) []byte {
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload([]byte(value))))
}
func buildM4AIndexTag(atomType string, number, total int) []byte {
payload := []byte{0, 0, 0, byte(number), 0, byte(total), 0, 0}
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload(payload)))
}
func buildM4AFileWithIlst(ilstPayload []byte, withUdta bool) []byte {
ilst := buildM4AAtom("ilst", ilstPayload)
meta := buildM4AAtom("meta", append([]byte{0, 0, 0, 0}, ilst...))
moovPayload := meta
if withUdta {
moovPayload = buildM4AAtom("udta", meta)
}
return append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", moovPayload)...)
}
func buildOggPage(headerType byte, granule uint64, packet []byte) []byte {
header := make([]byte, 27)
copy(header[0:4], "OggS")
header[4] = 0
header[5] = headerType
binary.LittleEndian.PutUint64(header[6:14], granule)
header[26] = 1
return append(append(header, byte(len(packet))), packet...)
}
func buildTestFLACPictureBlock(image []byte, mime string) []byte {
var picture bytes.Buffer
binary.Write(&picture, binary.BigEndian, uint32(3))
binary.Write(&picture, binary.BigEndian, uint32(len(mime)))
picture.WriteString(mime)
binary.Write(&picture, binary.BigEndian, uint32(0))
binary.Write(&picture, binary.BigEndian, uint32(1))
binary.Write(&picture, binary.BigEndian, uint32(1))
binary.Write(&picture, binary.BigEndian, uint32(32))
binary.Write(&picture, binary.BigEndian, uint32(0))
binary.Write(&picture, binary.BigEndian, uint32(len(image)))
picture.Write(image)
return picture.Bytes()
}
+1 -105
View File
@@ -9,23 +9,14 @@ import (
// ErrDownloadCancelled is returned when a download is cancelled by the user.
var ErrDownloadCancelled = errors.New("download cancelled")
// ErrExtensionRequestCancelled is returned when a UI-driven extension request
// is superseded by a newer home/search request.
var ErrExtensionRequestCancelled = errors.New("extension request cancelled")
type cancelEntry struct {
ctx context.Context
cancel context.CancelFunc
canceled bool
refs int
}
var (
cancelMu sync.Mutex
cancelMap = make(map[string]*cancelEntry)
extensionRequestCancelMu sync.Mutex
extensionRequestCancelMap = make(map[string]*cancelEntry)
)
func initDownloadCancel(itemID string) context.Context {
@@ -36,25 +27,10 @@ func initDownloadCancel(itemID string) context.Context {
cancelMu.Lock()
defer cancelMu.Unlock()
if entry, ok := cancelMap[itemID]; ok {
if entry.ctx == nil {
ctx, cancel := context.WithCancel(context.Background())
entry.ctx = ctx
entry.cancel = cancel
if entry.canceled && entry.cancel != nil {
entry.cancel()
}
}
entry.refs++
return entry.ctx
}
ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{
ctx: ctx,
cancel: cancel,
canceled: false,
refs: 1,
}
return ctx
}
@@ -97,86 +73,6 @@ func clearDownloadCancel(itemID string) {
}
cancelMu.Lock()
if entry, ok := cancelMap[itemID]; ok {
entry.refs--
if entry.refs <= 0 {
delete(cancelMap, itemID)
}
}
delete(cancelMap, itemID)
cancelMu.Unlock()
}
func initExtensionRequestCancel(requestID string) context.Context {
if requestID == "" {
return context.Background()
}
extensionRequestCancelMu.Lock()
defer extensionRequestCancelMu.Unlock()
if entry, ok := extensionRequestCancelMap[requestID]; ok {
if entry.ctx == nil {
ctx, cancel := context.WithCancel(context.Background())
entry.ctx = ctx
entry.cancel = cancel
if entry.canceled && entry.cancel != nil {
entry.cancel()
}
}
entry.refs++
return entry.ctx
}
ctx, cancel := context.WithCancel(context.Background())
extensionRequestCancelMap[requestID] = &cancelEntry{
ctx: ctx,
cancel: cancel,
canceled: false,
refs: 1,
}
return ctx
}
func cancelExtensionRequest(requestID string) {
if requestID == "" {
return
}
extensionRequestCancelMu.Lock()
if entry, ok := extensionRequestCancelMap[requestID]; ok {
entry.canceled = true
if entry.cancel != nil {
entry.cancel()
}
} else {
extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true}
}
extensionRequestCancelMu.Unlock()
}
func isExtensionRequestCancelled(requestID string) bool {
if requestID == "" {
return false
}
extensionRequestCancelMu.Lock()
entry, ok := extensionRequestCancelMap[requestID]
canceled := ok && entry.canceled
extensionRequestCancelMu.Unlock()
return canceled
}
func clearExtensionRequestCancel(requestID string) {
if requestID == "" {
return
}
extensionRequestCancelMu.Lock()
if entry, ok := extensionRequestCancelMap[requestID]; ok {
entry.refs--
if entry.refs <= 0 {
delete(extensionRequestCancelMap, requestID)
}
}
extensionRequestCancelMu.Unlock()
}
+1 -3
View File
@@ -19,8 +19,6 @@ var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
var qobuzSizeRegex = regexp.MustCompile(`_\d+\.jpg$`)
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
@@ -137,7 +135,7 @@ func upgradeQobuzCover(coverURL string) string {
return coverURL
}
upgraded := qobuzSizeRegex.ReplaceAllString(coverURL, "_max.jpg")
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
if upgraded != coverURL {
GoLog("[Cover] Qobuz: upgraded to max resolution")
}
-401
View File
@@ -1,401 +0,0 @@
package gobackend
import (
"archive/zip"
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func newTestLoadedExtension(t *testing.T, types ...ExtensionType) *loadedExtension {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.js"), []byte(testExtensionJS), 0600); err != nil {
t.Fatalf("write index.js: %v", err)
}
return &loadedExtension{
ID: "coverage-ext",
Manifest: &ExtensionManifest{
Name: "coverage-ext",
Description: "Coverage extension",
Version: "1.0.0",
Types: types,
Permissions: ExtensionPermissions{File: true, Network: []string{"example.test"}},
SearchBehavior: &SearchBehaviorConfig{
Enabled: true,
Placeholder: "Search coverage",
Primary: true,
Icon: "search",
},
URLHandler: &URLHandlerConfig{Enabled: true, Patterns: []string{"https://example.test/"}},
TrackMatching: &TrackMatchingConfig{CustomMatching: true},
PostProcessing: &PostProcessingConfig{
Enabled: true,
Hooks: []PostProcessingHook{{ID: "hook", Name: "Hook", DefaultEnabled: true, SupportedFormats: []string{"flac"}}},
},
},
Enabled: true,
SourceDir: dir,
DataDir: t.TempDir(),
}
}
const testExtensionJS = `
function track(id) {
return {
id: id,
name: "Track " + id,
artists: "Artist",
albumName: "Album",
albumArtist: "Album Artist",
durationMs: 180000,
coverUrl: "https://example.test/cover.jpg",
releaseDate: "2026-05-04",
trackNumber: 1,
totalTracks: 10,
discNumber: 1,
totalDiscs: 1,
isrc: "USRC17607839",
itemType: "track",
albumType: "album",
tidalId: "tidal-1",
qobuzId: "qobuz-1",
deezerId: "deezer-1",
spotifyId: "spotify:track:1",
externalLinks: { tidal: "https://tidal.example/1" },
label: "Label",
copyright: "Copyright",
genre: "Pop",
composer: "Composer",
audioQuality: "FLAC 24-bit",
audioModes: "DOLBY_ATMOS"
};
}
registerExtension({
searchTracks: function(query, limit) {
return { tracks: [track("search-1")], total: 1 };
},
customSearch: function(query, options) {
var t = track("custom-1");
t.name = "Custom " + query;
return [t];
},
getHomeFeed: function() {
return [{ id: "home-1", title: "Home", tracks: [track("home-track")] }];
},
getBrowseCategories: function() {
return [{ id: "cat-1", title: "Category" }];
},
getTrack: function(id) {
return track(id);
},
getAlbum: function(id) {
return {
id: id,
name: "Album " + id,
artists: "Artist",
artistId: "artist-1",
coverUrl: "https://example.test/album.jpg",
releaseDate: "2026-05-04",
totalTracks: 1,
albumType: "album",
tracks: [track("album-track")]
};
},
getPlaylist: function(id) {
return {
id: id,
name: "Playlist " + id,
artists: "Owner",
coverUrl: "https://example.test/playlist.jpg",
totalTracks: 1,
tracks: [track("playlist-track")]
};
},
getArtist: function(id) {
return {
id: id,
name: "Artist",
imageUrl: "https://example.test/artist.jpg",
headerImage: "https://example.test/header.jpg",
listeners: 123,
albums: [{ id: "album-1", name: "Album", artists: "Artist", totalTracks: 1 }],
releases: [{ id: "release-1", name: "Release", artists: "Artist", totalTracks: 1, tracks: [track("release-track")] }],
topTracks: [track("top-track")]
};
},
enrichTrack: function(input) {
var t = track(input.id || "enriched");
t.name = "Enriched";
return t;
},
checkAvailability: function(isrc, name, artist, ids) {
return { available: true, reason: "ok", trackId: "download-track", skipFallback: true };
},
getDownloadUrl: function(id, quality) {
return { url: "https://example.test/audio.flac", format: "flac", bitDepth: 24, sampleRate: 96000 };
},
download: function(id, quality, outputPath, onProgress) {
if (onProgress) onProgress(100);
return {
success: true,
filePath: "EXISTS:" + outputPath,
alreadyExists: false,
bitDepth: 24,
sampleRate: 96000,
title: "Downloaded",
artist: "Artist",
album: "Album",
albumArtist: "Album Artist",
trackNumber: 1,
totalTracks: 10,
discNumber: 1,
totalDiscs: 1,
releaseDate: "2026-05-04",
coverUrl: "https://example.test/cover.jpg",
isrc: "USRC17607839",
genre: "Pop",
label: "Label",
copyright: "Copyright",
composer: "Composer",
lyricsLrc: "[00:00.00]Hello",
decryptionKey: "001122",
decryption: { strategy: "mp4_decryption_key", options: { kid: "1" } }
};
},
fetchLyrics: function(name, artist, album, duration) {
return { syncType: "LINE_SYNCED", provider: "coverage-ext", lines: [{ startTimeMs: 0, endTimeMs: 1000, words: "Hello" }] };
},
handleUrl: function(url) {
return { type: "track", name: "Handled", coverUrl: "https://example.test/cover.jpg", track: track("url-track"), tracks: [track("url-track")], album: this.getAlbum("url-album"), artist: this.getArtist("url-artist") };
},
matchTrack: function(req) {
return { matched: true, trackId: "download-track", confidence: 0.95, reason: "exact" };
},
postProcess: function(path, req) {
return { success: true, newFilePath: path, bitDepth: 24, sampleRate: 96000 };
},
postProcessV2: function(input, metadata, hookId) {
return { success: true, newFilePath: input.path || input.uri, newFileUri: input.uri || "", bitDepth: 24, sampleRate: 96000 };
}
});
`
func mustReadFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file: %v", err)
}
return data
}
func buildID3v23Tag(frames ...[]byte) []byte {
body := bytes.Join(frames, nil)
header := []byte{'I', 'D', '3', 3, 0, 0, 0, 0, 0, 0}
copy(header[6:10], syncsafeBytes(len(body)))
return append(header, body...)
}
func id3TextFrame(id, value string) []byte {
return id3v23Frame(id, append([]byte{3}, []byte(value)...))
}
func id3CommentFrame(id, value string) []byte {
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
return id3v23Frame(id, payload)
}
func id3UserTextFrame(id, desc, value string) []byte {
payload := append([]byte{3}, []byte(desc)...)
payload = append(payload, 0)
payload = append(payload, []byte(value)...)
return id3v23Frame(id, payload)
}
func id3v23Frame(id string, payload []byte) []byte {
frame := make([]byte, 10+len(payload))
copy(frame[0:4], id)
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
copy(frame[10:], payload)
return frame
}
func buildID3v22Tag(frames ...[]byte) []byte {
body := bytes.Join(frames, nil)
header := []byte{'I', 'D', '3', 2, 0, 0, 0, 0, 0, 0}
copy(header[6:10], syncsafeBytes(len(body)))
return append(header, body...)
}
func id3v22TextFrame(id, value string) []byte {
return id3v22Frame(id, append([]byte{3}, []byte(value)...))
}
func id3v22CommentFrame(id, value string) []byte {
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
return id3v22Frame(id, payload)
}
func id3v22Frame(id string, payload []byte) []byte {
frame := make([]byte, 6+len(payload))
copy(frame[0:3], id)
size := len(payload)
frame[3] = byte(size >> 16)
frame[4] = byte(size >> 8)
frame[5] = byte(size)
copy(frame[6:], payload)
return frame
}
func syncsafeBytes(size int) []byte {
return []byte{
byte((size >> 21) & 0x7f),
byte((size >> 14) & 0x7f),
byte((size >> 7) & 0x7f),
byte(size & 0x7f),
}
}
func buildID3v1Tag(title, artist, album, year string, track, genre byte) []byte {
tag := make([]byte, 128)
copy(tag[0:3], "TAG")
copyPadded(tag[3:33], title)
copyPadded(tag[33:63], artist)
copyPadded(tag[63:93], album)
copyPadded(tag[93:97], year)
tag[125] = 0
tag[126] = track
tag[127] = genre
return tag
}
func copyPadded(dst []byte, value string) {
for i := range dst {
dst[i] = ' '
}
copy(dst, value)
}
func writeExportCueFixture(t *testing.T, dir string) (string, string) {
t.Helper()
audioPath := filepath.Join(dir, "exports.wav")
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
t.Fatalf("write export audio: %v", err)
}
cuePath := filepath.Join(dir, "exports.cue")
cue := "PERFORMER \"Artist\"\nTITLE \"Album\"\nFILE \"exports.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"Song\"\n INDEX 01 00:00:00\n"
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
t.Fatalf("write export cue: %v", err)
}
return cuePath, audioPath
}
func escapeJSONPath(path string) string {
data, _ := json.Marshal(path)
return strings.Trim(string(data), `"`)
}
func fakeDeezerResponse(path, rawQuery string) string {
switch {
case path == "/2.0/search/track":
if strings.Contains(rawQuery, "MISSING") {
return `{"data":[]}`
}
return `{"data":[` + fakeDeezerTrackJSON(101, true) + `]}`
case path == "/2.0/search/artist":
return `{"data":[{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123}]}`
case path == "/2.0/search/album":
return `{"data":[{"id":201,"title":"Album","cover_xl":"album-xl","nb_tracks":2,"release_date":"2026-05-04","record_type":"compile","artist":{"id":301,"name":"Artist"}}]}`
case path == "/2.0/search/playlist":
return `{"data":[{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"user":{"name":"Owner"}}]}`
case path == "/2.0/track/101", path == "/2.0/track/isrc:USRC17607839":
return fakeDeezerTrackJSON(101, true)
case path == "/2.0/track/102":
return fakeDeezerTrackJSON(102, true)
case path == "/2.0/track/isrc:MISSING":
return `{"id":0}`
case path == "/2.0/album/201":
return `{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","nb_tracks":2,"record_type":"compile","label":"Label","copyright":"Copyright","genres":{"data":[{"name":"Pop"},{"name":"Dance"}]},"artist":{"id":301,"name":"Album Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
case path == "/2.0/artist/301":
return `{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123,"nb_album":1}`
case path == "/2.0/artist/301/albums":
return `{"data":[{"id":201,"title":"Album","release_date":"2026-05-04","nb_tracks":0,"cover_xl":"album-xl","record_type":"compile"}]}`
case path == "/2.0/artist/301/related":
return `{"data":[{"id":302,"name":"Related","picture_xl":"related-xl","nb_fan":10}]}`
case path == "/2.0/playlist/401":
return `{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"creator":{"name":"Owner"},"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
default:
return ""
}
}
func fakeDeezerTrackJSON(id int, withISRC bool) string {
isrc := ""
if withISRC {
isrc = `,"isrc":"USRC17607839"`
if id == 102 {
isrc = `,"isrc":"USRC17607840"`
}
}
return fmt.Sprintf(`{"id":%d,"title":"Track %d","duration":180,"track_position":%d,"disk_number":1%s,"link":"https://deezer.test/track/%d","release_date":"2026-05-04","artist":{"id":301,"name":"Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"album":{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","record_type":"album"}}`, id, id, id-100, isrc, id)
}
func createTestExtensionPackage(t *testing.T, path, name, version, js string, extraFiles map[string]string) {
t.Helper()
out, err := os.Create(path)
if err != nil {
t.Fatalf("create extension package: %v", err)
}
defer out.Close()
zw := zip.NewWriter(out)
defer zw.Close()
manifest := fmt.Sprintf(`{
"name": %q,
"displayName": %q,
"version": %q,
"description": "Packaged test extension",
"type": ["metadata_provider", "download_provider", "lyrics_provider"],
"permissions": {"network": ["example.test"], "storage": true, "file": true},
"icon": "icon.png",
"settings": [{"key":"quality","type":"string","label":"Quality"}],
"qualityOptions": [{"id":"lossless","label":"Lossless","description":"Lossless"}],
"searchBehavior": {"enabled": true, "placeholder": "Search", "primary": true},
"urlHandler": {"enabled": true, "patterns": ["https://example.test/"]},
"trackMatching": {"customMatching": true},
"postProcessing": {"enabled": true, "hooks": [{"id":"hook","name":"Hook"}]},
"serviceHealth": [{"id":"main","url":"https://example.test/health"}],
"capabilities": {"homeFeed": true}
}`, name, name, version)
for fileName, content := range map[string]string{
"manifest.json": manifest,
"index.js": js,
"icon.png": "png",
} {
writer, err := zw.Create(fileName)
if err != nil {
t.Fatalf("zip create %s: %v", fileName, err)
}
if _, err := writer.Write([]byte(content)); err != nil {
t.Fatalf("zip write %s: %v", fileName, err)
}
}
for fileName, content := range extraFiles {
writer, err := zw.Create(fileName)
if err != nil {
t.Fatalf("zip create extra %s: %v", fileName, err)
}
if _, err := writer.Write([]byte(content)); err != nil {
t.Fatalf("zip write extra %s: %v", fileName, err)
}
}
}
-442
View File
@@ -1,442 +0,0 @@
package gobackend
import (
"encoding/json"
"sort"
"strings"
"sync"
)
type CrossExtensionShareResult struct {
ExtensionID string `json:"extension_id"`
DisplayName string `json:"display_name"`
Found bool `json:"found"`
URL string `json:"url,omitempty"`
ItemName string `json:"item_name,omitempty"`
ItemArtists string `json:"item_artists,omitempty"`
Error string `json:"error,omitempty"`
}
var crossExtensionShareResultCache = struct {
sync.RWMutex
entries map[string]string
order []string
}{
entries: make(map[string]string),
}
const crossExtensionShareResultCacheLimit = 128
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
var req struct {
Name string `json:"name"`
Artists string `json:"artists"`
Type string `json:"type"`
SourceExtensionID string `json:"source_extension_id"`
}
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", err
}
req.Name = strings.TrimSpace(req.Name)
req.Artists = strings.TrimSpace(req.Artists)
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
if req.Name == "" {
return "[]", nil
}
if req.Type == "" {
req.Type = "album"
}
providers := getExtensionManager().GetMetadataProviders()
work := make([]*extensionProviderWrapper, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
if provider.extension.ID == req.SourceExtensionID {
continue
}
work = append(work, provider)
}
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
return cached, nil
}
query := req.Name
if req.Artists != "" {
query += " " + req.Artists
}
results := make([]CrossExtensionShareResult, len(work))
var wg sync.WaitGroup
for i, provider := range work {
wg.Add(1)
go func(index int, p *extensionProviderWrapper) {
defer wg.Done()
results[index] = findCollectionForExtension(
p,
req.Type,
req.Name,
req.Artists,
query,
)
}(i, provider)
}
wg.Wait()
data, err := json.Marshal(results)
if err != nil {
return "[]", err
}
response := string(data)
if crossExtensionShareResultsCacheable(results) {
setCrossExtensionShareCache(cacheKey, response)
}
return response, nil
}
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
providerKeys := make([]string, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
ext := provider.extension
displayName := ""
if ext.Manifest != nil {
displayName = ext.Manifest.DisplayName
}
providerKeys = append(providerKeys, strings.Join([]string{
strings.TrimSpace(ext.ID),
strings.TrimSpace(displayName),
strings.TrimSpace(ext.SourceDir),
}, "\x1f"))
}
sort.Strings(providerKeys)
return strings.Join([]string{
normalizeLooseTitle(itemType),
normalizeLooseTitle(name),
normalizeLooseArtistName(artists),
strings.TrimSpace(sourceExtensionID),
strings.Join(providerKeys, "\x1e"),
}, "\x1d")
}
func getCrossExtensionShareCache(key string) string {
if key == "" {
return ""
}
crossExtensionShareResultCache.RLock()
defer crossExtensionShareResultCache.RUnlock()
return crossExtensionShareResultCache.entries[key]
}
func setCrossExtensionShareCache(key string, value string) {
if key == "" || value == "" {
return
}
crossExtensionShareResultCache.Lock()
defer crossExtensionShareResultCache.Unlock()
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
}
crossExtensionShareResultCache.entries[key] = value
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
oldest := crossExtensionShareResultCache.order[0]
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
delete(crossExtensionShareResultCache.entries, oldest)
}
}
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
for _, result := range results {
if result.Found {
continue
}
errText := strings.ToLower(strings.TrimSpace(result.Error))
if errText == "" ||
errText == "no results" ||
errText == "unsupported collection type" ||
strings.HasSuffix(errText, " not found") ||
strings.Contains(errText, "found without shareable link") {
continue
}
return false
}
return true
}
func findCollectionForExtension(
provider *extensionProviderWrapper,
itemType string,
name string,
artists string,
query string,
) CrossExtensionShareResult {
result := CrossExtensionShareResult{
ExtensionID: provider.extension.ID,
}
if provider.extension.Manifest != nil {
result.DisplayName = provider.extension.Manifest.DisplayName
}
if result.DisplayName == "" {
result.DisplayName = provider.extension.ID
}
searchResult, err := searchCollectionCandidates(provider, itemType, query)
if err != nil {
result.Error = err.Error()
return result
}
if searchResult == nil || len(searchResult.Tracks) == 0 {
result.Error = "no results"
return result
}
var best *ExtTrackMetadata
switch itemType {
case "artist":
best = bestArtistTrack(searchResult.Tracks, name)
case "album":
best = bestAlbumTrack(searchResult.Tracks, name, artists)
default:
result.Error = "unsupported collection type"
return result
}
if best == nil {
result.Error = itemType + " not found"
return result
}
url := resolveCollectionShareURL(provider.extension, itemType, best)
if url == "" {
result.Error = itemType + " found without shareable link"
return result
}
result.Found = true
result.URL = url
if itemType == "artist" {
result.ItemName = collectionArtistName(*best)
} else {
result.ItemName = collectionAlbumName(*best)
result.ItemArtists = best.Artists
}
return result
}
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
filter := ""
switch itemType {
case "album":
filter = "albums"
case "artist":
filter = "artists"
}
if filter != "" {
tracks, err := provider.CustomSearch(query, map[string]interface{}{
"filter": filter,
"limit": 10,
})
if err == nil && len(tracks) > 0 {
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
}
}
return provider.SearchTracks(query, 10)
}
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
targetAlbum := normalizeLooseTitle(albumName)
targetArtists := normalizeLooseArtistName(artists)
bestScore := 0
bestIndex := -1
for i := range tracks {
track := tracks[i]
album := normalizeLooseTitle(collectionAlbumName(track))
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
score := 0
if isCollectionItemType(track, "album") {
score += 25
}
if album == targetAlbum {
score += 100
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
score += 50
}
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
score += 30
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 50 {
return nil
}
return &tracks[bestIndex]
}
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
targetArtist := normalizeLooseArtistName(artistName)
bestScore := 0
bestIndex := -1
for i := range tracks {
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
score := 0
if isCollectionItemType(tracks[i], "artist") {
score += 25
}
if artist == targetArtist {
score += 100
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
score += 60
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 60 {
return nil
}
return &tracks[bestIndex]
}
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
if track == nil {
return ""
}
if itemType == "album" {
if isCollectionItemType(*track, "album") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.AlbumURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
return url
}
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
return url
}
return ""
}
if isCollectionItemType(*track, "artist") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.ArtistURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
return url
}
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
return url
}
return ""
}
func collectionAlbumName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "album") {
return track.Name
}
return track.AlbumName
}
func collectionArtistName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "artist") {
return track.Name
}
return track.Artists
}
func collectionID(track ExtTrackMetadata, itemType string) string {
if isCollectionItemType(track, itemType) {
return track.ID
}
return ""
}
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
}
func normalizeShareURL(value string) string {
trimmed := strings.TrimSpace(value)
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
return trimmed
}
return ""
}
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
for key, value := range links {
if strings.Contains(strings.ToLower(key), preferredKey) {
if url := normalizeShareURL(value); url != "" {
return url
}
}
}
return ""
}
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return ""
}
id = stripProviderPrefix(strings.TrimSpace(id))
if id == "" {
return ""
}
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
if !ok {
return ""
}
rawTemplate, ok := templates[itemType].(string)
if !ok {
return ""
}
rawTemplate = strings.TrimSpace(rawTemplate)
if rawTemplate == "" {
return ""
}
return strings.ReplaceAll(rawTemplate, "{id}", id)
}
func stripProviderPrefix(id string) string {
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
return id[index+1:]
}
return id
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
-100
View File
@@ -1,100 +0,0 @@
package gobackend
import "testing"
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
"album": "https://music.apple.com/us/album/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "1440783617",
Name: "Nevermind",
Artists: "Nirvana",
ItemType: "album",
},
}
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
if best == nil {
t.Fatal("expected album collection item to match")
}
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
t.Fatalf("album share URL = %q", url)
}
}
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
"artist": "https://music.youtube.com/browse/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "UCrPe3hLA51968GwxHSZ1llw",
Name: "Nirvana",
ItemType: "artist",
},
}
best := bestArtistTrack(tracks, "Nirvana")
if best == nil {
t.Fatal("expected artist collection item to match")
}
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
t.Fatalf("artist share URL = %q", url)
}
}
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
apple := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "apple",
SourceDir: "/extensions/apple",
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
},
}
qobuz := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "qobuz",
SourceDir: "/extensions/qobuz",
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
},
}
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
if first != second {
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
}
}
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
cacheable := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "album not found"},
{ExtensionID: "tidal", Error: "no results"},
}
if !crossExtensionShareResultsCacheable(cacheable) {
t.Fatal("expected found and deterministic not-found results to be cacheable")
}
transient := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "request failed: timeout"},
}
if crossExtensionShareResultsCacheable(transient) {
t.Fatal("expected transient extension errors to skip cache")
}
}
-171
View File
@@ -1,171 +0,0 @@
package gobackend
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestCueParserEndToEnd(t *testing.T) {
dir := t.TempDir()
audioPath := filepath.Join(dir, "album.wav")
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
t.Fatalf("write audio: %v", err)
}
cuePath := filepath.Join(dir, "album.cue")
cue := "\ufeffREM GENRE \"Pop\"\n" +
"REM DATE 2026\n" +
"REM COMMENT \"comment\"\n" +
"REM COMPOSER \"Album Composer\"\n" +
"PERFORMER \"Album Artist\"\n" +
"TITLE \"Album Title\"\n" +
"FILE \"album.wav\" WAVE\n" +
" TRACK 01 AUDIO\n" +
" TITLE \"First\"\n" +
" PERFORMER \"Track Artist\"\n" +
" ISRC USRC17607839\n" +
" INDEX 01 00:00:00\n" +
" TRACK 02 AUDIO\n" +
" TITLE \"Second\"\n" +
" SONGWRITER \"Track Composer\"\n" +
" INDEX 00 03:00:00\n" +
" INDEX 01 03:05:00\n"
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
t.Fatalf("write cue: %v", err)
}
sheet, err := ParseCueFile(cuePath)
if err != nil {
t.Fatalf("ParseCueFile: %v", err)
}
if sheet.Performer != "Album Artist" || sheet.Title != "Album Title" || len(sheet.Tracks) != 2 {
t.Fatalf("sheet = %#v", sheet)
}
if got := parseCueTimestamp("01:02:37"); got <= 62 || got >= 63 {
t.Fatalf("timestamp = %f", got)
}
if got := formatCueTimestamp(3723.5); got != "01:02:03.500" {
t.Fatalf("format timestamp = %q", got)
}
if got := unquoteCue(" \"quoted\" "); got != "quoted" {
t.Fatalf("unquote = %q", got)
}
fileName, fileType := parseCueFileLine("unquoted album.flac FLAC")
if fileName != "unquoted album.flac" || fileType != "FLAC" {
t.Fatalf("file line = %q/%q", fileName, fileType)
}
if resolved := ResolveCueAudioPath(cuePath, "album.flac"); resolved != audioPath {
t.Fatalf("resolved = %q want %q", resolved, audioPath)
}
info, err := BuildCueSplitInfo(cuePath, sheet, "")
if err != nil {
t.Fatalf("BuildCueSplitInfo: %v", err)
}
if info.Tracks[0].EndSec != 180 || info.Tracks[1].Composer != "Track Composer" {
t.Fatalf("split info = %#v", info.Tracks)
}
jsonText, err := ParseCueFileJSON(cuePath, "")
if err != nil {
t.Fatalf("ParseCueFileJSON: %v", err)
}
var decoded CueSplitInfo
if err := json.Unmarshal([]byte(jsonText), &decoded); err != nil {
t.Fatalf("decode cue json: %v", err)
}
if decoded.AudioPath != audioPath {
t.Fatalf("decoded audio path = %q", decoded.AudioPath)
}
results, err := ScanCueFileForLibraryExt(cuePath, "", "virtual/album.cue", 1234, "scan-time")
if err != nil {
t.Fatalf("ScanCueFileForLibraryExt: %v", err)
}
if len(results) != 2 || results[0].TrackName != "First" || results[0].Duration != 180 {
t.Fatalf("scan results = %#v", results)
}
if results[0].FilePath != "virtual/album.cue#track01" || results[0].Format != "cue+wav" {
t.Fatalf("scan path/format = %q/%q", results[0].FilePath, results[0].Format)
}
if _, err := ParseCueFile(filepath.Join(dir, "missing.cue")); err == nil {
t.Fatal("expected missing cue error")
}
emptyCue := filepath.Join(dir, "empty.cue")
if err := os.WriteFile(emptyCue, []byte("TITLE \"No tracks\""), 0600); err != nil {
t.Fatal(err)
}
if _, err := ParseCueFile(emptyCue); err == nil {
t.Fatal("expected no tracks error")
}
missingDir := t.TempDir()
missingCuePath := filepath.Join(missingDir, "missing.cue")
if err := os.WriteFile(missingCuePath, []byte(cue), 0600); err != nil {
t.Fatal(err)
}
if _, err := BuildCueSplitInfo(missingCuePath, &CueSheet{FileName: "missing.wav"}, ""); err == nil {
t.Fatal("expected missing audio error")
}
if _, err := resolveCueAudioPathForLibrary(cuePath, nil, ""); err == nil {
t.Fatal("expected nil sheet error")
}
if _, err := scanCueSheetForLibrary(cuePath, nil, audioPath, "", 0, "", ""); err == nil {
t.Fatal("expected nil scan sheet error")
}
}
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "song.flac")
if err := os.WriteFile(filePath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir, buildTime: time.Now()}
idx.Add("usrc17607839", filePath)
if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath {
t.Fatalf("lookup = %q/%v", got, ok)
}
if got, err := idx.Lookup("usrc17607839"); err != nil || got != filePath {
t.Fatalf("Lookup = %q/%v", got, err)
}
idx.remove("usrc17607839")
if _, ok := idx.lookup("usrc17607839"); ok {
t.Fatal("expected removed ISRC")
}
isrcIndexCacheMu.Lock()
isrcIndexCache[dir] = idx
isrcIndexCacheMu.Unlock()
defer InvalidateISRCCache(dir)
AddToISRCIndex(dir, "USRC17607839", filePath)
if found, err := CheckISRCExists(dir, "USRC17607839"); err != nil || found != filePath {
t.Fatalf("CheckISRCExists = %q/%v", found, err)
}
if !CheckFileExists(filePath) || CheckFileExists(dir) || CheckFileExists(filepath.Join(dir, "missing.flac")) {
t.Fatal("unexpected file existence result")
}
tracksJSON := `[{"isrc":"USRC17607839","track_name":"Song","artist_name":"Artist"},{"isrc":"MISSING","track_name":"Other","artist_name":"Artist"}]`
resultJSON, err := CheckFilesExistParallel(dir, tracksJSON)
if err != nil {
t.Fatalf("CheckFilesExistParallel: %v", err)
}
var results []FileExistenceResult
if err := json.Unmarshal([]byte(resultJSON), &results); err != nil {
t.Fatalf("decode results: %v", err)
}
if !results[0].Exists || results[0].FilePath != filePath || results[1].Exists {
t.Fatalf("results = %#v", results)
}
if _, err := CheckFilesExistParallel(dir, `not-json`); err == nil {
t.Fatal("expected invalid json error")
}
if err := PreBuildISRCIndex(""); err == nil {
t.Fatal("expected empty dir error")
}
}
+1 -9
View File
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
}
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, baseName+ext)
if _, err := os.Stat(candidate); err == nil {
@@ -513,11 +513,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
album = "Unknown Album"
}
composer := track.Composer
if composer == "" {
composer = sheet.Composer
}
var duration int
if i+1 < len(sheet.Tracks) {
nextStart := sheet.Tracks[i+1].StartTime
@@ -544,15 +539,12 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
ScannedAt: scanTime,
ISRC: track.ISRC,
TrackNumber: track.Number,
TotalTracks: len(sheet.Tracks),
DiscNumber: 1,
TotalDiscs: 1,
Duration: duration,
ReleaseDate: sheet.Date,
BitDepth: bitDepth,
SampleRate: sampleRate,
Genre: sheet.Genre,
Composer: composer,
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
}
+12 -30
View File
@@ -3,7 +3,6 @@ package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -631,12 +630,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
totalDiscs := 0
for _, track := range allTracks {
if track.DiskNumber > totalDiscs {
totalDiscs = track.DiskNumber
}
}
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
albumType := album.RecordType
@@ -665,7 +658,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
TrackNumber: trackNum,
TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber,
TotalDiscs: totalDiscs,
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
@@ -784,6 +776,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
// not include this field. Albums whose track count is already known (non-zero)
// are skipped.
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
// Find albums that need track counts
type indexedID struct {
idx int
albumID string
@@ -1267,7 +1260,16 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
}
lastErr = err
if !isDeezerRetryableError(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 {
return err
}
@@ -1277,26 +1279,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
}
type deezerAPIError struct {
StatusCode int
Body string
}
func (e *deezerAPIError) Error() string {
return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body)
}
func isDeezerRetryableError(err error) bool {
if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
var apiErr *deezerAPIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
}
return false
}
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
@@ -1317,7 +1299,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
}
if resp.StatusCode != http.StatusOK {
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)}
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
}
return json.Unmarshal(body, dst)
+444
View File
@@ -0,0 +1,444 @@
package gobackend
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr"
type DeezerDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
}
func isLikelySpotifyTrackID(value string) bool {
if len(value) != 22 {
return false
}
for _, r := range value {
switch {
case r >= 'A' && r <= 'Z':
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
default:
return false
}
}
return true
}
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
deezerID := strings.TrimSpace(req.DeezerID)
if deezerID == "" {
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
deezerID = strings.TrimSpace(prefixed)
}
}
if deezerID != "" {
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
// Don't reject direct IDs from request payload — they're presumably correct.
}
return trackURL, nil
}
spotifyID := strings.TrimSpace(req.SpotifyID)
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
songlink := NewSongLinkClient()
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
if err == nil && availability.Deezer && availability.DeezerURL != "" {
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
// Fall through to ISRC search instead of using wrong track.
} else {
return availability.DeezerURL, nil
}
} else {
return availability.DeezerURL, nil
}
}
}
isrc := strings.TrimSpace(req.ISRC)
if isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
if err == nil && track != nil {
resolvedID := songLinkExtractDeezerTrackID(track)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
}
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
}
}
}
return "", fmt.Errorf("could not resolve Deezer track URL")
}
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
if err != nil {
return nil // Can't verify — don't block the download.
}
resolved := resolvedTrackInfo{
Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists,
ISRC: trackResp.Track.ISRC,
Duration: trackResp.Track.DurationMS / 1000,
SkipNameVerification: skipNameVerification,
}
if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
}
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
return nil
}
type deezerMusicDLRequest struct {
Platform string `json:"platform"`
URL string `json:"url"`
}
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
payload := deezerMusicDLRequest{
Platform: "deezer",
URL: deezerTrackURL,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("MusicDL request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
}
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
return "", fmt.Errorf("MusicDL error: %s", errMsg)
}
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
}
if data, ok := raw["data"].(map[string]any); ok {
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
}
}
return "", fmt.Errorf("no download URL found in MusicDL response")
}
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
if err != nil {
return err
}
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return err
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush output: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close output: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
return nil
}
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
deezerClient := GetDeezerClient()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
var outputPath string
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
}
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
coverURL := req.CoverURL
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
embedLyrics = false
}
parallelResult = FetchCoverAndLyricsParallel(
coverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
embedLyrics,
int64(req.DurationMS),
)
}()
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
if deezerURLErr != nil {
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed: could not resolve Deezer URL: %w",
deezerURLErr,
)
}
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
downloadErr := deezerClient.DownloadFromMusicDL(
deezerTrackURL,
outputPath,
req.OutputFD,
req.ItemID,
)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
return DeezerDownloadResult{}, ErrDownloadCancelled
}
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed via MusicDL: %w",
downloadErr,
)
}
<-parallelDone
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
}
if isSafOutput || !req.EmbedMetadata {
if !req.EmbedMetadata {
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
} else {
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
}
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
}
}
}
}
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
}
bitDepth, sampleRate := 0, 0
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
}
lyricsLRC := ""
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return DeezerDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
}, nil
}
-153
View File
@@ -1,153 +0,0 @@
package gobackend
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
)
func TestDeezerClientWithFakeHTTP(t *testing.T) {
client := &DeezerClient{
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
status := http.StatusOK
if body == "" {
status = http.StatusNotFound
body = `{"error":"missing"}`
}
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})},
searchCache: map[string]*cacheEntry{},
albumCache: map[string]*cacheEntry{},
artistCache: map[string]*cacheEntry{},
isrcCache: map[string]string{},
cacheCleanupInterval: time.Millisecond,
}
ctx := context.Background()
search, err := client.SearchAll(ctx, "artist song", 2, 2, "")
if err != nil {
t.Fatalf("SearchAll: %v", err)
}
if len(search.Tracks) != 1 || len(search.Artists) != 1 || len(search.Albums) != 1 || len(search.Playlists) != 1 {
t.Fatalf("search = %#v", search)
}
cached, err := client.SearchAll(ctx, "artist song", 2, 2, "")
if err != nil || cached != search {
t.Fatalf("cached SearchAll = %#v/%v", cached, err)
}
if filtered, err := client.SearchAll(ctx, "artist song", 1, 1, "track"); err != nil || len(filtered.Tracks) != 1 || len(filtered.Artists) != 0 {
t.Fatalf("filtered search = %#v/%v", filtered, err)
}
track, err := client.GetTrack(ctx, "101")
if err != nil {
t.Fatalf("GetTrack: %v", err)
}
if track.Track.SpotifyID != "deezer:101" || track.Track.Artists != "Contributor A, Contributor B" {
t.Fatalf("track = %#v", track)
}
album, err := client.GetAlbum(ctx, "201")
if err != nil {
t.Fatalf("GetAlbum: %v", err)
}
if album.AlbumInfo.Name != "Album" || len(album.TrackList) != 2 || album.TrackList[1].ISRC == "" {
t.Fatalf("album = %#v", album)
}
if cachedAlbum, err := client.GetAlbum(ctx, "201"); err != nil || cachedAlbum != album {
t.Fatalf("cached album = %#v/%v", cachedAlbum, err)
}
artist, err := client.GetArtist(ctx, "301")
if err != nil {
t.Fatalf("GetArtist: %v", err)
}
if artist.ArtistInfo.Name != "Artist" || len(artist.Albums) != 1 || artist.Albums[0].TotalTracks == 0 {
t.Fatalf("artist = %#v", artist)
}
if cachedArtist, err := client.GetArtist(ctx, "301"); err != nil || cachedArtist != artist {
t.Fatalf("cached artist = %#v/%v", cachedArtist, err)
}
related, err := client.GetRelatedArtists(ctx, "deezer:301", 3)
if err != nil {
t.Fatalf("GetRelatedArtists: %v", err)
}
if len(related) != 1 || related[0].ID != "deezer:302" {
t.Fatalf("related = %#v", related)
}
if _, err := client.GetRelatedArtists(ctx, "", 0); err == nil {
t.Fatal("expected invalid related artist ID")
}
playlist, err := client.GetPlaylist(ctx, "401")
if err != nil {
t.Fatalf("GetPlaylist: %v", err)
}
if playlist.PlaylistInfo.Tracks.Total != 2 || len(playlist.TrackList) != 2 {
t.Fatalf("playlist = %#v", playlist)
}
byISRC, err := client.SearchByISRC(ctx, "USRC17607839")
if err != nil {
t.Fatalf("SearchByISRC: %v", err)
}
if byISRC.SpotifyID != "deezer:101" {
t.Fatalf("by ISRC = %#v", byISRC)
}
if _, err := client.SearchByISRC(ctx, "MISSING"); err == nil {
t.Fatal("expected missing ISRC error")
}
isrc, err := client.GetTrackISRC(ctx, "102")
if err != nil || isrc != "USRC17607840" {
t.Fatalf("GetTrackISRC = %q/%v", isrc, err)
}
albumID, err := client.GetTrackAlbumID(ctx, "101")
if err != nil || albumID != "201" {
t.Fatalf("GetTrackAlbumID = %q/%v", albumID, err)
}
extended, err := client.GetAlbumExtendedMetadata(ctx, "201")
if err != nil {
t.Fatalf("GetAlbumExtendedMetadata: %v", err)
}
if extended.Genre != "Pop, Dance" || extended.Label != "Label" {
t.Fatalf("extended = %#v", extended)
}
if byTrack, err := client.GetExtendedMetadataByTrackID(ctx, "101"); err != nil || byTrack.Label != "Label" {
t.Fatalf("metadata by track = %#v/%v", byTrack, err)
}
if byISRCMeta, err := client.GetExtendedMetadataByISRC(ctx, "USRC17607839"); err != nil || byISRCMeta.Label != "Label" {
t.Fatalf("metadata by isrc = %#v/%v", byISRCMeta, err)
}
if _, err := client.GetExtendedMetadataByISRC(ctx, ""); err == nil {
t.Fatal("expected empty ISRC metadata error")
}
if typ, id, err := parseDeezerURL("https://www.deezer.com/us/track/101"); err != nil || typ != "track" || id != "101" {
t.Fatalf("parseDeezerURL = %q/%q/%v", typ, id, err)
}
if _, _, err := parseDeezerURL("https://example.com/track/101"); err == nil {
t.Fatal("expected non-Deezer URL error")
}
client.cacheMu.Lock()
client.searchCache["expired"] = &cacheEntry{expiresAt: time.Now().Add(-time.Hour)}
client.searchCache["keep1"] = &cacheEntry{expiresAt: time.Now().Add(time.Hour)}
client.searchCache["keep2"] = &cacheEntry{expiresAt: time.Now().Add(2 * time.Hour)}
client.pruneExpiredCacheEntriesLocked(client.searchCache, time.Now())
client.trimCacheEntriesLocked(client.searchCache, 1)
client.isrcCache["1"] = "A"
client.isrcCache["2"] = "B"
client.trimStringCacheEntriesLocked(client.isrcCache, 1)
client.cacheMu.Unlock()
}
+987 -1407
View File
File diff suppressed because it is too large Load Diff
@@ -1,83 +0,0 @@
package gobackend
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestExtensionPackageExportWrappers(t *testing.T) {
dir := t.TempDir()
extensionsDir := filepath.Join(dir, "extensions")
dataDir := filepath.Join(dir, "data")
if err := InitExtensionSystem(extensionsDir, dataDir); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
CleanupExtensions()
defer CleanupExtensions()
js := `
registerExtension({
initialize: function(settings) { this.settings = settings || {}; },
cleanup: function() {},
doAction: function() { return { message: "wrapped", setting_updates: { quality: "lossless" } }; },
searchTracks: function() { return { tracks: [], total: 0 }; },
fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; },
getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; }
});
`
pkgV1 := filepath.Join(dir, "wrapper-ext-v1.spotiflac-ext")
pkgV2 := filepath.Join(dir, "wrapper-ext-v2.spotiflac-ext")
createTestExtensionPackage(t, pkgV1, "wrapper-ext", "1.0.0", js, nil)
createTestExtensionPackage(t, pkgV2, "wrapper-ext", "1.1.0", js, nil)
loadedJSON, err := LoadExtensionFromPath(pkgV1)
if err != nil || !strings.Contains(loadedJSON, "wrapper-ext") {
t.Fatalf("LoadExtensionFromPath = %q/%v", loadedJSON, err)
}
if installedJSON, err := GetInstalledExtensions(); err != nil || !strings.Contains(installedJSON, "wrapper-ext") {
t.Fatalf("GetInstalledExtensions = %q/%v", installedJSON, err)
}
if err := SetExtensionEnabledByID("wrapper-ext", true); err != nil {
t.Fatalf("SetExtensionEnabledByID true: %v", err)
}
if actionJSON, err := InvokeExtensionActionJSON("wrapper-ext", "doAction"); err != nil || !strings.Contains(actionJSON, "wrapped") {
t.Fatalf("InvokeExtensionActionJSON = %q/%v", actionJSON, err)
}
if upgradeJSON, err := CheckExtensionUpgradeFromPath(pkgV2); err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) {
t.Fatalf("CheckExtensionUpgradeFromPath = %q/%v", upgradeJSON, err)
}
if upgradedJSON, err := UpgradeExtensionFromPath(pkgV2); err != nil || !strings.Contains(upgradedJSON, "1.1.0") {
t.Fatalf("UpgradeExtensionFromPath = %q/%v", upgradedJSON, err)
}
if err := SetExtensionEnabledByID("wrapper-ext", false); err != nil {
t.Fatalf("SetExtensionEnabledByID false: %v", err)
}
if err := UnloadExtensionByID("wrapper-ext"); err != nil {
t.Fatalf("UnloadExtensionByID: %v", err)
}
dirExt := filepath.Join(extensionsDir, "wrapper-dir-ext")
if err := createDirectoryExtension(dirExt, "wrapper-dir-ext", "1.0.0"); err != nil {
t.Fatalf("create directory extension: %v", err)
}
if loadedDirJSON, err := LoadExtensionsFromDir(extensionsDir); err != nil || !strings.Contains(loadedDirJSON, "wrapper-dir-ext") {
t.Fatalf("LoadExtensionsFromDir = %q/%v", loadedDirJSON, err)
}
if err := RemoveExtensionByID("wrapper-dir-ext"); err != nil {
t.Fatalf("RemoveExtensionByID: %v", err)
}
}
func createDirectoryExtension(dir, name, version string) error {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
manifest := fmt.Sprintf(`{"name":%q,"displayName":%q,"version":%q,"description":"Directory wrapper extension","type":["metadata_provider"],"permissions":{}}`, name, name, version)
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0600); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600)
}
@@ -1,158 +0,0 @@
package gobackend
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
dir := t.TempDir()
audioPath := filepath.Join(dir, "sidecar.mp3")
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte("[00:00.00]Sidecar lyric"), 0600); err != nil {
t.Fatal(err)
}
if jsonText, err := FetchLyrics("spotify-1", "Song Instrumental", "Artist", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
t.Fatalf("FetchLyrics instrumental = %q/%v", jsonText, err)
}
if lrc, err := GetLyricsLRC("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || lrc != "[instrumental:true]" {
t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err)
}
if jsonText, err := GetLyricsLRCWithSource("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
t.Fatalf("GetLyricsLRCWithSource instrumental = %q/%v", jsonText, err)
}
if lrc, err := GetLyricsLRC("", "", "", audioPath, 0); err != nil || !strings.Contains(lrc, "Sidecar lyric") {
t.Fatalf("GetLyricsLRC sidecar = %q/%v", lrc, err)
}
if jsonText, err := GetLyricsLRCWithSource("", "", "", audioPath, 0); err != nil || !strings.Contains(jsonText, "Sidecar lyric") {
t.Fatalf("GetLyricsLRCWithSource sidecar = %q/%v", jsonText, err)
}
outPath := filepath.Join(dir, "lyrics.lrc")
if err := FetchAndSaveLyrics("Song", "Artist", "", 0, outPath, audioPath); err != nil {
t.Fatalf("FetchAndSaveLyrics sidecar: %v", err)
}
if data := string(mustReadFile(t, outPath)); !strings.Contains(data, "Sidecar lyric") {
t.Fatalf("saved lyrics = %q", data)
}
if response, err := EmbedLyricsToFile(filepath.Join(dir, "not-flac.mp3"), "lyrics"); err != nil || !strings.Contains(response, `"success":false`) {
t.Fatalf("EmbedLyricsToFile error = %q/%v", response, err)
}
if response, err := RewriteSplitArtistTagsExport(filepath.Join(dir, "not-flac.mp3"), "A;B", "A"); err != nil || !strings.Contains(response, `"success":false`) {
t.Fatalf("RewriteSplitArtistTagsExport error = %q/%v", response, err)
}
}
func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
origClient := globalSongLinkClient
origRetryConfig := songLinkRetryConfig
origSearchByISRC := songLinkSearchByISRC
origCheckFromDeezer := songLinkCheckAvailabilityFromDeezer
defer func() {
globalSongLinkClient = origClient
songLinkRetryConfig = origRetryConfig
songLinkSearchByISRC = origSearchByISRC
songLinkCheckAvailabilityFromDeezer = origCheckFromDeezer
SetSongLinkNetworkOptions(false, false)
}()
songLinkRetryConfig = func() RetryConfig {
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
}
globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
var body string
if req.URL.Host == "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" {
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 {
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
})}}
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)
}
if tidalURL, err := GetTidalURLFromDeezerTrack("101"); err != nil || !strings.Contains(tidalURL, "tidal") {
t.Fatalf("GetTidalURLFromDeezerTrack = %q/%v", tidalURL, err)
}
if urls, err := NewSongLinkClient().GetStreamingURLs("spotify-1"); err != nil || urls["tidal"] == "" || urls["amazon"] == "" {
t.Fatalf("GetStreamingURLs = %#v/%v", urls, err)
}
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromSpotify("spotify-1"); err != nil || !strings.Contains(youtubeURL, "youtu") {
t.Fatalf("GetYouTubeURLFromSpotify = %q/%v", youtubeURL, err)
}
if amazonURL, err := NewSongLinkClient().GetAmazonURLFromDeezer("101"); err != nil || !strings.Contains(amazonURL, "amazon") {
t.Fatalf("GetAmazonURLFromDeezer = %q/%v", amazonURL, err)
}
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromDeezer("101"); err != nil || !strings.Contains(youtubeURL, "youtube") {
t.Fatalf("GetYouTubeURLFromDeezer = %q/%v", youtubeURL, err)
}
if deezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify("spotify-1"); err != nil || deezerID != "101" {
t.Fatalf("GetDeezerIDFromSpotify = %q/%v", deezerID, err)
}
if album, err := NewSongLinkClient().CheckAlbumAvailability("album-1"); err != nil || !album.Deezer || album.DeezerID == "" {
t.Fatalf("CheckAlbumAvailability = %#v/%v", album, err)
}
if albumID, err := NewSongLinkClient().GetDeezerAlbumIDFromSpotify("album-1"); err != nil || albumID == "" {
t.Fatalf("GetDeezerAlbumIDFromSpotify = %q/%v", albumID, err)
}
if availability, err := NewSongLinkClient().CheckAvailabilityFromURL("https://www.deezer.com/track/101"); err != nil || !availability.Deezer {
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")
}
deezerClient = &DeezerClient{
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
if body == "" {
body = `{"error":"missing"}`
}
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
})},
searchCache: map[string]*cacheEntry{},
albumCache: map[string]*cacheEntry{},
artistCache: map[string]*cacheEntry{},
isrcCache: map[string]string{},
cacheCleanupInterval: time.Hour,
}
deezerClientOnce.Do(func() {})
if jsonText, err := ConvertSpotifyToDeezer("track", "spotify-1"); err != nil || !strings.Contains(jsonText, `"spotify_id":"deezer:101"`) {
t.Fatalf("ConvertSpotifyToDeezer track = %q/%v", jsonText, err)
}
if jsonText, err := ConvertSpotifyToDeezer("album", "album-1"); err != nil || jsonText == "" {
t.Fatalf("ConvertSpotifyToDeezer album = %q/%v", jsonText, err)
}
}
-493
View File
@@ -1,493 +0,0 @@
package gobackend
import (
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
got := classifyDownloadErrorType("All providers failed. Last error: HTTP status 429: too many requests")
if got != "rate_limit" {
t.Fatalf("expected rate_limit, got %q", got)
}
responseJSON, err := errorResponse("All services failed. Last error: rate limit exceeded")
if err != nil {
t.Fatalf("errorResponse returned error: %v", err)
}
var response DownloadResponse
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
t.Fatalf("invalid response JSON: %v", err)
}
if response.ErrorType != "rate_limit" {
t.Fatalf("expected rate_limit response, got %q", response.ErrorType)
}
}
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
cases := []string{
"HTTP 401 for /tickets",
"HTTP status 428: precondition required",
"Verification required",
}
for _, tc := range cases {
if got := classifyDownloadErrorType(tc); got != "verification_required" {
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
}
}
}
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
dir := t.TempDir()
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
CleanupExtensions()
defer CleanupExtensions()
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
ext.ID = "deezer"
ext.Manifest.Name = "deezer"
manager := getExtensionManager()
manager.mu.Lock()
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
manager.mu.Unlock()
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
if err != nil {
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
}
if !strings.Contains(jsonText, "album-track") {
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
}
}
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
extensionsDir := filepath.Join(dir, "extensions")
if err := InitExtensionSystem(extensionsDir, dataDir); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
manager := getExtensionManager()
manager.mu.Lock()
if manager.extensions == nil {
manager.extensions = map[string]*loadedExtension{}
}
manager.extensions[ext.ID] = ext
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
delete(manager.extensions, ext.ID)
manager.mu.Unlock()
}()
if response, err := DownloadTrack(`{}`); err != nil || !strings.Contains(response, "retired") {
t.Fatalf("DownloadTrack = %q/%v", response, err)
}
if response, err := DownloadByStrategy(`not-json`); err != nil || !strings.Contains(response, "Invalid request") {
t.Fatalf("DownloadByStrategy invalid = %q/%v", response, err)
}
if response, err := DownloadByStrategy(`{"use_extensions":false}`); err != nil || !strings.Contains(response, "disabled") {
t.Fatalf("DownloadByStrategy disabled = %q/%v", response, err)
}
if response, err := DownloadWithFallback(`{}`); err != nil || !strings.Contains(response, "retired") {
t.Fatalf("DownloadWithFallback = %q/%v", response, err)
}
InitItemProgress("item-1")
FinishItemProgress("item-1")
ClearItemProgress("item-1")
CancelDownload("item-1")
if GetDownloadProgress() == "" || GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
t.Fatal("expected progress JSON")
}
CleanupConnections()
cuePath, audioPath := writeExportCueFixture(t, dir)
if jsonText, err := ParseCueSheet(cuePath, ""); err != nil {
t.Fatalf("ParseCueSheet = %q/%v", jsonText, err)
} else {
var parsed CueSplitInfo
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
t.Fatalf("decode ParseCueSheet: %v", err)
}
if parsed.AudioPath != audioPath {
t.Fatalf("ParseCueSheet audio path = %q want %q", parsed.AudioPath, audioPath)
}
}
if jsonText, err := ScanCueSheetForLibrary(cuePath, "", "virtual.cue", 111); err != nil || !strings.Contains(jsonText, "cue+wav") {
t.Fatalf("ScanCueSheetForLibrary = %q/%v", jsonText, err)
}
if jsonText, err := ScanCueSheetForLibraryWithCoverCacheKey(cuePath, "", "virtual.cue", 111, "cover-key"); err != nil || !strings.Contains(jsonText, "cue+wav") {
t.Fatalf("ScanCueSheetForLibraryWithCoverCacheKey = %q/%v", jsonText, err)
}
apePath := filepath.Join(dir, "edit.ape")
if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
editJSON := `{"title":"Edited","artist":"Artist","track_number":"1","track_total":"2","disc_number":"1","disc_total":"1"}`
if response, err := EditFileMetadata(apePath, editJSON); err != nil || !strings.Contains(response, "native_ape") {
t.Fatalf("EditFileMetadata ape = %q/%v", response, err)
}
if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") {
t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err)
}
misnamedM4APath := filepath.Join(dir, "misnamed.flac")
if err := os.WriteFile(misnamedM4APath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "Misnamed"), true), 0600); err != nil {
t.Fatal(err)
}
replayGainJSON := `{"replaygain_track_gain":"-1 dB","replaygain_track_peak":"0.9"}`
if response, err := EditFileMetadata(misnamedM4APath, replayGainJSON); err != nil || !strings.Contains(response, "native_m4a_replaygain") {
t.Fatalf("EditFileMetadata misnamed m4a replaygain = %q/%v", response, err)
}
if _, err := EditFileMetadata(apePath, `not-json`); err == nil {
t.Fatal("expected invalid metadata JSON")
}
if !hasOnlyM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-1 dB"}) {
t.Fatal("expected replaygain-only fields")
}
if hasOnlyM4AReplayGainFields(map[string]string{"title": "Song"}) {
t.Fatal("expected non-replaygain field rejection")
}
AllowDownloadDir(dir)
if err := SetDownloadDirectory(dir); err != nil {
t.Fatalf("SetDownloadDirectory: %v", err)
}
if duplicateJSON, err := CheckDuplicate(dir, ""); err != nil || !strings.Contains(duplicateJSON, "exists") {
t.Fatalf("CheckDuplicate = %q/%v", duplicateJSON, err)
}
if batchJSON, err := CheckDuplicatesBatch(dir, `[{"isrc":"","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(batchJSON, "Song") {
t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err)
}
_ = PreBuildDuplicateIndex(dir)
InvalidateDuplicateIndex(dir)
if filename, err := BuildFilename("{artist} - {title}", `{"artist":"A/B","title":"Song?"}`); err != nil || filename == "" {
t.Fatalf("BuildFilename = %q/%v", filename, err)
}
if _, err := BuildFilename("{title}", `not-json`); err == nil {
t.Fatal("expected BuildFilename JSON error")
}
if got := SanitizeFilename(`A/B:C*D?`); strings.ContainsAny(got, `/:*?`) {
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")
}
ClearTrackIDCache()
if err := SetLyricsProvidersJSON(`["lrclib","apple_music"]`); err != nil {
t.Fatalf("SetLyricsProvidersJSON: %v", err)
}
if providers, err := GetLyricsProvidersJSON(); err != nil || !strings.Contains(providers, "lrclib") {
t.Fatalf("GetLyricsProvidersJSON = %q/%v", providers, err)
}
if available, err := GetAvailableLyricsProvidersJSON(); err != nil || available == "" {
t.Fatalf("GetAvailableLyricsProvidersJSON = %q/%v", available, err)
}
if err := SetLyricsFetchOptionsJSON(`{"include_translation_netease":true}`); err != nil {
t.Fatalf("SetLyricsFetchOptionsJSON: %v", err)
}
if opts, err := GetLyricsFetchOptionsJSON(); err != nil || opts == "" {
t.Fatalf("GetLyricsFetchOptionsJSON = %q/%v", opts, err)
}
if err := SetProviderPriorityJSON(`["coverage-ext"]`); err != nil {
t.Fatalf("SetProviderPriorityJSON: %v", err)
}
if jsonText, err := GetProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
t.Fatalf("GetProviderPriorityJSON = %q/%v", jsonText, err)
}
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)
}
if err := SetMetadataProviderPriorityJSON(`["coverage-ext"]`); err != nil {
t.Fatalf("SetMetadataProviderPriorityJSON: %v", err)
}
if jsonText, err := GetMetadataProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
t.Fatalf("GetMetadataProviderPriorityJSON = %q/%v", jsonText, err)
}
if err := SetExtensionSettingsJSON(ext.ID, `{"quality":"lossless","_secret":"hidden"}`); err != nil {
t.Fatalf("SetExtensionSettingsJSON: %v", err)
}
if settingsJSON, err := GetExtensionSettingsJSON(ext.ID); err != nil || !strings.Contains(settingsJSON, "quality") {
t.Fatalf("GetExtensionSettingsJSON = %q/%v", settingsJSON, err)
}
if err := SetExtensionSettingsJSON(ext.ID, `not-json`); err == nil {
t.Fatal("expected settings JSON error")
}
if jsonText, err := SearchTracksWithExtensionsJSON("song", 5); err != nil || !strings.Contains(jsonText, "search-1") {
t.Fatalf("SearchTracksWithExtensionsJSON = %q/%v", jsonText, err)
}
if jsonText, err := SearchTracksWithMetadataProvidersJSON("song", 5, true); err != nil || !strings.Contains(jsonText, "search-1") {
t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err)
}
if jsonText, err := GetProviderMetadataJSON(ext.ID, "track", "track-1"); err != nil || !strings.Contains(jsonText, "Track track-1") {
t.Fatalf("GetProviderMetadataJSON track = %q/%v", jsonText, err)
}
for _, resourceType := range []string{"album", "playlist", "artist"} {
if jsonText, err := GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1"); err != nil || jsonText == "" {
t.Fatalf("GetProviderMetadataJSON %s = %q/%v", resourceType, jsonText, err)
}
}
if _, err := GetProviderMetadataJSON("", "track", "id"); err == nil {
t.Fatal("expected empty provider ID error")
}
if _, err := GetProviderMetadataJSON(ext.ID, "unsupported", "id"); err == nil {
t.Fatal("expected unsupported provider type")
}
if firstNonEmptyTrimmed(" ", " value ") != "value" {
t.Fatal("expected first trimmed value")
}
requestJSON := `{"use_extensions":true,"use_fallback":false,"service":"coverage-ext","source":"coverage-ext","track_name":"Song","artist_name":"Artist","album_name":"Album","output_dir":"` + escapeJSONPath(dir) + `","output_ext":".flac","quality":"LOSSLESS"}`
if jsonText, err := DownloadWithExtensionsJSON(requestJSON); err != nil || !strings.Contains(jsonText, "coverage-ext") {
t.Fatalf("DownloadWithExtensionsJSON = %q/%v", jsonText, err)
}
if _, err := DownloadWithExtensionsJSON(`not-json`); err == nil {
t.Fatal("expected DownloadWithExtensionsJSON JSON error")
}
SetExtensionAuthCodeByID(ext.ID, "code")
SetExtensionTokensByID(ext.ID, "access", "refresh", 60)
if !IsExtensionAuthenticatedByID(ext.ID) {
t.Fatal("expected authenticated extension")
}
if pending, err := GetExtensionPendingAuthJSON(ext.ID); err != nil || pending != "" {
t.Fatalf("GetExtensionPendingAuthJSON = %q/%v", pending, err)
}
ClearExtensionPendingAuthByID(ext.ID)
if all, err := GetAllPendingAuthRequestsJSON(); err != nil || all == "" {
t.Fatalf("GetAllPendingAuthRequestsJSON = %q/%v", all, err)
}
ffmpegCommandsMu.Lock()
ffmpegCommands["cmd-1"] = &FFmpegCommand{ExtensionID: ext.ID, Command: "ffmpeg -version", InputPath: "in", OutputPath: "out"}
ffmpegCommandsMu.Unlock()
if cmdJSON, err := GetPendingFFmpegCommandJSON("cmd-1"); err != nil || !strings.Contains(cmdJSON, "cmd-1") {
t.Fatalf("GetPendingFFmpegCommandJSON = %q/%v", cmdJSON, err)
}
if all, err := GetAllPendingFFmpegCommandsJSON(); err != nil || !strings.Contains(all, "cmd-1") {
t.Fatalf("GetAllPendingFFmpegCommandsJSON = %q/%v", all, err)
}
SetFFmpegCommandResultByID("cmd-1", true, "ok", "")
ClearFFmpegCommand("cmd-1")
if empty, err := GetPendingFFmpegCommandJSON("missing"); err != nil || empty != "" {
t.Fatalf("missing ffmpeg = %q/%v", empty, err)
}
enrichedJSON, err := EnrichTrackWithExtensionJSON(ext.ID, `{"id":"track-1","name":"Old","artists":"Artist"}`)
if err != nil || !strings.Contains(enrichedJSON, "Enriched") {
t.Fatalf("EnrichTrackWithExtensionJSON = %q/%v", enrichedJSON, err)
}
if sameJSON, err := EnrichTrackWithExtensionJSON("missing", `{"name":"Old"}`); err != nil || !strings.Contains(sameJSON, "Old") {
t.Fatalf("missing EnrichTrackWithExtensionJSON = %q/%v", sameJSON, err)
}
deezerClient = &DeezerClient{
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
status := http.StatusOK
if body == "" {
status = http.StatusNotFound
body = `{"error":"missing"}`
}
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
})},
searchCache: map[string]*cacheEntry{},
albumCache: map[string]*cacheEntry{},
artistCache: map[string]*cacheEntry{},
isrcCache: map[string]string{},
cacheCleanupInterval: time.Hour,
}
deezerClientOnce.Do(func() {})
for _, item := range []struct {
typ string
id string
}{
{"track", "101"},
{"album", "201"},
{"artist", "301"},
{"playlist", "401"},
} {
if jsonText, err := GetDeezerMetadata(item.typ, item.id); err != nil || jsonText == "" {
t.Fatalf("GetDeezerMetadata %s = %q/%v", item.typ, jsonText, err)
}
}
if _, err := GetDeezerMetadata("bad", "1"); err == nil {
t.Fatal("expected unsupported Deezer metadata type")
}
if jsonText, err := GetDeezerRelatedArtists("301", 2); err != nil || !strings.Contains(jsonText, "Related") {
t.Fatalf("GetDeezerRelatedArtists = %q/%v", jsonText, err)
}
if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") {
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
}
if _, err := GetDeezerExtendedMetadata(""); err == nil {
t.Fatal("expected empty Deezer metadata ID error")
}
if jsonText, err := SearchDeezerByISRC("USRC17607839"); err != nil || !strings.Contains(jsonText, "deezer:101") {
t.Fatalf("SearchDeezerByISRC = %q/%v", jsonText, err)
}
if jsonText, err := SearchDeezerByISRCForItemID("USRC17607839", "item-isrc"); err != nil || !strings.Contains(jsonText, "deezer:101") {
t.Fatalf("SearchDeezerByISRCForItemID = %q/%v", jsonText, err)
}
customJSON, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`)
if err != nil || !strings.Contains(customJSON, "Custom needle") {
t.Fatalf("CustomSearchWithExtensionJSON = %q/%v", customJSON, err)
}
if customJSON, err := CustomSearchWithExtensionJSONWithRequestID(ext.ID, "needle", `not-json`, "req-custom"); err != nil || !strings.Contains(customJSON, "custom-1") {
t.Fatalf("CustomSearchWithExtensionJSONWithRequestID = %q/%v", customJSON, err)
}
if providersJSON, err := GetSearchProvidersJSON(); err != nil || !strings.Contains(providersJSON, "coverage-ext") {
t.Fatalf("GetSearchProvidersJSON = %q/%v", providersJSON, err)
}
if found := FindURLHandlerJSON("https://example.test/track/1"); found != ext.ID {
t.Fatalf("FindURLHandlerJSON = %q", found)
}
if handlersJSON, err := GetURLHandlersJSON(); err != nil || !strings.Contains(handlersJSON, "coverage-ext") {
t.Fatalf("GetURLHandlersJSON = %q/%v", handlersJSON, err)
}
if handledJSON, err := HandleURLWithExtensionJSON("https://example.test/track/1"); err != nil || !strings.Contains(handledJSON, "url-track") {
t.Fatalf("HandleURLWithExtensionJSON = %q/%v", handledJSON, err)
}
if postJSON, err := RunPostProcessingJSON(filepath.Join(dir, "song.flac"), `{"title":"Song"}`); err != nil || !strings.Contains(postJSON, "success") {
t.Fatalf("RunPostProcessingJSON = %q/%v", postJSON, err)
}
v2Input := `{"path":"` + escapeJSONPath(filepath.Join(dir, "song.flac")) + `","uri":"content://song","name":"song.flac","mime_type":"audio/flac","size":10}`
if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") {
t.Fatalf("RunPostProcessingV2JSON = %q/%v", postJSON, err)
}
if postProviders, err := GetPostProcessingProvidersJSON(); err != nil || !strings.Contains(postProviders, "hook") {
t.Fatalf("GetPostProcessingProvidersJSON = %q/%v", postProviders, err)
}
if feedJSON, err := GetExtensionHomeFeedJSON(ext.ID); err != nil || !strings.Contains(feedJSON, "home-1") {
t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err)
}
if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") {
t.Fatalf("GetExtensionHomeFeedJSONWithRequestID = %q/%v", feedJSON, err)
}
if categoriesJSON, err := GetExtensionBrowseCategoriesJSON(ext.ID); err != nil || !strings.Contains(categoriesJSON, "cat-1") {
t.Fatalf("GetExtensionBrowseCategoriesJSON = %q/%v", categoriesJSON, err)
}
CancelExtensionRequestJSON("req-home")
storeDir := filepath.Join(dir, "store")
if err := InitExtensionStoreJSON(storeDir); err != nil {
t.Fatalf("InitExtensionStoreJSON: %v", err)
}
if err := SetStoreRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
t.Fatalf("SetStoreRegistryURLJSON: %v", err)
}
store := getExtensionStore()
store.cache = &storeRegistry{Extensions: []storeExtension{{
ID: "coverage-ext",
Name: "coverage-ext",
Version: "1.0.0",
Description: "Coverage",
Category: CategoryMetadata,
Tags: []string{"metadata"},
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 storeJSON, err := GetStoreExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("GetStoreExtensionsJSON = %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 catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
}
if dest, err := buildStoreExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.spotiflac-ext",
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
}
if dest, err := buildStoreExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.sflx",
); err != nil || !strings.HasSuffix(dest, ".sflx") {
t.Fatalf("buildStoreExtensionDestPath sflx = %q/%v", dest, err)
}
if _, err := buildStoreExtensionDestPath(
dir,
" ",
"https://registry.example.com/coverage.sflx",
); err == nil {
t.Fatal("expected invalid extension id")
}
if err := ClearStoreCacheJSON(); err != nil {
t.Fatalf("ClearStoreCacheJSON: %v", err)
}
if err := ClearStoreRegistryURLJSON(); err != nil {
t.Fatalf("ClearStoreRegistryURLJSON: %v", err)
}
SetLibraryCoverCacheDirJSON(filepath.Join(dir, "covers"))
libraryDir := filepath.Join(dir, "library")
if err := os.MkdirAll(libraryDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(libraryDir, "Artist - Song.mp3"), []byte("not mp3"), 0600); err != nil {
t.Fatal(err)
}
if scanJSON, err := ScanLibraryFolderJSON(libraryDir); err != nil || !strings.Contains(scanJSON, "Song") {
t.Fatalf("ScanLibraryFolderJSON = %q/%v", scanJSON, err)
}
if scanJSON, err := ScanLibraryFolderIncrementalJSON(libraryDir, `[]`); err != nil || !strings.Contains(scanJSON, "Song") {
t.Fatalf("ScanLibraryFolderIncrementalJSON = %q/%v", scanJSON, err)
}
snapshotPath := filepath.Join(dir, "snapshot.json")
if err := os.WriteFile(snapshotPath, []byte(`[]`), 0600); err != nil {
t.Fatal(err)
}
if scanJSON, err := ScanLibraryFolderIncrementalFromSnapshotJSON(libraryDir, snapshotPath); err != nil || !strings.Contains(scanJSON, "Song") {
t.Fatalf("ScanLibraryFolderIncrementalFromSnapshotJSON = %q/%v", scanJSON, err)
}
if GetLibraryScanProgressJSON() == "" {
t.Fatal("expected scan progress JSON")
}
CancelLibraryScanJSON()
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)
}
}
+7 -390
View File
@@ -1,25 +1,6 @@
package gobackend
import (
"context"
"fmt"
"testing"
)
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
}
if got := GetExtensionFallbackProviderIDs(); got != nil {
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
}
}
import "testing"
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{
@@ -133,216 +114,6 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
}
}
func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) {
req := DownloadRequest{
TrackName: "Track",
ArtistName: "Artist",
}
result := DownloadResult{
Title: "Track",
Artist: "Artist",
DecryptionKey: "00112233",
}
resp := buildDownloadSuccessResponse(
req,
result,
"amazon",
"ok",
"/tmp/test.m4a",
false,
)
if resp.Decryption == nil {
t.Fatal("expected decryption descriptor to be present")
}
if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy {
t.Fatalf("strategy = %q", resp.Decryption.Strategy)
}
if resp.Decryption.Key != result.DecryptionKey {
t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey)
}
}
func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) {
got := formatMusicBrainzGenre([]musicBrainzTag{
{Name: "art pop", Count: 3},
{Name: "pop", Count: 8},
{Name: "dance pop", Count: 5},
})
if got != "Pop" {
t.Fatalf("genre = %q, want %q", got, "Pop")
}
}
func TestSelectMusicBrainzAlbumArtistPrefersMatchingRelease(t *testing.T) {
releases := []musicBrainzRelease{
{
Title: "Other Album",
ArtistCredit: []musicBrainzArtistCredit{
{Name: "Wrong Artist"},
},
},
{
Title: "Target Album",
ArtistCredit: []musicBrainzArtistCredit{
{Name: "Artist A", JoinPhrase: " & "},
{Name: "Artist B"},
},
},
}
got := selectMusicBrainzAlbumArtist(releases, "Target Album")
if got != "Artist A & Artist B" {
t.Fatalf("album artist = %q, want matching release artist credit", got)
}
}
func TestEnrichRequestExtendedMetadataUsesMusicBrainzAlbumArtist(t *testing.T) {
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC
origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC
defer func() {
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher
fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher
}()
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return &AlbumExtendedMetadata{}, nil
}
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
return "", fmt.Errorf("no genre")
}
fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) {
if isrc != "TESTISRC" || albumName != "Target Album" {
t.Fatalf("unexpected MusicBrainz args: %q / %q", isrc, albumName)
}
return "MusicBrainz Album Artist", nil
}
req := DownloadRequest{
ISRC: "TESTISRC",
ArtistName: "Track Artist",
AlbumName: "Target Album",
}
enrichRequestExtendedMetadata(&req)
if req.AlbumArtist != "MusicBrainz Album Artist" {
t.Fatalf("album artist = %q, want MusicBrainz value", req.AlbumArtist)
}
}
func TestEnrichRequestExtendedMetadataDoesNotFallbackAlbumArtistToTrackArtist(t *testing.T) {
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC
origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC
defer func() {
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher
fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher
}()
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return &AlbumExtendedMetadata{}, nil
}
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
return "", fmt.Errorf("no genre")
}
fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) {
return "", fmt.Errorf("no album artist")
}
req := DownloadRequest{
ISRC: "TESTISRC",
ArtistName: "Track Artist",
AlbumName: "Target Album",
}
enrichRequestExtendedMetadata(&req)
if req.AlbumArtist != "" {
t.Fatalf("album artist = %q, want empty when MusicBrainz has no value", req.AlbumArtist)
}
}
func TestEnrichExtraMetadataByISRCFallsBackToMusicBrainzGenre(t *testing.T) {
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
defer func() {
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
}()
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return nil, nil
}
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
if isrc != "TEST123" {
t.Fatalf("unexpected isrc: %q", isrc)
}
return "Alternative Rock", nil
}
genre := ""
label := ""
copyright := ""
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST123", &genre, &label, &copyright)
if genre != "Alternative Rock" {
t.Fatalf("genre = %q, want fallback genre", genre)
}
if label != "" {
t.Fatalf("label = %q, want empty", label)
}
if copyright != "" {
t.Fatalf("copyright = %q, want empty", copyright)
}
}
func TestEnrichExtraMetadataByISRCPrefersDeezerGenre(t *testing.T) {
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
defer func() {
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
}()
musicBrainzCalled := false
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return &AlbumExtendedMetadata{
Genre: "Synthpop",
Label: "EMI",
Copyright: "(C) Test",
}, nil
}
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
musicBrainzCalled = true
return "Rock", nil
}
genre := ""
label := ""
copyright := ""
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST456", &genre, &label, &copyright)
if genre != "Synthpop" {
t.Fatalf("genre = %q, want Deezer genre", genre)
}
if label != "EMI" {
t.Fatalf("label = %q, want Deezer label", label)
}
if copyright != "(C) Test" {
t.Fatalf("copyright = %q, want Deezer copyright", copyright)
}
if musicBrainzCalled {
t.Fatal("expected MusicBrainz not to be called when Deezer already provides genre")
}
}
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
req := reEnrichRequest{
SpotifyID: "spotify-track-id",
@@ -407,90 +178,6 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
}
}
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
AlbumName: "Album Name",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "wrong-rich-metadata",
Name: "Different Song",
Artists: "Different Artist",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "2024-03-09",
TrackNumber: 4,
DiscNumber: 1,
ISRC: "WRONG1234567",
ProviderID: "deezer",
},
}
if best := selectBestReEnrichTrack(req, tracks); best != nil {
t.Fatalf("selected track = %q, want no match", best.ID)
}
}
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
ISRC: "USRC17607839",
DurationMs: 999999000,
}
tracks := []ExtTrackMetadata{
{
ID: "same-isrc",
Name: "Different Song",
Artists: "Different Artist",
DurationMS: 180000,
ISRC: "USRC17607839",
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected exact ISRC candidate to be selected")
}
if best.ID != "same-isrc" {
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
}
}
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
req := reEnrichRequest{
TrackName: "Unknown Title",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "album-match",
Name: "Sign of the Times",
Artists: "Harry Styles",
AlbumName: "Harry Styles",
DurationMS: 180000,
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
}
if best.ID != "album-match" {
t.Fatalf("selected track = %q, want album-match", best.ID)
}
}
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song",
@@ -508,11 +195,13 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
metadata := buildReEnrichFFmpegMetadata(&req, "")
if metadata["TITLE"] != "Song" {
t.Fatalf("title = %q", metadata["TITLE"])
// Title and Artist are never written by re-enrich (they are search keys
// preserved as-is from the file).
if _, exists := metadata["TITLE"]; exists {
t.Fatalf("TITLE should not be in metadata: %#v", metadata)
}
if metadata["ARTIST"] != "Artist" {
t.Fatalf("artist = %q", metadata["ARTIST"])
if _, exists := metadata["ARTIST"]; exists {
t.Fatalf("ARTIST should not be in metadata: %#v", metadata)
}
if metadata["ALBUM"] != "Album" {
t.Fatalf("album = %q", metadata["ALBUM"])
@@ -535,75 +224,3 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
}
}
}
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
req := reEnrichRequest{
TrackName: "Sign of the Times",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
}
query := buildReEnrichSearchQuery(req)
if query != "Sign of the Times" {
t.Fatalf("query = %q", query)
}
req = reEnrichRequest{
TrackName: "Unknown Title",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
}
query = buildReEnrichSearchQuery(req)
if query != "Harry Styles" {
t.Fatalf("fallback album query = %q", query)
}
}
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
req := reEnrichRequest{}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
Name: "Resolved Song",
Artists: "Resolved Artist",
TrackNumber: 7,
TotalTracks: 12,
DiscNumber: 2,
TotalDiscs: 3,
Composer: "Composer",
})
if req.TrackNumber != 7 || req.TotalTracks != 12 {
t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks)
}
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
}
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
}
if req.Composer != "Composer" {
t.Fatalf("composer = %q", req.Composer)
}
}
func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) {
req := reEnrichRequest{
TrackNumber: 7,
TotalTracks: 12,
DiscNumber: 2,
TotalDiscs: 3,
Composer: "Composer",
}
metadata := buildReEnrichFFmpegMetadata(&req, "")
if metadata["TRACKNUMBER"] != "7/12" {
t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"])
}
if metadata["DISCNUMBER"] != "2/3" {
t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"])
}
if metadata["COMPOSER"] != "Composer" {
t.Fatalf("COMPOSER = %q", metadata["COMPOSER"])
}
}
-459
View File
@@ -1,459 +0,0 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
extensionHealthDefaultCache = 10 * time.Minute
extensionHealthMinCache = 60 * time.Second
extensionHealthUnknownCache = 2 * time.Minute
)
type ExtensionHealthResult struct {
ExtensionID string `json:"extension_id"`
Status string `json:"status"`
CheckedAt string `json:"checked_at"`
Checks []ExtensionHealthCheckResult `json:"checks"`
}
type ExtensionHealthCheckResult struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
URL string `json:"url"`
Method string `json:"method"`
ServiceKey string `json:"service_key,omitempty"`
Required bool `json:"required"`
Status string `json:"status"`
HTTPStatus int `json:"http_status,omitempty"`
LatencyMs int64 `json:"latency_ms"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
CheckedAt string `json:"checked_at"`
}
type cachedExtensionHealthResult struct {
result ExtensionHealthResult
expiresAt time.Time
}
var (
extensionHealthCacheMu sync.Mutex
extensionHealthCache = map[string]cachedExtensionHealthResult{}
)
func CheckExtensionHealthJSON(extensionID string) (string, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
bytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(bytes), nil
}
func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return CheckExtensionHealth(ext)
}
cacheKey := strings.TrimSpace(ext.ID)
if cacheKey == "" {
return CheckExtensionHealth(ext)
}
now := time.Now()
extensionHealthCacheMu.Lock()
cached, ok := extensionHealthCache[cacheKey]
if ok && now.Before(cached.expiresAt) {
extensionHealthCacheMu.Unlock()
return cached.result
}
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: time.Now().Add(ttl),
}
extensionHealthCacheMu.Unlock()
}
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
now := time.Now().UTC().Format(time.RFC3339)
result := ExtensionHealthResult{
ExtensionID: "",
Status: "unsupported",
CheckedAt: now,
Checks: []ExtensionHealthCheckResult{},
}
if ext == nil || ext.Manifest == nil {
result.Status = "offline"
return result
}
result.ExtensionID = ext.ID
checks := ext.Manifest.ServiceHealth
if len(checks) == 0 {
return result
}
result.Status = "online"
for _, check := range checks {
checkResult := runExtensionHealthCheck(ext.Manifest, check)
result.Checks = append(result.Checks, checkResult)
switch checkResult.Status {
case "offline":
if check.Required {
result.Status = "offline"
} else if result.Status == "online" {
result.Status = "degraded"
}
case "degraded":
if result.Status == "online" {
result.Status = "degraded"
}
case "unknown":
if result.Status == "online" {
result.Status = "unknown"
}
}
}
return result
}
func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
ttl := extensionHealthDefaultCache
for _, check := range checks {
if check.CacheTTLSeconds <= 0 {
continue
}
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
if checkTTL < extensionHealthMinCache {
checkTTL = extensionHealthMinCache
}
if checkTTL < ttl {
ttl = checkTTL
}
}
return ttl
}
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
method := strings.ToUpper(strings.TrimSpace(check.Method))
if method == "" {
method = http.MethodGet
}
now := time.Now().UTC().Format(time.RFC3339)
result := ExtensionHealthCheckResult{
ID: check.ID,
Label: check.Label,
URL: check.URL,
Method: method,
ServiceKey: strings.TrimSpace(check.ServiceKey),
Required: check.Required,
Status: "unknown",
CheckedAt: now,
}
parsed, err := url.Parse(check.URL)
if err != nil {
result.Status = "offline"
result.Error = fmt.Sprintf("invalid health URL: %v", err)
return result
}
if parsed.Scheme != "https" {
result.Status = "offline"
result.Error = "health check must use https"
return result
}
host := parsed.Hostname()
if host == "" {
result.Status = "offline"
result.Error = "health check URL hostname is required"
return result
}
if isPrivateIP(host) {
result.Status = "offline"
result.Error = "private/local health check host is not allowed"
return result
}
if manifest == nil || !manifest.IsDomainAllowed(host) {
result.Status = "offline"
result.Error = fmt.Sprintf("health check host '%s' is not in extension network permissions", host)
return result
}
if method != http.MethodGet && method != http.MethodHead {
result.Status = "offline"
result.Error = "health check method must be GET or HEAD"
return result
}
timeout := extensionHealthDefaultTimeout
if check.TimeoutMs > 0 {
timeout = time.Duration(check.TimeoutMs) * time.Millisecond
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, check.URL, nil)
if err != nil {
result.Status = "offline"
result.Error = err.Error()
return result
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgentForURL(parsed))
start := time.Now()
resp, err := NewMetadataHTTPClient(timeout).Do(req)
result.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
if isTransientExtensionHealthError(err) {
result.Status = "unknown"
} else {
result.Status = "offline"
}
result.Error = err.Error()
return result
}
defer resp.Body.Close()
result.HTTPStatus = resp.StatusCode
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
result.Status = "offline"
result.Message = resp.Status
return result
}
if method == http.MethodHead {
result.Status = "online"
result.Message = resp.Status
return result
}
body, err := io.ReadAll(io.LimitReader(resp.Body, extensionHealthMaxBodyBytes))
if err != nil {
result.Status = "degraded"
result.Error = err.Error()
return result
}
status, message := classifyExtensionHealthBody(body, check.ServiceKey)
result.Status = status
if message == "" {
result.Message = resp.Status
} else {
result.Message = message
}
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{}
if err := json.Unmarshal(body, &payload); err != nil {
return "online", ""
}
serviceKey = strings.TrimSpace(serviceKey)
if serviceKey != "" {
if status, message, ok := classifyExtensionHealthService(payload, serviceKey); ok {
return status, message
}
}
rawStatus, _ := payload["status"].(string)
normalized := strings.ToLower(strings.TrimSpace(rawStatus))
switch normalized {
case "", "ok", "up", "online", "healthy", "operational", "pass", "passing":
return "online", rawStatus
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) {
rawServices, ok := payload["services"]
if !ok {
return "", "", false
}
services, ok := rawServices.(map[string]interface{})
if !ok {
return "", "", false
}
rawService, ok := services[serviceKey]
if !ok {
return "unknown", fmt.Sprintf("service '%s' not found", serviceKey), true
}
service, ok := rawService.(map[string]interface{})
if !ok {
return "unknown", fmt.Sprintf("service '%s' has invalid health payload", serviceKey), true
}
label, _ := service["label"].(string)
detail, _ := service["detail"].(string)
errText, _ := service["error"].(string)
messageParts := []string{}
if strings.TrimSpace(label) != "" {
messageParts = append(messageParts, strings.TrimSpace(label))
}
if strings.TrimSpace(detail) != "" {
messageParts = append(messageParts, strings.TrimSpace(detail))
}
if strings.TrimSpace(errText) != "" {
messageParts = append(messageParts, strings.TrimSpace(errText))
}
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", joinedMessage, true
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return "degraded", joinedMessage, true
}
if statusCode == http.StatusInternalServerError && hasOK && okValue {
return "online", joinedMessage, true
}
if transient || isTransientHealthStatusCode(statusCode) {
return "unknown", joinedMessage, true
}
return "offline", joinedMessage, true
}
if isExtensionHealthAuthRequired(detail) {
return "degraded", joinedMessage, true
}
if transient {
return "unknown", joinedMessage, true
}
if hasOK {
if okValue {
return "online", joinedMessage, true
}
return "offline", joinedMessage, true
}
if !hasStatus {
return "unknown", joinedMessage, true
}
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
switch statusString {
case "ok", "up", "online", "healthy", "operational":
return "online", joinedMessage, true
case "degraded", "partial", "warning", "warn":
return "degraded", joinedMessage, true
case "down", "offline", "error", "failed", "fail", "unhealthy":
return "offline", joinedMessage, true
default:
return "unknown", joinedMessage, true
}
}
func isExtensionHealthAuthRequired(detail string) bool {
switch strings.ToLower(strings.TrimSpace(detail)) {
case "auth_required", "authorization_required", "login_required", "unauthorized":
return true
default:
return false
}
}
func isTransientHealthStatusMessage(text string) bool {
t := strings.ToLower(strings.TrimSpace(text))
if t == "" {
return false
}
return strings.Contains(t, "context deadline exceeded") ||
strings.Contains(t, "deadline exceeded") ||
strings.Contains(t, "timeout") ||
strings.Contains(t, "timed out") ||
strings.Contains(t, "temporarily unavailable") ||
strings.Contains(t, "try again")
}
func isTransientHealthStatusCode(code int) bool {
switch code {
case http.StatusRequestTimeout,
http.StatusTooManyRequests,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
func healthNumber(value interface{}) (int, bool) {
switch v := value.(type) {
case float64:
return int(v), true
case int:
return v, true
case json.Number:
n, err := v.Int64()
return int(n), err == nil
default:
return 0, false
}
}
@@ -1,151 +0,0 @@
package gobackend
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"testing"
)
func TestExtensionHealthClassificationAndValidation(t *testing.T) {
if status, msg := classifyExtensionHealthBody([]byte(`{"status":"degraded"}`), ""); status != "degraded" || msg != "degraded" {
t.Fatalf("status/message = %q/%q", status, msg)
}
if status, _ := classifyExtensionHealthBody([]byte(`not-json`), ""); status != "online" {
t.Fatalf("invalid JSON status = %q", status)
}
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") {
t.Fatalf("missing service = %q/%q/%v", status, msg, ok)
}
if n, ok := healthNumber(json.Number("503")); !ok || n != 503 {
t.Fatalf("health number = %d/%v", n, ok)
}
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)
}
manifest := &ExtensionManifest{Permissions: ExtensionPermissions{Network: []string{"status.example.com"}}}
invalidURL := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "bad", URL: "://bad"})
if invalidURL.Status != "offline" {
t.Fatalf("invalid URL = %#v", invalidURL)
}
insecure := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "http", URL: "http://status.example.com"})
if insecure.Status != "offline" || !strings.Contains(insecure.Error, "https") {
t.Fatalf("insecure = %#v", insecure)
}
disallowedHost := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "host", URL: "https://other.example.com"})
if disallowedHost.Status != "offline" || !strings.Contains(disallowedHost.Error, "permissions") {
t.Fatalf("host = %#v", disallowedHost)
}
badMethod := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "method", URL: "https://status.example.com", Method: "POST"})
if badMethod.Status != "offline" || !strings.Contains(badMethod.Error, "method") {
t.Fatalf("method = %#v", badMethod)
}
ext := &loadedExtension{
ID: "health-ext",
Manifest: &ExtensionManifest{
ServiceHealth: []ExtensionHealthCheck{
{ID: "required", URL: "http://status.example.com", Required: true},
{ID: "optional", URL: "http://status.example.com", Required: false},
},
},
}
if result := CheckExtensionHealth(ext); result.Status != "offline" || len(result.Checks) != 2 {
t.Fatalf("extension health = %#v", result)
}
}
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)
}
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)
}
if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") {
t.Fatalf("tidal cover = %q", got)
}
if got := upgradeToMaxQuality("https://static.qobuz.com/images/covers/ab/cd/foo_600.jpg"); !strings.Contains(got, "_max.jpg") {
t.Fatalf("qobuz cover = %q", got)
}
if data, err := downloadCoverToMemory("", false); err == nil || data != nil {
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)
}
body := `{"id":"1","type":"song","title":"Song","links":[{"type":"tidal","url":"https://tidal.com/browse/track/7"},{"type":"deezer","url":"https://www.deezer.com/track/9"},{"type":"spotify","url":"https://open.spotify.com/track/abc"}]}`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})}}
availability, err := client.GetAvailabilityFromSpotify("spotify-track")
if err != nil {
t.Fatalf("GetAvailabilityFromSpotify: %v", err)
}
if !availability.Tidal || !availability.Deezer || availability.DeezerID != "9" {
t.Fatalf("spotify availability = %#v", availability)
}
deezerAvailability, err := client.GetAvailabilityFromDeezer("9")
if err != nil {
t.Fatalf("GetAvailabilityFromDeezer: %v", err)
}
if deezerAvailability.SpotifyID != "abc" || !deezerAvailability.Tidal {
t.Fatalf("deezer availability = %#v", deezerAvailability)
}
errorClient := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
})}}
if _, err := errorClient.Search("bad", nil); err == nil {
t.Fatal("expected rate limit error")
}
}
+141 -295
View File
@@ -10,7 +10,6 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
@@ -44,24 +43,18 @@ func compareVersions(v1, v2 string) int {
return 0
}
func isExtensionPackagePath(filePath string) bool {
lowerPath := strings.ToLower(filePath)
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
}
type loadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *extensionRuntime
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"`
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 getExtensionInitSettings(extensionID string) map[string]interface{} {
@@ -80,7 +73,7 @@ func getExtensionInitSettings(extensionID string) map[string]interface{} {
return filtered
}
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
if ext.VM == nil || ext.runtime == nil {
if err := initializeVMLocked(ext); err != nil {
ext.Error = err.Error()
@@ -107,14 +100,14 @@ func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) er
return nil
}
func (ext *loadedExtension) ensureRuntimeReady() error {
func (ext *LoadedExtension) ensureRuntimeReady() error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return ensureRuntimeReadyLocked(ext, true)
}
func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
ext.VMMu.Lock()
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
ext.VMMu.Unlock()
@@ -123,32 +116,28 @@ func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
return ext.VM, nil
}
type extensionManager struct {
mu sync.RWMutex
// mutationMu serializes install/upgrade/remove (heavy FS + goja VM
// teardown/reload), which are not safe to run concurrently. Acquired before
// m.mu; "*Locked" helpers assume it is held.
mutationMu sync.Mutex
extensions map[string]*loadedExtension
type ExtensionManager struct {
mu sync.RWMutex
extensions map[string]*LoadedExtension
extensionsDir string
dataDir string
}
var (
globalExtManager *extensionManager
globalExtManager *ExtensionManager
globalExtManagerOnce sync.Once
)
func getExtensionManager() *extensionManager {
func GetExtensionManager() *ExtensionManager {
globalExtManagerOnce.Do(func() {
globalExtManager = &extensionManager{
extensions: make(map[string]*loadedExtension),
globalExtManager = &ExtensionManager{
extensions: make(map[string]*LoadedExtension),
}
})
return globalExtManager
}
func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -165,20 +154,14 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
return nil
}
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
return m.loadExtensionFromFileLocked(filePath)
}
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
zipReader, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package")
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
}
defer zipReader.Close()
@@ -203,16 +186,16 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
}
if manifestData == nil {
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
}
if !hasIndexJS {
return nil, fmt.Errorf("invalid extension package: index.js not found")
return nil, fmt.Errorf("Invalid extension package: index.js not found")
}
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("invalid extension manifest: %w", err)
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
m.mu.RLock()
@@ -228,11 +211,11 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
if exists {
versionCompare := compareVersions(manifest.Version, existingVersion)
if versionCompare > 0 {
return m.upgradeExtensionLocked(filePath)
return m.UpgradeExtension(filePath)
} else if versionCompare == 0 {
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
} else {
return nil, fmt.Errorf("cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
return nil, fmt.Errorf("Cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
}
}
@@ -240,7 +223,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
defer m.mu.Unlock()
if _, exists := m.extensions[manifest.Name]; exists {
return nil, fmt.Errorf("extension '%s' was installed by another process", manifest.DisplayName)
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
}
extDir := filepath.Join(m.extensionsDir, manifest.Name)
@@ -289,7 +272,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
ext := &loadedExtension{
ext := &LoadedExtension{
ID: manifest.Name,
Manifest: manifest,
Enabled: false, // New extensions start disabled
@@ -309,10 +292,9 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
return ext, nil
}
func initializeVMLocked(ext *loadedExtension) error {
func initializeVMLocked(ext *LoadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.indexProgram = nil
ext.initialized = false
vm := goja.New()
ext.VM = vm
@@ -322,13 +304,8 @@ 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)
runtime := NewExtensionRuntime(ext)
ext.runtime = runtime
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
@@ -353,7 +330,7 @@ func initializeVMLocked(ext *loadedExtension) error {
return goja.Undefined()
})
_, err = vm.RunProgram(indexProgram)
_, err = vm.RunString(string(jsCode))
if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err)
}
@@ -365,97 +342,23 @@ func initializeVMLocked(ext *loadedExtension) error {
return nil
}
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
vm := goja.New()
indexProgram := ext.indexProgram
if indexProgram == nil {
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
}
}
runtime := &extensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
cookieJar: nil,
dataDir: ext.DataDir,
vm: vm,
storageFlushDelay: defaultStorageFlushDelay,
}
if ext.runtime != nil && ext.runtime.cookieJar != nil {
runtime.cookieJar = ext.runtime.cookieJar
} else {
jar, _ := newSimpleCookieJar()
runtime.cookieJar = jar
}
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
GoLog("[Extension:%s] %v\n", ext.ID, args)
return goja.Undefined()
})
vm.Set("console", console)
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
})
if _, err := vm.RunProgram(indexProgram); err != nil {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
}
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
}
settings := getExtensionInitSettings(ext.ID)
if len(settings) > 0 {
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
runtime.closeStorageFlusher()
return nil, nil, err
}
}
return vm, runtime, nil
}
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return initializeVMLocked(ext)
}
func initializeExtensionRuntimeWithSettings(
vm *goja.Runtime,
extensionID string,
func initializeExtensionWithSettingsLocked(
ext *LoadedExtension,
settings map[string]interface{},
) error {
if ext.VM == nil {
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
}
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("failed to save settings")
return fmt.Errorf("Failed to save settings")
}
script := fmt.Sprintf(`
@@ -473,9 +376,11 @@ func initializeExtensionRuntimeWithSettings(
})()
`, string(settingsJSON))
result, err := vm.RunString(script)
result, err := ext.VM.RunString(script)
if err != nil {
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
ext.Error = fmt.Sprintf("initialize failed: %v", err)
ext.Enabled = false
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
return err
}
@@ -487,87 +392,61 @@ func initializeExtensionRuntimeWithSettings(
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
ext.Error = errMsg
ext.Enabled = false
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
return nil
}
func initializeExtensionWithSettingsLocked(
ext *loadedExtension,
settings map[string]interface{},
) error {
if ext.VM == nil {
return fmt.Errorf("extension failed to load: please reinstall the extension")
}
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
ext.Error = err.Error()
ext.Enabled = false
return err
}
ext.initialized = true
GoLog("[Extension] Initialized %s\n", ext.ID)
return nil
}
func runCleanupLocked(ext *loadedExtension) error {
func runCleanupLocked(ext *LoadedExtension) error {
if ext.VM != nil {
if err := runCleanupOnVM(ext.VM); err != nil {
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := ext.VM.RunString(script)
if err != nil {
return err
}
if ext.VM.Get("extension") != nil {
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
}
}
return nil
}
func runCleanupOnVM(vm *goja.Runtime) error {
if vm == nil {
return nil
}
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := vm.RunString(script)
if err != nil {
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
return nil
}
func teardownVMLocked(ext *loadedExtension) {
func teardownVMLocked(ext *LoadedExtension) {
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
}
@@ -582,7 +461,7 @@ func teardownVMLocked(ext *loadedExtension) {
ext.initialized = false
}
func validateExtensionLoad(ext *loadedExtension) error {
func validateExtensionLoad(ext *LoadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
@@ -593,13 +472,13 @@ func validateExtensionLoad(ext *loadedExtension) error {
return nil
}
func (m *extensionManager) UnloadExtension(extensionID string) error {
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("extension not found")
return fmt.Errorf("Extension not found")
}
ext.VMMu.Lock()
@@ -612,35 +491,35 @@ func (m *extensionManager) UnloadExtension(extensionID string) error {
return nil
}
func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) {
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
m.mu.RLock()
defer m.mu.RUnlock()
ext, exists := m.extensions[extensionID]
if !exists {
return nil, fmt.Errorf("extension not found")
return nil, fmt.Errorf("Extension not found")
}
return ext, nil
}
func (m *extensionManager) GetAllExtensions() []*loadedExtension {
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*loadedExtension, 0, len(m.extensions))
result := make([]*LoadedExtension, 0, len(m.extensions))
for _, ext := range m.extensions {
result = append(result, ext)
}
return result
}
func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("extension not found")
return fmt.Errorf("Extension not found")
}
if enabled {
@@ -668,7 +547,7 @@ func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return nil
}
func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
var loaded []string
var errors []error
@@ -692,7 +571,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
loaded = append(loaded, ext.ID)
}
}
} else if isExtensionPackagePath(entry.Name()) {
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
if err != nil {
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
@@ -706,7 +585,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
return loaded, errors
}
func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) {
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -718,12 +597,12 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("invalid extension manifest: %w", err)
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
indexPath := filepath.Join(dirPath, "index.js")
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
return nil, fmt.Errorf("extension is missing index.js file")
return nil, fmt.Errorf("Extension is missing index.js file")
}
if existing, exists := m.extensions[manifest.Name]; exists {
@@ -736,7 +615,7 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
ext := &loadedExtension{
ext := &LoadedExtension{
ID: manifest.Name,
Manifest: manifest,
Enabled: false, // Will be restored from settings store
@@ -764,10 +643,7 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
return ext, nil
}
func (m *extensionManager) RemoveExtension(extensionID string) error {
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
ext, err := m.GetExtension(extensionID)
if err != nil {
return err
@@ -787,20 +663,14 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
}
// Only allows upgrades (new version > current version), not downgrades
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
return m.upgradeExtensionLocked(filePath)
}
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
zipReader, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package")
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
}
defer zipReader.Close()
@@ -825,16 +695,16 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
}
if manifestData == nil {
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
}
if !hasIndexJS {
return nil, fmt.Errorf("invalid extension package: index.js not found")
return nil, fmt.Errorf("Invalid extension package: index.js not found")
}
newManifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("invalid extension manifest: %w", err)
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
m.mu.RLock()
@@ -842,15 +712,15 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
m.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("extension '%s' is not installed; use install instead of upgrade", newManifest.DisplayName)
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
}
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
if versionCompare < 0 {
return nil, fmt.Errorf("cannot downgrade extension: current version: %s, new version: %s", existing.Manifest.Version, newManifest.Version)
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
}
if versionCompare == 0 {
return nil, fmt.Errorf("extension is already at version %s", existing.Manifest.Version)
return nil, fmt.Errorf("Extension is already at version %s", existing.Manifest.Version)
}
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
@@ -907,7 +777,7 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
}
}
ext := &loadedExtension{
ext := &LoadedExtension{
ID: newManifest.Name,
Manifest: newManifest,
Enabled: wasEnabled, // Preserve enabled state from before upgrade
@@ -942,15 +812,15 @@ type ExtensionUpgradeInfo struct {
IsInstalled bool `json:"is_installed"`
}
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
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")
}
zipReader, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("cannot open extension file")
return nil, fmt.Errorf("Cannot open extension file")
}
defer zipReader.Close()
@@ -977,7 +847,7 @@ func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
newManifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("invalid manifest: %w", err)
return nil, fmt.Errorf("Invalid manifest: %w", err)
}
m.mu.RLock()
@@ -1001,7 +871,7 @@ func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return info, nil
}
func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
info, err := m.checkExtensionUpgradeInternal(filePath)
if err != nil {
return "", err
@@ -1015,7 +885,7 @@ func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
return string(jsonBytes), nil
}
func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions()
type ExtensionInfo struct {
@@ -1023,6 +893,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"`
@@ -1038,11 +909,9 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
HasLyricsProvider bool `json:"has_lyrics_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SkipLyrics bool `json:"skip_lyrics"`
StopProviderFallback bool `json:"stop_provider_fallback"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
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"`
}
@@ -1082,6 +951,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
Name: ext.Manifest.Name,
DisplayName: ext.Manifest.DisplayName,
Version: ext.Manifest.Version,
Author: ext.Manifest.Author,
Description: ext.Manifest.Description,
Homepage: ext.Manifest.Homepage,
IconPath: iconPath,
@@ -1097,11 +967,9 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
SkipLyrics: ext.Manifest.SkipLyrics,
StopProviderFallback: ext.Manifest.StopsProviderFallback(),
SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching,
PostProcessing: ext.Manifest.PostProcessing,
ServiceHealth: ext.Manifest.ServiceHealth,
Capabilities: ext.Manifest.Capabilities,
}
}
@@ -1114,13 +982,13 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
return string(jsonBytes), nil
}
func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("extension not found")
return fmt.Errorf("Extension not found")
}
ext.VMMu.Lock()
@@ -1132,13 +1000,13 @@ func (m *extensionManager) InitializeExtension(extensionID string, settings map[
return initializeExtensionWithSettingsLocked(ext, settings)
}
func (m *extensionManager) CleanupExtension(extensionID string) error {
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("extension not found")
return fmt.Errorf("Extension not found")
}
if ext.VM == nil {
@@ -1154,7 +1022,7 @@ func (m *extensionManager) CleanupExtension(extensionID string) error {
return nil
}
func (m *extensionManager) UnloadAllExtensions() {
func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Lock()
extensionIDs := make([]string, 0, len(m.extensions))
for id := range m.extensions {
@@ -1169,7 +1037,7 @@ func (m *extensionManager) UnloadAllExtensions() {
GoLog("[Extension] All extensions unloaded\n")
}
func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -1187,45 +1055,23 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
}
defer ext.VMMu.Unlock()
// Merge extension return values onto the top-level JSON object so Flutter can read
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
actionNameLiteral := strconv.Quote(actionName)
script := fmt.Sprintf(`
(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) {
isArr = Array.isArray(result);
}
if (!isArr) {
var out = { success: true };
for (var k in result) {
out[k] = result[k];
}
return out;
}
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
// Handle promise - return pending status
return { success: true, pending: true, message: 'Action started' };
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
}
} catch (e) {
return { success: false, error: e.toString() };
}
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)
}
return { success: false, error: 'Action function not found: %s' };
})()
`, actionName, actionName, actionName)
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
if err != nil {
@@ -1,143 +0,0 @@
package gobackend
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestExtensionManagerPackageLifecycle(t *testing.T) {
dir := t.TempDir()
extensionsDir := filepath.Join(dir, "extensions")
dataDir := filepath.Join(dir, "data")
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
t.Fatalf("SetDirectories: %v", err)
}
if err := GetExtensionSettingsStore().SetDataDir(dataDir); err != nil {
t.Fatalf("settings data dir: %v", err)
}
js := `
var cleaned = false;
registerExtension({
initialize: function(settings) { this.settings = settings || {}; },
cleanup: function() { cleaned = true; },
doAction: function() { return { message: "done", setting_updates: { quality: "lossless" } }; },
getHomeFeed: function() { return [{ id: "home", title: "Home" }]; },
getBrowseCategories: function() { return [{ id: "cat", title: "Category" }]; },
searchTracks: function() { return { tracks: [], total: 0 }; },
fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; },
getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; }
});
`
pkgV1 := filepath.Join(dir, "manager-ext-v1.spotiflac-ext")
createTestExtensionPackage(t, pkgV1, "manager-ext", "1.0.0", js, map[string]string{"../unsafe.txt": "skip"})
pkgV2 := filepath.Join(dir, "manager-ext-v2.spotiflac-ext")
createTestExtensionPackage(t, pkgV2, "manager-ext", "1.1.0", js, nil)
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")
}
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "bad.txt")); err == nil {
t.Fatal("expected bad extension suffix error")
}
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "missing.spotiflac-ext")); err == nil {
t.Fatal("expected invalid package error")
}
ext, err := manager.LoadExtensionFromFile(pkgV1)
if err != nil {
t.Fatalf("LoadExtensionFromFile: %v", err)
}
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")
}
installedJSON, err := manager.GetInstalledExtensionsJSON()
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{}
if err := json.Unmarshal([]byte(installedJSON), &installed); err != nil || len(installed) != 1 {
t.Fatalf("decode installed = %#v/%v", installed, err)
}
if err := GetExtensionSettingsStore().Set("manager-ext", "quality", "lossless"); err != nil {
t.Fatalf("settings Set: %v", err)
}
if err := manager.SetExtensionEnabled("manager-ext", true); err != nil {
t.Fatalf("enable extension: %v", err)
}
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 {
t.Fatalf("InitializeExtension: %v", err)
}
action, err := manager.InvokeAction("manager-ext", "doAction")
if err != nil || action["success"] != true || action["message"] != "done" {
t.Fatalf("InvokeAction = %#v/%v", action, err)
}
if err := manager.CleanupExtension("manager-ext"); err != nil {
t.Fatalf("CleanupExtension: %v", err)
}
if err := manager.SetExtensionEnabled("manager-ext", false); err != nil {
t.Fatalf("disable extension: %v", err)
}
if ext.VM != nil || ext.initialized {
t.Fatalf("expected VM teardown, got %#v", ext)
}
if _, err := manager.InvokeAction("manager-ext", "doAction"); err == nil {
t.Fatal("expected disabled action error")
}
upgradeJSON, err := manager.CheckExtensionUpgradeJSON(pkgV2)
if err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) {
t.Fatalf("CheckExtensionUpgradeJSON = %q/%v", upgradeJSON, err)
}
upgraded, err := manager.UpgradeExtension(pkgV2)
if err != nil {
t.Fatalf("UpgradeExtension: %v", err)
}
if upgraded.Manifest.Version != "1.1.0" {
t.Fatalf("upgraded = %#v", upgraded.Manifest)
}
if _, err := manager.UpgradeExtension(pkgV1); err == nil {
t.Fatal("expected downgrade error")
}
if err := manager.RemoveExtension("manager-ext"); err != nil {
t.Fatalf("RemoveExtension: %v", err)
}
if _, err := manager.GetExtension("manager-ext"); err == nil {
t.Fatal("expected removed extension missing")
}
dirExt := filepath.Join(extensionsDir, "dir-ext")
if err := os.MkdirAll(dirExt, 0755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"dir-ext","displayName":"dir-ext","version":"1.0.0","description":"Directory extension","type":["metadata_provider"],"permissions":{}}`
if err := os.WriteFile(filepath.Join(dirExt, "manifest.json"), []byte(manifest), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dirExt, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600); err != nil {
t.Fatal(err)
}
loaded, loadErrs := manager.LoadExtensionsFromDirectory(extensionsDir)
if len(loadErrs) != 0 || len(loaded) != 1 || loaded[0] != "dir-ext" {
t.Fatalf("LoadExtensionsFromDirectory = %#v/%#v", loaded, loadErrs)
}
manager.UnloadAllExtensions()
if len(manager.GetAllExtensions()) != 0 {
t.Fatal("expected all extensions unloaded")
}
}
+28 -107
View File
@@ -3,7 +3,6 @@ package gobackend
import (
"encoding/json"
"fmt"
"net/url"
"strings"
)
@@ -26,10 +25,9 @@ const (
)
type ExtensionPermissions struct {
Network []string `json:"network"`
Storage bool `json:"storage"`
File bool `json:"file"`
AllowHTTP bool `json:"allowHttp,omitempty"`
Network []string `json:"network"`
Storage bool `json:"storage"`
File bool `json:"file"`
}
type ExtensionSetting struct {
@@ -103,60 +101,27 @@ type PostProcessingConfig struct {
Hooks []PostProcessingHook `json:"hooks,omitempty"`
}
type ExtensionHealthCheck struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
URL string `json:"url"`
Method string `json:"method,omitempty"`
ServiceKey string `json:"serviceKey,omitempty"`
TimeoutMs int `json:"timeoutMs,omitempty"`
CacheTTLSeconds int `json:"cacheTtlSeconds,omitempty"`
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"`
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
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"`
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"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
type ManifestValidationError struct {
@@ -190,6 +155,10 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "version", Message: "version is required"}
}
if strings.TrimSpace(m.Author) == "" {
return &ManifestValidationError{Field: "author", Message: "author is required"}
}
if strings.TrimSpace(m.Description) == "" {
return &ManifestValidationError{Field: "description", Message: "description is required"}
}
@@ -222,6 +191,7 @@ 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),
@@ -237,48 +207,6 @@ func (m *ExtensionManifest) Validate() error {
}
}
for i, check := range m.ServiceHealth {
if strings.TrimSpace(check.ID) == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("serviceHealth[%d].id", i),
Message: "health check id is required",
}
}
if strings.TrimSpace(check.URL) == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("serviceHealth[%d].url", i),
Message: "health check url is required",
}
}
method := strings.ToUpper(strings.TrimSpace(check.Method))
if method != "" && method != "GET" && method != "HEAD" {
return &ManifestValidationError{
Field: fmt.Sprintf("serviceHealth[%d].method", i),
Message: "health check method must be GET or HEAD",
}
}
}
if m.SignedSession != nil {
if strings.TrimSpace(m.SignedSession.Namespace) == "" {
return &ManifestValidationError{Field: "signedSession.namespace", Message: "namespace is required"}
}
baseURL := strings.TrimSpace(m.SignedSession.BaseURL)
if baseURL == "" {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is required"}
}
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl must use https"}
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Hostname() == "" {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is invalid"}
}
if !m.IsDomainAllowed(parsed.Hostname()) {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl host must be listed in permissions.network"}
}
}
return nil
}
@@ -303,13 +231,6 @@ func (m *ExtensionManifest) IsLyricsProvider() bool {
return m.HasType(ExtensionTypeLyricsProvider)
}
func (m *ExtensionManifest) StopsProviderFallback() bool {
if m == nil {
return false
}
return m.StopProviderFallback || m.SkipBuiltInFallback
}
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain))
for _, allowed := range m.Permissions.Network {
-119
View File
@@ -1,119 +0,0 @@
package gobackend
import (
"encoding/json"
"time"
"github.com/dop251/goja"
)
type extensionCallPerf struct {
extensionID string
operation string
startedAt time.Time
initMs float64
jsMs float64
parseMs float64
items int
payloadBytes int
}
func newExtensionCallPerf(extensionID, operation string) *extensionCallPerf {
if !GetLogBuffer().IsLoggingEnabled() {
return nil
}
return &extensionCallPerf{
extensionID: extensionID,
operation: operation,
startedAt: time.Now(),
}
}
func extensionDurationMs(duration time.Duration) float64 {
return float64(duration.Microseconds()) / 1000.0
}
func (p *extensionCallPerf) recordInit(duration time.Duration) {
if p == nil {
return
}
p.initMs += extensionDurationMs(duration)
}
func (p *extensionCallPerf) recordJS(duration time.Duration) {
if p == nil {
return
}
p.jsMs += extensionDurationMs(duration)
}
func (p *extensionCallPerf) recordParse(duration time.Duration) {
if p == nil {
return
}
p.parseMs += extensionDurationMs(duration)
}
func (p *extensionCallPerf) recordPayload(value goja.Value) {
if p == nil || gojaValueIsEmpty(value) {
return
}
if payload, err := json.Marshal(value); err == nil {
p.payloadBytes = len(payload)
}
}
func (p *extensionCallPerf) setPayloadBytes(payloadBytes int) {
if p == nil {
return
}
p.payloadBytes = payloadBytes
}
func (p *extensionCallPerf) setItems(items int) {
if p == nil {
return
}
p.items = items
}
func (p *extensionCallPerf) finish() {
if p == nil {
return
}
LogDebug(
"ExtensionPerf",
"extension=%s op=%s totalMs=%.1f initMs=%.1f jsMs=%.1f parseMs=%.1f items=%d payloadBytes=%d",
p.extensionID,
p.operation,
extensionDurationMs(time.Since(p.startedAt)),
p.initMs,
p.jsMs,
p.parseMs,
p.items,
p.payloadBytes,
)
}
func countExtensionTopLevelItems(vm *goja.Runtime, value goja.Value) int {
if gojaValueIsEmpty(value) {
return 0
}
if length, err := gojaArrayLength(value, vm); err == nil && length > 0 {
return length
}
obj := value.ToObject(vm)
for _, key := range []string{"items", "tracks", "sections", "albums", "artists", "playlists", "results"} {
child := obj.Get(key)
if gojaValueIsEmpty(child) {
continue
}
if length, err := gojaArrayLength(child, vm); err == nil && length > 0 {
return length
}
}
return 1
}
@@ -1,164 +0,0 @@
package gobackend
import (
"path/filepath"
"testing"
)
func TestExtensionProviderWrapperFullSurface(t *testing.T) {
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
provider := newExtensionProviderWrapper(ext)
search, err := provider.SearchTracks("query", 5)
if err != nil {
t.Fatalf("SearchTracks: %v", err)
}
if search.Total != 1 || search.Tracks[0].ProviderID != ext.ID || search.Tracks[0].ExternalLinks["tidal"] == "" {
t.Fatalf("search = %#v", search)
}
track, err := provider.GetTrack("track-1")
if err != nil {
t.Fatalf("GetTrack: %v", err)
}
if track.Name != "Track track-1" || track.ProviderID != ext.ID || track.AudioQuality == "" {
t.Fatalf("track = %#v", track)
}
album, err := provider.GetAlbum("album-1")
if err != nil {
t.Fatalf("GetAlbum: %v", err)
}
if album.ProviderID != ext.ID || len(album.Tracks) != 1 || album.Tracks[0].ProviderID != ext.ID {
t.Fatalf("album = %#v", album)
}
playlist, err := provider.GetPlaylist("playlist-1")
if err != nil {
t.Fatalf("GetPlaylist: %v", err)
}
if playlist.Name != "Playlist playlist-1" || playlist.ProviderID != ext.ID {
t.Fatalf("playlist = %#v", playlist)
}
artist, err := provider.GetArtist("artist-1")
if err != nil {
t.Fatalf("GetArtist: %v", err)
}
if artist.ProviderID != ext.ID || len(artist.Releases) != 1 || artist.Releases[0].ProviderID != ext.ID {
t.Fatalf("artist = %#v", artist)
}
enriched, err := provider.EnrichTrack(&ExtTrackMetadata{ID: "track-1", Name: "Old", ProviderID: ext.ID})
if err != nil {
t.Fatalf("EnrichTrack: %v", err)
}
if enriched.Name != "Enriched" || enriched.ProviderID != ext.ID {
t.Fatalf("enriched = %#v", enriched)
}
availability, err := provider.CheckAvailability("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz")
if err != nil {
t.Fatalf("CheckAvailability: %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)
})
if err != nil {
t.Fatalf("Download: %v", err)
}
if !download.Success || download.Decryption == nil || download.DecryptionKey != "001122" || len(progress) != 1 || progress[0] != 100 {
t.Fatalf("download = %#v progress=%v", download, progress)
}
lyrics, err := provider.FetchLyrics("Song", "Artist", "Album", 180)
if err != nil {
t.Fatalf("GetLyrics: %v", err)
}
if lyrics.Provider != ext.ID || len(lyrics.Lines) != 1 || lyrics.Lines[0].Words != "Hello" {
t.Fatalf("lyrics = %#v", lyrics)
}
urlResult, err := provider.HandleURL("https://example.test/track/1")
if err != nil {
t.Fatalf("HandleURL: %v", err)
}
if urlResult.Track == nil || urlResult.Track.Name == "" || len(urlResult.Tracks) != 1 || urlResult.Album == nil || urlResult.Artist == nil {
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")
if err != nil {
t.Fatalf("PostProcess: %v", err)
}
if !post.Success || post.BitDepth != 24 || post.SampleRate != 96000 {
t.Fatalf("post = %#v", post)
}
}
func TestExtensionProviderAndManagerSelectionHelpers(t *testing.T) {
manifest := &ExtensionManifest{Capabilities: map[string]interface{}{
"replacesBuiltInProviders": []interface{}{" Deezer ", 7, ""},
}}
if values := manifestCapabilityStringList(manifest, "replacesBuiltInProviders"); len(values) != 1 || values[0] != "deezer" {
t.Fatalf("capability list = %#v", values)
}
if !extensionReplacesBuiltInProvider(&loadedExtension{Manifest: manifest}, "deezer") || extensionReplacesBuiltInProvider(nil, "deezer") {
t.Fatal("extension replacement mismatch")
}
if trimKnownProviderPrefix("Deezer:101", "deezer") != "101" || trimKnownProviderPrefix("101", "deezer") != "101" {
t.Fatal("trimKnownProviderPrefix mismatch")
}
if metadataTrackDedupKey(ExtTrackMetadata{ISRC: "usrc"}) != "isrc:USRC" ||
metadataTrackDedupKey(ExtTrackMetadata{SpotifyID: "sp"}) != "spotify:sp" ||
metadataTrackDedupKey(ExtTrackMetadata{ProviderID: "p", ID: "1"}) != "p:1" {
t.Fatal("metadata dedup key mismatch")
}
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider, ExtensionTypeMetadataProvider)
manager.extensions[downloadExt.ID] = downloadExt
if providers := manager.GetDownloadProviders(); len(providers) != 1 {
t.Fatalf("download providers = %#v", providers)
}
SetProviderPriority([]string{"deezer", "coverage-ext", "coverage-ext", " "})
if priority := GetProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
t.Fatalf("provider priority = %#v", priority)
}
SetExtensionFallbackProviderIDs([]string{"a", "a", " ", "b"})
if ids := GetExtensionFallbackProviderIDs(); len(ids) != 2 || !isExtensionFallbackAllowed("a") || isExtensionFallbackAllowed("z") {
t.Fatalf("fallback ids = %#v", ids)
}
SetExtensionFallbackProviderIDs(nil)
if !isExtensionFallbackAllowed("z") {
t.Fatal("nil fallback list should allow all")
}
SetMetadataProviderPriority([]string{"spotify", "deezer", "coverage-ext", "coverage-ext"})
if priority := GetMetadataProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
t.Fatalf("metadata priority = %#v", priority)
}
}
File diff suppressed because it is too large Load Diff
+39 -759
View File
@@ -1,88 +1,14 @@
package gobackend
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
import "testing"
"github.com/dop251/goja"
)
func TestSetMetadataProviderPriorityStripsRetiredBuiltIns(t *testing.T) {
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
original := GetMetadataProviderPriority()
defer SetMetadataProviderPriority(original)
SetMetadataProviderPriority([]string{"qobuz"})
SetMetadataProviderPriority([]string{"tidal"})
got := GetMetadataProviderPriority()
if len(got) != 0 {
t.Fatalf("expected retired built-in qobuz to be stripped, got %v", got)
}
}
func TestSetExtensionFallbackProviderIDsDedupesExtensions(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"ext-a", "ext-a", " ext-b "})
got := GetExtensionFallbackProviderIDs()
want := []string{"ext-a", "ext-b"}
if len(got) != len(want) {
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
}
}
}
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs(nil)
if !isExtensionFallbackAllowed("custom-ext") {
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
}
}
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
if !isExtensionFallbackAllowed("allowed-ext") {
t.Fatal("expected explicitly allowed extension to be permitted")
}
if isExtensionFallbackAllowed("blocked-ext") {
t.Fatal("expected extension outside allowlist to be blocked")
}
if isExtensionFallbackAllowed("deezer") {
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
}
}
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
original := GetProviderPriority()
defer SetProviderPriority(original)
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
got := GetProviderPriority()
want := []string{"custom-ext"}
want := []string{"tidal", "deezer", "qobuz"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
@@ -93,696 +19,50 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
}
}
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
original := GetProviderPriority()
defer SetProviderPriority(original)
manager := getExtensionManager()
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
ext.ID = "deezer"
ext.Manifest.Name = "deezer"
manager.mu.Lock()
previous, hadPrevious := manager.extensions[ext.ID]
manager.extensions[ext.ID] = ext
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadPrevious {
manager.extensions[ext.ID] = previous
} else {
delete(manager.extensions, ext.ID)
}
manager.mu.Unlock()
}()
SetProviderPriority([]string{"deezer", "custom-ext"})
got := GetProviderPriority()
want := []string{"deezer", "custom-ext"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
}
}
}
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
manager := getExtensionManager()
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
amazon.ID = "amazon"
amazon.Manifest.Name = "amazon"
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "://bad",
Required: true,
}}
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
plain.ID = "plain"
plain.Manifest.Name = "plain"
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
deezer.ID = "deezer"
deezer.Manifest.Name = "deezer"
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "https://example.test/health",
}}
manager.mu.Lock()
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
previousPlain, hadPlain := manager.extensions[plain.ID]
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
manager.extensions[amazon.ID] = amazon
manager.extensions[plain.ID] = plain
manager.extensions[deezer.ID] = deezer
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadAmazon {
manager.extensions[amazon.ID] = previousAmazon
} else {
delete(manager.extensions, amazon.ID)
}
if hadPlain {
manager.extensions[plain.ID] = previousPlain
} else {
delete(manager.extensions, plain.ID)
}
if hadDeezer {
manager.extensions[deezer.ID] = previousDeezer
} else {
delete(manager.extensions, deezer.ID)
}
manager.mu.Unlock()
extensionHealthCacheMu.Lock()
delete(extensionHealthCache, deezer.ID)
extensionHealthCacheMu.Unlock()
}()
extensionHealthCacheMu.Lock()
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
result: ExtensionHealthResult{
ExtensionID: deezer.ID,
Status: "online",
CheckedAt: time.Now().UTC().Format(time.RFC3339),
},
expiresAt: time.Now().Add(time.Minute),
}
extensionHealthCacheMu.Unlock()
got := prioritizeFallbackProvidersByHealth(
[]string{"amazon", "plain", "deezer"},
manager,
"",
)
want := []string{"deezer", "plain"}
if len(got) != len(want) {
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
}
}
}
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
if normalized == nil {
t.Fatal("expected legacy decryption key to produce normalized descriptor")
}
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
t.Fatalf("strategy = %q", normalized.Strategy)
}
if normalized.Key != "001122" {
t.Fatalf("key = %q", normalized.Key)
}
if normalized.InputFormat != "mov" {
t.Fatalf("input format = %q", normalized.InputFormat)
}
}
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
Strategy: "mp4_decryption_key",
Key: "abcd",
InputFormat: "",
}, "")
if normalized == nil {
t.Fatal("expected descriptor to remain available")
}
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
t.Fatalf("strategy = %q", normalized.Strategy)
}
if normalized.InputFormat != "mov" {
t.Fatalf("input format = %q", normalized.InputFormat)
}
}
func TestExtensionDownloadUsesIsolatedRuntimeForConcurrentCalls(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(500 * time.Millisecond)
_, _ = w.Write([]byte("ok"))
}))
defer server.Close()
setPrivateIPCache("download.test", false, time.Minute)
originalTransport := sharedTransport
testTransport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
},
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
sharedTransport = testTransport
defer func() {
testTransport.CloseIdleConnections()
sharedTransport = originalTransport
}()
extDir := t.TempDir()
if err := os.WriteFile(filepath.Join(extDir, "index.js"), []byte(`
registerExtension({
download: function(trackID, quality, outputPath, onProgress) {
var result = file.download('https://download.test/' + trackID, outputPath, {
onProgress: function(written, total) {
if (onProgress) onProgress(50);
}
});
if (!result || !result.success) {
return {
success: false,
error_message: result && result.error ? result.error : 'download failed',
error_type: 'download_error'
};
}
if (onProgress) onProgress(100);
return { success: true, file_path: result.path };
}
});
`), 0600); err != nil {
t.Fatalf("write extension index: %v", err)
}
outputDir := t.TempDir()
SetAllowedDownloadDirs([]string{outputDir})
defer SetAllowedDownloadDirs(nil)
ext := &loadedExtension{
ID: "concurrent-download",
Manifest: &ExtensionManifest{
Name: "concurrent-download",
Description: "Concurrent download test",
Version: "1.0.0",
Types: []ExtensionType{ExtensionTypeDownloadProvider},
Permissions: ExtensionPermissions{
Network: []string{"download.test"},
File: true,
},
},
Enabled: true,
SourceDir: extDir,
DataDir: t.TempDir(),
}
provider := newExtensionProviderWrapper(ext)
start := time.Now()
var wg sync.WaitGroup
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
result, err := provider.Download(
fmt.Sprintf("track-%d", i),
"LOSSLESS",
filepath.Join(outputDir, fmt.Sprintf("track-%d.flac", i)),
"",
nil,
)
if err != nil {
errs <- err
return
}
if result == nil || !result.Success {
errs <- fmt.Errorf("download failed: %#v", result)
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
if elapsed := time.Since(start); elapsed >= 850*time.Millisecond {
t.Fatalf("expected same-extension downloads to overlap, elapsed %s", elapsed)
}
}
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := buildOutputPath(DownloadRequest{
TrackName: "Song",
ArtistName: "Artist",
OutputDir: outputDir,
OutputExt: ".flac",
FilenameFormat: "",
})
if !isPathInAllowedDirs(outputPath) {
t.Fatalf("expected output path %q to be allowed", outputPath)
}
}
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := filepath.Join(outputDir, "custom.flac")
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
OutputPath: outputPath,
}, ext)
if resolved != outputPath {
t.Fatalf("resolved output path = %q", resolved)
}
if !isPathInAllowedDirs(outputPath) {
t.Fatalf("expected output path %q to be allowed", outputPath)
}
}
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
SetAllowedDownloadDirs(nil)
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
TrackName: "Song",
ArtistName: "Artist",
OutputDir: filepath.Join("Artist", "Album"),
OutputFD: 123,
OutputExt: ".flac",
}, ext)
expectedBase := filepath.Join(ext.DataDir, "downloads")
if !isPathWithinBase(expectedBase, resolved) {
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
}
if !isPathInAllowedDirs(resolved) {
t.Fatalf("expected resolved output path %q to be allowed", resolved)
}
}
func TestBuildOutputPathSanitizesTemplateFilename(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := buildOutputPath(DownloadRequest{
TrackName: `Gehra Hua (From "Dhurandhar")`,
ArtistName: "Artist",
OutputDir: outputDir,
OutputExt: ".flac",
FilenameFormat: "{artist} - {title}",
})
base := filepath.Base(outputPath)
if strings.ContainsAny(base, `<>:"/\|?*`) {
t.Fatalf("output filename still contains illegal characters: %q", base)
}
if strings.Contains(base, `"`) {
t.Fatalf("output filename still contains straight double quote: %q", base)
}
}
func TestBuildOutputPathForExtensionSanitizesTemplateFilename(t *testing.T) {
SetAllowedDownloadDirs(nil)
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
TrackName: `Gehra Hua (From "Dhurandhar")`,
ArtistName: "Artist",
OutputFD: 123,
OutputExt: ".flac",
FilenameFormat: "{artist} - {title}",
}, ext)
base := filepath.Base(resolved)
if strings.ContainsAny(base, `<>:"/\|?*`) {
t.Fatalf("extension output filename still contains illegal characters: %q", base)
}
}
func TestShouldStopProviderFallback(t *testing.T) {
if shouldStopProviderFallback(nil) {
t.Fatal("nil availability should not stop fallback")
}
if shouldStopProviderFallback(&ExtAvailabilityResult{Available: false}) {
t.Fatal("availability without skip_fallback should not stop fallback")
}
if !shouldStopProviderFallback(&ExtAvailabilityResult{Available: false, SkipFallback: true}) {
t.Fatal("skip_fallback availability should stop fallback")
}
}
func TestBuildExtensionFallbackStoppedResponsePrefersAvailabilityReason(t *testing.T) {
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
Reason: "direct SoundCloud track ID",
SkipFallback: true,
}, errors.New("ignored"))
if resp.Service != "soundcloud" {
t.Fatalf("service = %q", resp.Service)
}
if resp.Error != "Fallback stopped by soundcloud: direct SoundCloud track ID" {
t.Fatalf("unexpected error message: %q", resp.Error)
}
if resp.ErrorType != "extension_error" {
t.Fatalf("error type = %q", resp.ErrorType)
}
}
func TestBuildExtensionFallbackStoppedResponseFallsBackToError(t *testing.T) {
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
SkipFallback: true,
}, errors.New("lookup failed"))
if resp.Error != "Fallback stopped by soundcloud: lookup failed" {
t.Fatalf("unexpected error message: %q", resp.Error)
}
}
func TestShouldAbortCancelledFallbackWithCancelledError(t *testing.T) {
if !shouldAbortCancelledFallback("", ErrDownloadCancelled) {
t.Fatal("expected cancelled error to abort fallback")
}
}
func TestShouldAbortCancelledFallbackWithCancelledItemState(t *testing.T) {
const itemID = "cancelled-item"
initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
cancelDownload(itemID)
if !shouldAbortCancelledFallback(itemID, errors.New("generic failure")) {
t.Fatal("expected cancelled item state to abort fallback even for generic errors")
}
}
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "track.flac")
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
tempM4A := filepath.Join(t.TempDir(), "track.m4a")
if err := os.WriteFile(tempM4A, []byte("not-flac"), 0644); err != nil {
t.Fatalf("failed to create temp m4a file: %v", err)
}
if canEmbedGenreLabel("relative.flac") {
t.Fatal("expected relative path to be rejected")
}
if canEmbedGenreLabel("content://example") {
t.Fatal("expected content URI to be rejected")
}
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
t.Fatal("expected missing file to be rejected")
}
if canEmbedGenreLabel(tempM4A) {
t.Fatalf("expected non-FLAC file %q to be rejected", tempM4A)
}
if !canEmbedGenreLabel(tempFile) {
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
}
}
func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) {
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
originalPriority := GetMetadataProviderPriority()
originalSearch := searchBuiltInMetadataTracksFunc
defer func() {
SetMetadataProviderPriority(originalPriority)
searchBuiltInMetadataTracksFunc = originalSearch
}()
SetMetadataProviderPriority([]string{"qobuz"})
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
manager := getExtensionManager()
var calls []string
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
calls = append(calls, providerID)
switch providerID {
case "qobuz":
return []ExtTrackMetadata{
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
}, nil
case "tidal":
return []ExtTrackMetadata{
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
}, nil
case "deezer":
return []ExtTrackMetadata{
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
}, nil
default:
return nil, nil
}
}
manager := GetExtensionManager()
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
if err != nil {
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
}
if len(tracks) != 0 {
t.Fatalf("expected no tracks from retired built-in provider, got %+v", tracks)
}
}
func TestParseExtensionSearchResultAcceptsObjectAndArrayShapes(t *testing.T) {
vm := goja.New()
value, err := vm.RunString(`({
tracks: [{
id: "track-1",
name: "Song",
artists: "Artist",
album_name: "Album",
duration_ms: 123000,
cover_url: "https://img.test/cover.jpg",
external_links: { spotify: "spotify:track:1" },
audio_quality: "LOSSLESS"
}],
total: 9
})`)
if err != nil {
t.Fatalf("build object search result: %v", err)
}
result, err := parseExtensionSearchResult(vm, value)
if err != nil {
t.Fatalf("parse object search result: %v", err)
}
if result.Total != 9 || len(result.Tracks) != 1 {
t.Fatalf("unexpected object result: %+v", result)
}
track := result.Tracks[0]
if track.ID != "track-1" ||
track.AlbumName != "Album" ||
track.DurationMS != 123000 ||
track.CoverURL != "https://img.test/cover.jpg" ||
track.ExternalLinks["spotify"] != "spotify:track:1" ||
track.AudioQuality != "LOSSLESS" {
t.Fatalf("unexpected parsed track: %+v", track)
}
arrayValue, err := vm.RunString(`[
{id: "track-2", name: "Other Song", artists: "Other Artist", albumName: "Other Album", durationMs: 456000}
]`)
if err != nil {
t.Fatalf("build array search result: %v", err)
}
arrayResult, err := parseExtensionSearchResult(vm, arrayValue)
if err != nil {
t.Fatalf("parse array search result: %v", err)
}
if arrayResult.Total != 1 ||
len(arrayResult.Tracks) != 1 ||
arrayResult.Tracks[0].AlbumName != "Other Album" ||
arrayResult.Tracks[0].DurationMS != 456000 {
t.Fatalf("unexpected array result: %+v", arrayResult)
}
}
func TestParseExtensionMetadataAndDownloadResults(t *testing.T) {
vm := goja.New()
value, err := vm.RunString(`({
id: "album-1",
name: "Album",
artists: "Artist",
artistId: "artist-1",
coverUrl: "https://img.test/album.jpg",
releaseDate: "2024-02-03",
totalTracks: 2,
albumType: "album",
tracks: [
{id: "track-1", name: "Song 1", artists: "Artist", durationMs: 180000},
{id: "track-2", name: "Song 2", artists: "Artist", duration_ms: 181000}
]
})`)
if err != nil {
t.Fatalf("build album value: %v", err)
}
album, err := parseExtensionAlbumValue(vm, value)
if err != nil {
t.Fatalf("parse album: %v", err)
}
if album.ID != "album-1" ||
album.ArtistID != "artist-1" ||
album.CoverURL != "https://img.test/album.jpg" ||
album.TotalTracks != 2 ||
len(album.Tracks) != 2 ||
album.Tracks[0].DurationMS != 180000 ||
album.Tracks[1].DurationMS != 181000 {
t.Fatalf("unexpected album: %+v", album)
}
artistValue, err := vm.RunString(`({
id: "artist-1",
name: "Artist",
imageUrl: "https://img.test/artist.jpg",
headerImage: "https://img.test/header.jpg",
listeners: 1234,
albums: [{id: "album-1", name: "Album", tracks: [{id: "track-1", name: "Song"}]}],
releases: [{id: "single-1", name: "Single"}],
topTracks: [{id: "top-1", name: "Top Song"}]
})`)
if err != nil {
t.Fatalf("build artist value: %v", err)
}
artist, err := parseExtensionArtistValue(vm, artistValue)
if err != nil {
t.Fatalf("parse artist: %v", err)
}
if artist.ID != "artist-1" ||
artist.ImageURL != "https://img.test/artist.jpg" ||
artist.HeaderImage != "https://img.test/header.jpg" ||
artist.Listeners != 1234 ||
len(artist.Albums) != 1 ||
len(artist.Albums[0].Tracks) != 1 ||
len(artist.Releases) != 1 ||
len(artist.TopTracks) != 1 {
t.Fatalf("unexpected artist: %+v", artist)
}
downloadValue, err := vm.RunString(`({
success: true,
filePath: "/tmp/song.flac",
alreadyExists: true,
bitDepth: 24,
sampleRate: 96000,
title: "Song",
albumArtist: "Album Artist",
lyricsLrc: "[00:00.00]Line",
decryptionKey: "001122",
decryption: {
strategy: "mp4_decryption_key",
key: "001122",
inputFormat: "m4a",
options: { map: "0:a" }
}
})`)
if err != nil {
t.Fatalf("build download value: %v", err)
}
download := parseExtensionDownloadResultValue(vm, downloadValue)
if !download.Success ||
download.FilePath != "/tmp/song.flac" ||
!download.AlreadyExists ||
download.BitDepth != 24 ||
download.SampleRate != 96000 ||
download.AlbumArtist != "Album Artist" ||
download.LyricsLRC != "[00:00.00]Line" ||
download.Decryption == nil ||
download.Decryption.InputFormat != "m4a" ||
download.Decryption.Options["map"] != "0:a" {
t.Fatalf("unexpected download result: %+v", download)
}
availabilityValue, err := vm.RunString(`({ available: true, trackId: "track-1", skipFallback: true, reason: "direct" })`)
if err != nil {
t.Fatalf("build availability value: %v", err)
}
availability := parseExtensionAvailabilityValue(vm, availabilityValue)
if !availability.Available || availability.TrackID != "track-1" || !availability.SkipFallback || availability.Reason != "direct" {
t.Fatalf("unexpected availability: %+v", availability)
}
}
func TestParseExtensionURLHandleResult(t *testing.T) {
vm := goja.New()
value, err := vm.RunString(`({
type: "album",
name: "Shared Album",
coverUrl: "https://img.test/shared.jpg",
track: { id: "track-1", name: "Song" },
tracks: [{ id: "track-2", name: "Song 2" }],
album: { id: "album-1", name: "Album", tracks: [{ id: "track-3", name: "Song 3" }] },
artist: { id: "artist-1", name: "Artist", topTracks: [{ id: "track-4", name: "Song 4" }] }
})`)
if err != nil {
t.Fatalf("build URL handle value: %v", err)
}
result, err := parseExtensionURLHandleValue(vm, value)
if err != nil {
t.Fatalf("parse URL handle: %v", err)
}
if result.Type != "album" ||
result.CoverURL != "https://img.test/shared.jpg" ||
result.Track == nil ||
result.Track.ID != "track-1" ||
len(result.Tracks) != 1 ||
result.Album == nil ||
len(result.Album.Tracks) != 1 ||
result.Artist == nil ||
len(result.Artist.TopTracks) != 1 {
t.Fatalf("unexpected URL handle result: %+v", result)
}
}
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)
}
post := parseExtensionPostProcessValue(vm, postValue)
if !post.Success || post.NewFilePath != "/tmp/new.flac" || post.NewFileURI != "content://new" || post.BitDepth != 24 || post.SampleRate != 96000 {
t.Fatalf("unexpected post-process result: %+v", post)
}
lyricsValue, err := vm.RunString(`({
syncType: "LINE_SYNCED",
instrumental: false,
plainLyrics: "Line",
provider: "Lyrics Provider",
lines: [{ startTimeMs: 1000, words: "Line", endTimeMs: 2000 }]
})`)
if err != nil {
t.Fatalf("build lyrics value: %v", err)
}
lyrics, err := parseExtensionLyricsValue(vm, lyricsValue)
if err != nil {
t.Fatalf("parse lyrics: %v", err)
}
if lyrics.SyncType != "LINE_SYNCED" ||
lyrics.PlainLyrics != "Line" ||
lyrics.Provider != "Lyrics Provider" ||
len(lyrics.Lines) != 1 ||
lyrics.Lines[0].StartTimeMs != 1000 ||
lyrics.Lines[0].EndTimeMs != 2000 {
t.Fatalf("unexpected lyrics result: %+v", lyrics)
if len(tracks) != 3 {
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
}
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
t.Fatalf("unexpected track provider order: %+v", tracks)
}
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
t.Fatalf("unexpected provider call order: %v", calls)
}
}
+14 -157
View File
@@ -5,38 +5,13 @@ import (
"net"
"net/http"
"net/url"
"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 (
@@ -105,7 +80,7 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
state.IsAuthenticated = accessToken != ""
}
type extensionRuntime struct {
type ExtensionRuntime struct {
extensionID string
manifest *ExtensionManifest
settings map[string]interface{}
@@ -118,9 +93,6 @@ type extensionRuntime struct {
activeDownloadMu sync.RWMutex
activeDownloadItemID string
activeRequestMu sync.RWMutex
activeRequestID string
storageMu sync.RWMutex
storageCache map[string]interface{}
storageLoaded bool
@@ -151,10 +123,10 @@ var (
privateIPCacheMu sync.RWMutex
)
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
jar, _ := newSimpleCookieJar()
runtime := &extensionRuntime{
runtime := &ExtensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
@@ -164,131 +136,42 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
storageFlushDelay: defaultStorageFlushDelay,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false)
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
return runtime
}
func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Duration {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return fallback
}
raw, ok := ext.Manifest.Capabilities["networkTimeoutSeconds"]
if !ok {
return fallback
}
seconds := parseExtensionTimeoutSeconds(raw)
if seconds <= 0 {
return fallback
}
if seconds < 5 {
seconds = 5
}
if seconds > 300 {
seconds = 300
}
return time.Duration(seconds) * time.Second
}
func parseExtensionTimeoutSeconds(raw interface{}) int {
switch v := raw.(type) {
case int:
return v
case int32:
return int(v)
case int64:
return int(v)
case float32:
return int(v)
case float64:
return int(v)
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return parsed
default:
return 0
}
}
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = strings.TrimSpace(itemID)
}
func (r *extensionRuntime) clearActiveDownloadItemID() {
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = ""
}
func (r *extensionRuntime) getActiveDownloadItemID() string {
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
r.activeDownloadMu.RLock()
defer r.activeDownloadMu.RUnlock()
return r.activeDownloadItemID
}
func (r *extensionRuntime) setActiveRequestID(requestID string) {
r.activeRequestMu.Lock()
defer r.activeRequestMu.Unlock()
r.activeRequestID = strings.TrimSpace(requestID)
}
func (r *extensionRuntime) clearActiveRequestID() {
r.activeRequestMu.Lock()
defer r.activeRequestMu.Unlock()
r.activeRequestID = ""
}
func (r *extensionRuntime) getActiveRequestID() string {
r.activeRequestMu.RLock()
defer r.activeRequestMu.RUnlock()
return r.activeRequestID
}
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
if req == nil {
return nil
}
itemID := r.getActiveDownloadItemID()
if itemID == "" {
requestID := r.getActiveRequestID()
if requestID == "" {
return req
}
return req.WithContext(initExtensionRequestCancel(requestID))
}
return req.WithContext(initDownloadCancel(itemID))
}
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops.
// API calls can use response compression for faster metadata/search loads,
// while media downloads keep identity transfer semantics for progress/streaming.
transport := sharedTransport
if compressResponses {
transport = extensionAPITransport
}
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
client := &http.Client{
Transport: transport,
Transport: sharedTransport,
Timeout: timeout,
Jar: jar,
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" &&
!(req.URL.Scheme == "http" && ext.Manifest.Permissions.AllowHTTP) {
if req.URL.Scheme != "https" {
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
return fmt.Errorf("redirect blocked: only https is allowed")
}
@@ -327,12 +210,6 @@ 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
@@ -452,11 +329,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
return j.cookies[u.Host]
}
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings
}
func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
r.vm = vm
httpObj := vm.NewObject()
@@ -495,23 +372,12 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
vm.Set("auth", authObj)
if r.manifest != nil && r.manifest.SignedSession != nil {
sessionObj := vm.NewObject()
sessionObj.Set("signedFetch", r.signedSessionFetch)
sessionObj.Set("completeGrant", r.signedSessionCompleteGrant)
sessionObj.Set("status", r.signedSessionStatus)
sessionObj.Set("clear", r.signedSessionClear)
vm.Set("session", sessionObj)
}
fileObj := vm.NewObject()
fileObj.Set("download", r.fileDownload)
fileObj.Set("exists", r.fileExists)
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)
@@ -541,17 +407,8 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("stringifyJSON", r.stringifyJSON)
utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
utilsObj.Set("decryptCTRSegments", r.decryptCTRSegments)
utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
utilsObj.Set("appVersion", r.appVersion)
utilsObj.Set("appUserAgent", r.appUserAgent)
utilsObj.Set("sleep", r.sleep)
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
utilsObj.Set("isRequestCancelled", r.isRequestCancelled)
utilsObj.Set("setDownloadStatus", r.setDownloadStatus)
vm.Set("utils", utilsObj)
logObj := vm.NewObject()
+11 -12
View File
@@ -52,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
}
func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -99,7 +99,7 @@ func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -111,7 +111,7 @@ func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(state.AuthCode)
}
func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
@@ -149,7 +149,7 @@ func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true)
}
func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.Lock()
delete(extensionAuthState, r.extensionID)
extensionAuthStateMu.Unlock()
@@ -162,7 +162,7 @@ func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true)
}
func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -178,7 +178,7 @@ func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(state.IsAuthenticated)
}
func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -228,7 +228,7 @@ func generatePKCEChallenge(verifier string) string {
return base64.RawURLEncoding.EncodeToString(hash[:])
}
func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
length := 64
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
@@ -265,7 +265,7 @@ func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -281,7 +281,7 @@ func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -385,7 +385,7 @@ func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
})
}
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -458,10 +458,9 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
resp, err := r.httpClient.Do(req)
if err != nil {
-534
View File
@@ -1,534 +0,0 @@
package gobackend
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"github.com/dop251/goja"
//lint:ignore SA1019 Blowfish is required for legacy extension crypto compatibility.
"golang.org/x/crypto/blowfish"
)
type runtimeBlockCipherOptions struct {
Algorithm string
Mode string
Key []byte
IV []byte
InputEncoding string
OutputEncoding string
Padding string
}
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
if len(call.Arguments) <= index {
return nil
}
value := call.Arguments[index]
if goja.IsUndefined(value) || goja.IsNull(value) {
return nil
}
exported := value.Export()
if options, ok := exported.(map[string]interface{}); ok {
return options
}
return nil
}
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case string:
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
case []byte:
if len(value) > 0 {
return string(value)
}
}
return defaultValue
}
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case bool:
return value
case int:
return value != 0
case int64:
return value != 0
case float64:
return value != 0
case string:
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return defaultValue
}
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case int:
return int64(value)
case int32:
return int64(value)
case int64:
return value
case float32:
return int64(value)
case float64:
return int64(value)
case string:
value = strings.TrimSpace(value)
if value == "" {
return defaultValue
}
var parsed int64
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
return parsed
}
}
return defaultValue
}
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
if options == nil {
return false
}
_, exists := options[key]
return exists
}
func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "utf8", "utf-8", "text":
return []byte(input), nil
case "base64":
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input))
if err != nil {
return nil, fmt.Errorf("invalid base64 data: %w", err)
}
return decoded, nil
case "hex":
decoded, err := hex.DecodeString(strings.TrimSpace(input))
if err != nil {
return nil, fmt.Errorf("invalid hex data: %w", err)
}
return decoded, nil
default:
return nil, fmt.Errorf("unsupported byte encoding: %s", encoding)
}
}
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
switch value := raw.(type) {
case string:
return decodeRuntimeBytesString(value, encoding)
case []byte:
cloned := make([]byte, len(value))
copy(cloned, value)
return cloned, nil
case goja.ArrayBuffer:
src := value.Bytes()
cloned := make([]byte, len(src))
copy(cloned, src)
return cloned, nil
case []interface{}:
decoded := make([]byte, len(value))
for i, item := range value {
switch num := item.(type) {
case int:
decoded[i] = byte(num)
case int64:
decoded[i] = byte(num)
case float64:
decoded[i] = byte(int(num))
default:
return nil, fmt.Errorf("unsupported byte array item at index %d", i)
}
}
return decoded, nil
default:
return nil, fmt.Errorf("unsupported byte payload type")
}
}
func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "base64":
return base64.StdEncoding.EncodeToString(data), nil
case "hex":
return hex.EncodeToString(data), nil
case "utf8", "utf-8", "text":
return string(data), nil
default:
return "", fmt.Errorf("unsupported byte encoding: %s", encoding)
}
}
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
parsed := &runtimeBlockCipherOptions{
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")),
OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")),
Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")),
}
if parsed.Algorithm == "" {
return nil, fmt.Errorf("algorithm is required")
}
if parsed.Mode == "" {
return nil, fmt.Errorf("mode is required")
}
key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8"))
if err != nil {
return nil, fmt.Errorf("invalid key: %w", err)
}
if len(key) == 0 {
return nil, fmt.Errorf("key is required")
}
parsed.Key = key
iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8"))
if err != nil {
return nil, fmt.Errorf("invalid iv: %w", err)
}
parsed.IV = iv
return parsed, nil
}
func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) {
switch options.Algorithm {
case "blowfish":
return blowfish.NewCipher(options.Key)
case "aes":
return aes.NewCipher(options.Key)
default:
return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm)
}
}
func applyPKCS7Padding(data []byte, blockSize int) []byte {
padding := blockSize - (len(data) % blockSize)
if padding == 0 {
padding = blockSize
}
out := make([]byte, len(data)+padding)
copy(out, data)
for i := len(data); i < len(out); i++ {
out[i] = byte(padding)
}
return out
}
func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, fmt.Errorf("invalid padded payload length")
}
padding := int(data[len(data)-1])
if padding <= 0 || padding > blockSize || padding > len(data) {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
for i := len(data) - padding; i < len(data); i++ {
if int(data[i]) != padding {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
}
return data[:len(data)-padding], nil
}
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "data and options are required",
})
}
options := parseRuntimeOptionsArgument(call, 1)
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
switch parsedOptions.Mode {
case "cbc", "ctr":
default:
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
})
}
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
block, err := newRuntimeBlockCipher(parsedOptions)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
if len(parsedOptions.IV) != block.BlockSize() {
ivLabel := "iv"
if parsedOptions.Mode == "ctr" {
ivLabel = "iv (counter)"
}
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm),
})
}
var output []byte
if parsedOptions.Mode == "ctr" {
// CTR is a stream mode: encryption and decryption are identical,
// require no padding, and accept arbitrary input lengths.
output = make([]byte, len(inputData))
cipher.NewCTR(block, parsedOptions.IV).XORKeyStream(output, inputData)
} else {
data := inputData
if !decrypt && parsedOptions.Padding == "pkcs7" {
data = applyPKCS7Padding(data, block.BlockSize())
}
if len(data)%block.BlockSize() != 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
})
}
output = make([]byte, len(data))
if decrypt {
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
if parsedOptions.Padding == "pkcs7" {
output, err = removePKCS7Padding(output, block.BlockSize())
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
}
} else {
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
}
}
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": encoded,
"block_size": block.BlockSize(),
})
}
func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value {
return r.transformBlockCipher(call, false)
}
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
return r.transformBlockCipher(call, true)
}
// decryptCTRSegments decrypts many independently-IV'd AES-CTR segments inside a
// single buffer in one host call. This exists to avoid thousands of JS->Go
// bridge crossings when an extension decrypts per-sample CENC media (each
// sample has its own IV/counter and cannot be merged into one stream).
//
// It is a generic primitive: any extension can use it for "one buffer, many
// CTR segments" workloads, not just Apple CENC.
//
// For best performance, pass the buffer as an ArrayBuffer/Uint8Array and set
// outputEncoding:"bytes" to get an ArrayBuffer back. This avoids base64
// encode/decode of the (potentially multi-MB) payload entirely, which is the
// dominant cost under the goja interpreter.
//
// JS signature:
// utils.decryptCTRSegments(data, {
// algorithm: "aes", // optional, default "aes"
// key: "<hex>", keyEncoding: "hex",
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
// ivEncoding: "base64", // encoding of each segment.iv, default base64
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
// })
// Returns { success, data, segments_processed } or { success:false, error }.
func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value {
fail := func(msg string) goja.Value {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": msg,
})
}
if len(call.Arguments) < 2 {
return fail("data and options are required")
}
options := parseRuntimeOptionsArgument(call, 1)
if options == nil {
return fail("options object is required")
}
algorithm := strings.ToLower(runtimeOptionString(options, "algorithm", "aes"))
inputEncoding := strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64"))
outputEncoding := strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64"))
ivEncoding := strings.ToLower(runtimeOptionString(options, "ivEncoding", "base64"))
key, err := decodeRuntimeBytesString(
runtimeOptionString(options, "key", ""),
runtimeOptionString(options, "keyEncoding", "hex"),
)
if err != nil {
return fail(fmt.Sprintf("invalid key: %v", err))
}
if len(key) == 0 {
return fail("key is required")
}
var block cipher.Block
switch algorithm {
case "aes":
block, err = aes.NewCipher(key)
case "blowfish":
block, err = blowfish.NewCipher(key)
default:
return fail("unsupported algorithm: " + algorithm)
}
if err != nil {
return fail(err.Error())
}
blockSize := block.BlockSize()
// Decode the payload. For "bytes" input we operate on the raw []byte
// (ArrayBuffer/Uint8Array) without any base64 round-trip.
var data []byte
if inputEncoding == "bytes" || inputEncoding == "raw" {
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), "")
if err != nil {
return fail("invalid byte payload: " + err.Error())
}
} else {
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), inputEncoding)
if err != nil {
return fail(err.Error())
}
}
rawSegments, ok := options["segments"]
if !ok || rawSegments == nil {
return fail("segments array is required")
}
segments, ok := rawSegments.([]interface{})
if !ok {
return fail("segments must be an array")
}
processed := 0
for i, rawSeg := range segments {
seg, ok := rawSeg.(map[string]interface{})
if !ok {
return fail(fmt.Sprintf("segment %d is not an object", i))
}
offset := int(runtimeOptionInt64(seg, "offset", -1))
size := int(runtimeOptionInt64(seg, "size", -1))
if offset < 0 || size < 0 {
return fail(fmt.Sprintf("segment %d has invalid offset/size", i))
}
if size == 0 {
continue
}
if offset+size > len(data) {
return fail(fmt.Sprintf("segment %d out of bounds (offset=%d size=%d len=%d)", i, offset, size, len(data)))
}
iv, err := decodeRuntimeBytesString(runtimeOptionString(seg, "iv", ""), ivEncoding)
if err != nil {
return fail(fmt.Sprintf("segment %d has invalid iv: %v", i, err))
}
if len(iv) != blockSize {
// Accept short IVs by left-aligning into a block-sized counter
// (CENC commonly uses 8-byte IVs for a 16-byte AES counter).
if len(iv) > blockSize {
return fail(fmt.Sprintf("segment %d iv longer than block size (%d > %d)", i, len(iv), blockSize))
}
padded := make([]byte, blockSize)
copy(padded, iv)
iv = padded
}
segData := data[offset : offset+size]
cipher.NewCTR(block, iv).XORKeyStream(segData, segData)
processed++
}
// Return raw bytes as an ArrayBuffer when requested (zero-copy-ish, no
// base64). Otherwise fall back to an encoded string.
if outputEncoding == "bytes" || outputEncoding == "raw" {
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": r.vm.NewArrayBuffer(data),
"segments_processed": processed,
})
}
encoded, err := encodeRuntimeBytes(data, outputEncoding)
if err != nil {
return fail(err.Error())
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": encoded,
"segments_processed": processed,
})
}
-485
View File
@@ -1,485 +0,0 @@
package gobackend
import (
"encoding/json"
"testing"
"github.com/dop251/goja"
)
func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime {
t.Helper()
ext := &loadedExtension{
ID: "binary-test-ext",
Manifest: &ExtensionManifest{
Name: "binary-test-ext",
Permissions: ExtensionPermissions{
File: withFilePermission,
},
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
vm := goja.New()
runtime.RegisterAPIs(vm)
return vm
}
func decodeJSONResult[T any](t *testing.T, value goja.Value) T {
t.Helper()
var decoded T
if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil {
t.Fatalf("failed to decode JSON result: %v", err)
}
return decoded
}
func TestExtensionRuntime_FileByteAPIs(t *testing.T) {
vm := newBinaryTestRuntime(t, true)
result, err := vm.RunString(`
(function() {
var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true});
if (!first.success) throw new Error(first.error);
var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true});
if (!second.success) throw new Error(second.error);
var all = file.readBytes("bytes.bin", {encoding: "hex"});
if (!all.success) throw new Error(all.error);
var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"});
if (!slice.success) throw new Error(slice.error);
var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"});
if (!tail.success) throw new Error(tail.error);
return JSON.stringify({
all: all.data,
slice: slice.data,
size: all.size,
sliceBytes: slice.bytes_read,
sliceEof: slice.eof,
tailBytes: tail.bytes_read,
tailEof: tail.eof
});
})()
`)
if err != nil {
t.Fatalf("file byte APIs failed: %v", err)
}
decoded := decodeJSONResult[struct {
All string `json:"all"`
Slice string `json:"slice"`
Size int64 `json:"size"`
SliceBytes int `json:"sliceBytes"`
SliceEof bool `json:"sliceEof"`
TailBytes int `json:"tailBytes"`
TailEof bool `json:"tailEof"`
}](t, result)
if decoded.All != "0001020304ff" {
t.Fatalf("all = %q", decoded.All)
}
if decoded.Slice != "0203" {
t.Fatalf("slice = %q", decoded.Slice)
}
if decoded.Size != 6 {
t.Fatalf("size = %d", decoded.Size)
}
if decoded.SliceBytes != 2 {
t.Fatalf("slice bytes = %d", decoded.SliceBytes)
}
if decoded.SliceEof {
t.Fatal("slice should not be EOF")
}
if decoded.TailBytes != 0 || !decoded.TailEof {
t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof)
}
}
func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "blowfish",
mode: "cbc",
key: "0123456789ABCDEFF0E1D2C3B4A59687",
keyEncoding: "hex",
iv: "0001020304050607",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex",
padding: "none"
};
var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, options);
if (!dec.success) throw new Error(dec.error);
return JSON.stringify({enc: enc.data, dec: dec.data});
})()
`)
if err != nil {
t.Fatalf("blowfish block cipher failed: %v", err)
}
decoded := decodeJSONResult[struct {
Enc string `json:"enc"`
Dec string `json:"dec"`
}](t, result)
if decoded.Dec != "00112233445566778899aabbccddeeff" {
t.Fatalf("dec = %q", decoded.Dec)
}
if decoded.Enc == decoded.Dec {
t.Fatal("expected ciphertext to differ from plaintext")
}
}
func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "cbc",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0f0e0d0c0b0a09080706050403020100",
ivEncoding: "hex",
inputEncoding: "utf8",
outputEncoding: "base64",
padding: "pkcs7"
};
var enc = utils.encryptBlockCipher("hello generic cbc", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, {
algorithm: "aes",
mode: "cbc",
key: options.key,
keyEncoding: options.keyEncoding,
iv: options.iv,
ivEncoding: options.ivEncoding,
inputEncoding: "base64",
outputEncoding: "utf8",
padding: "pkcs7"
});
if (!dec.success) throw new Error(dec.error);
return dec.data;
})()
`)
if err != nil {
t.Fatalf("aes block cipher failed: %v", err)
}
if result.String() != "hello generic cbc" {
t.Fatalf("unexpected decrypted value: %q", result.String())
}
}
func TestExtensionRuntime_BlockCipherCTRSupportsAES(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// NIST SP 800-38A, F.5.1 CTR-AES128.Encrypt test vector.
// Key: 2b7e151628aed2a6abf7158809cf4f3c
// Counter: f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff
// Plaintext: 6bc1bee22e409f96e93d7e117393172a (block 1)
// Ciphertext: 874d6191b620e3261bef6864990db6ce (block 1)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "ctr",
key: "2b7e151628aed2a6abf7158809cf4f3c",
keyEncoding: "hex",
iv: "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex"
};
var enc = utils.encryptBlockCipher("6bc1bee22e409f96e93d7e117393172a", options);
if (!enc.success) throw new Error(enc.error);
// CTR is symmetric: decrypt is the same transform as encrypt.
var dec = utils.decryptBlockCipher(enc.data, options);
if (!dec.success) throw new Error(dec.error);
return JSON.stringify({enc: enc.data, dec: dec.data});
})()
`)
if err != nil {
t.Fatalf("aes ctr block cipher failed: %v", err)
}
decoded := decodeJSONResult[struct {
Enc string `json:"enc"`
Dec string `json:"dec"`
}](t, result)
if decoded.Enc != "874d6191b620e3261bef6864990db6ce" {
t.Fatalf("ctr ciphertext = %q, want NIST vector 874d6191b620e3261bef6864990db6ce", decoded.Enc)
}
if decoded.Dec != "6bc1bee22e409f96e93d7e117393172a" {
t.Fatalf("ctr round-trip dec = %q", decoded.Dec)
}
}
func TestExtensionRuntime_BlockCipherCTRHandlesNonBlockLength(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// CTR is a stream mode, so arbitrary (non-16-byte-aligned) input lengths
// must round-trip without any padding.
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "ctr",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0f0e0d0c0b0a09080706050403020100",
ivEncoding: "hex",
inputEncoding: "utf8",
outputEncoding: "base64"
};
var enc = utils.encryptBlockCipher("stream ctr of odd length", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, {
algorithm: "aes",
mode: "ctr",
key: options.key,
keyEncoding: options.keyEncoding,
iv: options.iv,
ivEncoding: options.ivEncoding,
inputEncoding: "base64",
outputEncoding: "utf8"
});
if (!dec.success) throw new Error(dec.error);
return dec.data;
})()
`)
if err != nil {
t.Fatalf("aes ctr stream length failed: %v", err)
}
if result.String() != "stream ctr of odd length" {
t.Fatalf("unexpected ctr decrypted value: %q", result.String())
}
}
func TestExtensionRuntime_BlockCipherCTRRejectsBadIV(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var res = utils.encryptBlockCipher("00112233", {
algorithm: "aes",
mode: "ctr",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0001",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex"
});
return JSON.stringify({success: res.success, error: res.error || ""});
})()
`)
if err != nil {
t.Fatalf("aes ctr bad iv eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Success bool `json:"success"`
Error string `json:"error"`
}](t, result)
if decoded.Success {
t.Fatal("expected failure for undersized CTR iv")
}
if decoded.Error == "" {
t.Fatal("expected error message for undersized CTR iv")
}
}
func TestExtensionRuntime_DecryptCTRSegmentsMatchesPerSegment(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// Build a buffer of 3 segments encrypted with distinct 8-byte IVs (CENC
// style), then verify the batch primitive decrypts all of them in one call,
// matching what per-segment decryptBlockCipher would produce.
result, err := vm.RunString(`
(function() {
var keyHex = "000102030405060708090a0b0c0d0e0f";
function b64(bytes){return utils.base64Encode(utils.toHex ? bytes : bytes);}
// segment plaintexts (hex) and 8-byte IVs (hex)
var segs = [
{ pt: "11111111111111111111", iv: "0000000000000001" },
{ pt: "2222222222", iv: "0000000000000002" },
{ pt: "333333333333333333333333", iv: "00000000000000ff" }
];
// Encrypt each segment individually using single-shot CTR with a
// 16-byte counter (8-byte iv left-aligned), producing ciphertext hex.
function ivToB64(ivHex){
// pad 8-byte hex iv to 16 bytes then base64
var full = ivHex + "00000000000000000000000000000000".slice(ivHex.length);
return utils.base64Encode(utils.hexToBytes ? utils.hexToBytes(full) : full);
}
var cipherHex = "";
var offsets = [];
var off = 0;
var ivB64s = [];
for (var i=0;i<segs.length;i++){
var ivFullHex = (segs[i].iv + "00000000000000000000000000000000").slice(0,32);
var enc = utils.encryptBlockCipher(segs[i].pt, {
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
iv: ivFullHex, ivEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex"
});
if(!enc.success) throw new Error("enc seg "+i+": "+enc.error);
cipherHex += enc.data;
var sz = segs[i].pt.length/2;
offsets.push({offset: off, size: sz, ivHex: ivFullHex});
off += sz;
}
// Now decrypt the whole concatenated buffer in ONE batch call.
var segments = offsets.map(function(o){
return { offset:o.offset, size:o.size, iv:o.ivHex };
});
var batch = utils.decryptCTRSegments(cipherHex, {
algorithm:"aes", key:keyHex, keyEncoding:"hex",
segments: segments, ivEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex"
});
if(!batch.success) throw new Error("batch: "+batch.error);
var expected = "";
for (var j=0;j<segs.length;j++) expected += segs[j].pt;
return JSON.stringify({
out: batch.data,
expected: expected,
processed: batch.segments_processed
});
})()
`)
if err != nil {
t.Fatalf("batch CTR eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Out string `json:"out"`
Expected string `json:"expected"`
Processed int `json:"processed"`
}](t, result)
if decoded.Out != decoded.Expected {
t.Fatalf("batch decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
}
if decoded.Processed != 3 {
t.Fatalf("segments_processed = %d, want 3", decoded.Processed)
}
}
func TestExtensionRuntime_DecryptCTRSegmentsRejectsOutOfBounds(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var res = utils.decryptCTRSegments("00112233", {
algorithm:"aes", key:"000102030405060708090a0b0c0d0e0f", keyEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex",
ivEncoding:"hex",
segments: [ { offset: 0, size: 99, iv: "00000000000000000000000000000000" } ]
});
return JSON.stringify({ success: res.success, error: res.error || "" });
})()
`)
if err != nil {
t.Fatalf("oob eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Success bool `json:"success"`
Error string `json:"error"`
}](t, result)
if decoded.Success {
t.Fatal("expected out-of-bounds segment to fail")
}
if decoded.Error == "" {
t.Fatal("expected error message for out-of-bounds segment")
}
}
func TestExtensionRuntime_DecryptCTRSegmentsRawBytes(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// Verify the zero-base64 path: pass an ArrayBuffer in, request bytes out,
// and confirm round-trip correctness against single-shot CTR.
result, err := vm.RunString(`
(function() {
var keyHex = "000102030405060708090a0b0c0d0e0f";
var ivFullHex = "0000000000000001" + "00000000000000000000000000000000".slice(16);
// Plaintext as a Uint8Array of 20 bytes.
var pt = new Uint8Array(20);
for (var i = 0; i < pt.length; i++) pt[i] = (i * 7 + 3) & 0xff;
// Encrypt single-shot to get ciphertext (hex output for clarity).
var ptHex = "";
for (var j = 0; j < pt.length; j++) { var h = pt[j].toString(16); ptHex += (h.length === 1 ? "0" : "") + h; }
var enc = utils.encryptBlockCipher(ptHex, {
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
iv: ivFullHex, ivEncoding:"hex", inputEncoding:"hex", outputEncoding:"base64"
});
if (!enc.success) throw new Error("enc: " + enc.error);
// Decode ciphertext base64 into a Uint8Array to feed the raw path.
var cipherBytes = utils.base64Decode ? null : null;
// Build ArrayBuffer from base64 via Uint8Array manually:
var b64 = enc.data;
var bin = (typeof atob === "function") ? null : null;
// Simpler: ask the host to give us bytes by decrypting nothing is hard,
// so just pass the base64 ciphertext through decryptCTRSegments using
// base64 input but bytes output, then re-run with bytes input.
var step1 = utils.decryptCTRSegments(b64, {
algorithm:"aes", key:keyHex, keyEncoding:"hex",
segments: [ { offset:0, size:20, iv: ivFullHex } ],
ivEncoding:"hex", inputEncoding:"base64", outputEncoding:"bytes"
});
if (!step1.success) throw new Error("step1: " + step1.error);
if (typeof step1.data === "string") throw new Error("expected ArrayBuffer output, got string");
var outArr = new Uint8Array(step1.data);
var outHex = "";
for (var k = 0; k < outArr.length; k++) { var hh = outArr[k].toString(16); outHex += (hh.length === 1 ? "0" : "") + hh; }
return JSON.stringify({ out: outHex, expected: ptHex, len: outArr.length });
})()
`)
if err != nil {
t.Fatalf("raw-bytes eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Out string `json:"out"`
Expected string `json:"expected"`
Len int `json:"len"`
}](t, result)
if decoded.Out != decoded.Expected {
t.Fatalf("raw-bytes decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
}
if decoded.Len != 20 {
t.Fatalf("output length = %d, want 20", decoded.Len)
}
}
+3 -4
View File
@@ -50,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
delete(ffmpegCommands, commandID)
}
func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -107,7 +107,7 @@ func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
}
}
func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -131,11 +131,10 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
"sample_rate": quality.SampleRate,
"total_samples": quality.TotalSamples,
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
"codec": quality.Codec,
})
}
func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
+19 -517
View File
@@ -8,7 +8,6 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
@@ -72,7 +71,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
return true
}
func (r *extensionRuntime) validatePath(path string) (string, error) {
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
if !r.manifest.Permissions.File {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
}
@@ -107,7 +106,7 @@ func (r *extensionRuntime) validatePath(path string) (string, error) {
return absPath, nil
}
func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -135,9 +134,6 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
var onProgress goja.Callable
var headers map[string]string
var chunkedDownload bool
trackItemBytes := true
var chunkSize int64
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
optionsObj := call.Arguments[2].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
@@ -152,39 +148,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
onProgress = callable
}
}
if trackBytes, ok := opts["trackItemBytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
} else if trackBytes, ok := opts["track_item_bytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
}
if chunked, ok := opts["chunked"]; ok {
switch v := chunked.(type) {
case bool:
chunkedDownload = v
case int64:
if v > 0 {
chunkedDownload = true
chunkSize = v
}
case float64:
if v > 0 {
chunkedDownload = true
chunkSize = int64(v)
}
}
}
}
}
// Default chunk size: 1MB (YouTube CDN max without poToken)
if chunkedDownload && chunkSize <= 0 {
chunkSize = 1024 * 1024
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -193,20 +159,6 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
client := r.downloadClient
if client == nil {
client = r.httpClient
}
ua := appUserAgent()
if h, ok := headers["User-Agent"]; ok && h != "" {
ua = h
}
if chunkedDownload {
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
}
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -214,13 +166,17 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
}
client := r.downloadClient
if client == nil {
client = r.httpClient
}
resp, err := client.Do(req)
@@ -232,7 +188,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if resp.StatusCode != 200 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
@@ -248,19 +204,14 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
defer out.Close()
activeItemID := r.getActiveDownloadItemID()
if activeItemID != "" {
SetItemDownloading(activeItemID)
}
contentLength := resp.ContentLength
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && contentLength > 0 {
activeItemID := r.getActiveDownloadItemID()
if activeItemID != "" && contentLength > 0 {
SetItemBytesTotal(activeItemID, contentLength)
}
var progressWriter interface{ Write([]byte) (int, error) } = out
if shouldTrackItemBytes {
if activeItemID != "" {
progressWriter = NewItemProgressWriter(out, activeItemID)
}
@@ -311,14 +262,6 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
}
if shouldTrackItemBytes {
if contentLength > 0 {
SetItemProgress(activeItemID, float64(written)/float64(contentLength), written, contentLength)
} else if written > 0 {
SetItemBytesReceived(activeItemID, written)
}
}
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
return r.vm.ToValue(map[string]interface{}{
@@ -328,237 +271,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// fileDownloadChunked downloads a URL using sequential Range requests.
// This is needed for servers (like YouTube's googlevideo CDN) that reject
// non-ranged or large-range requests with 403 and require small chunk downloads.
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
// First, get the total content length with a small probe request
probeReq, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: probe request error: %v", err),
})
}
probeReq = r.bindDownloadCancelContext(probeReq)
probeReq.Header.Set("User-Agent", ua)
for k, v := range headers {
if k != "Range" { // Don't copy any existing Range header
probeReq.Header.Set(k, v)
}
}
probeReq.Header.Set("Range", "bytes=0-1")
probeResp, err := client.Do(probeReq)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: probe error: %v", err),
})
}
io.Copy(io.Discard, probeResp.Body)
probeResp.Body.Close()
if probeResp.StatusCode != 206 && probeResp.StatusCode != 200 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: probe HTTP %d", probeResp.StatusCode),
})
}
// Parse Content-Range to get total size: "bytes 0-1/TOTAL"
var totalSize int64
contentRange := probeResp.Header.Get("Content-Range")
if contentRange != "" {
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
sizeStr := contentRange[idx+1:]
if sizeStr != "*" {
fmt.Sscanf(sizeStr, "%d", &totalSize)
}
}
}
if totalSize <= 0 {
// Fallback: try Content-Length from a HEAD-like approach
// If we can't determine size, download with unknown size
GoLog("[Extension:%s] Chunked download: unknown total size, will download until server says done\n", r.extensionID)
} else {
GoLog("[Extension:%s] Chunked download: total size %d bytes, chunk size %d\n", r.extensionID, totalSize, chunkSize)
}
out, err := os.Create(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create file: %v", err),
})
}
defer out.Close()
activeItemID := r.getActiveDownloadItemID()
if activeItemID != "" {
SetItemDownloading(activeItemID)
}
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && totalSize > 0 {
SetItemBytesTotal(activeItemID, totalSize)
}
var progressWriter interface{ Write([]byte) (int, error) } = out
if shouldTrackItemBytes {
progressWriter = NewItemProgressWriter(out, activeItemID)
}
var totalWritten int64
buf := make([]byte, 32*1024)
maxRetries := 3
for offset := int64(0); totalSize <= 0 || offset < totalSize; {
end := offset + chunkSize - 1
if totalSize > 0 && end >= totalSize {
end = totalSize - 1
}
var chunkResp *http.Response
var chunkErr error
for retry := 0; retry < maxRetries; retry++ {
chunkReq, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: request error at offset %d: %v", offset, err),
})
}
chunkReq = r.bindDownloadCancelContext(chunkReq)
chunkReq.Header.Set("User-Agent", ua)
for k, v := range headers {
if k != "Range" {
chunkReq.Header.Set(k, v)
}
}
chunkReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, end))
chunkResp, chunkErr = client.Do(chunkReq)
if chunkErr != nil {
if retry < maxRetries-1 {
time.Sleep(time.Duration(retry+1) * time.Second)
continue
}
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: error at offset %d after %d retries: %v", offset, maxRetries, chunkErr),
})
}
if chunkResp.StatusCode == 206 || chunkResp.StatusCode == 200 {
break // Success
}
io.Copy(io.Discard, chunkResp.Body)
chunkResp.Body.Close()
if chunkResp.StatusCode == 403 || chunkResp.StatusCode == 429 {
if retry < maxRetries-1 {
time.Sleep(time.Duration(retry+1) * 2 * time.Second)
continue
}
}
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: HTTP %d at offset %d", chunkResp.StatusCode, offset),
})
}
chunkWritten := int64(0)
for {
nr, er := chunkResp.Body.Read(buf)
if nr > 0 {
nw, ew := progressWriter.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
ew = fmt.Errorf("invalid write result")
}
}
chunkWritten += int64(nw)
totalWritten += int64(nw)
if ew != nil {
chunkResp.Body.Close()
if ew == ErrDownloadCancelled {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "download cancelled",
})
}
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to write file: %v", ew),
})
}
if nr != nw {
chunkResp.Body.Close()
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "short write",
})
}
if onProgress != nil && totalSize > 0 {
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize))
}
}
if er != nil {
if er != io.EOF {
chunkResp.Body.Close()
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read chunk at offset %d: %v", offset, er),
})
}
break
}
}
chunkResp.Body.Close()
offset += chunkWritten
// If server returned 200 (full content) instead of 206, we're done
if chunkResp.StatusCode == 200 {
break
}
// If we got less data than expected and we know total size, check if done
if totalSize > 0 && offset >= totalSize {
break
}
// Unknown size: if we got less than chunk size, assume done
if totalSize <= 0 && chunkWritten < chunkSize {
break
}
}
if shouldTrackItemBytes {
if totalSize > 0 {
SetItemProgress(activeItemID, float64(totalWritten)/float64(totalSize), totalWritten, totalSize)
} else if totalWritten > 0 {
SetItemBytesReceived(activeItemID, totalWritten)
}
}
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullPath,
"size": totalWritten,
})
}
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
@@ -573,7 +286,7 @@ func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(err == nil)
}
func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -602,7 +315,7 @@ func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -633,117 +346,7 @@ func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
options := parseRuntimeOptionsArgument(call, 1)
offset := runtimeOptionInt64(options, "offset", 0)
length := runtimeOptionInt64(options, "length", -1)
encoding := runtimeOptionString(options, "encoding", "base64")
if offset < 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "offset must be >= 0",
})
}
file, err := os.Open(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
size := info.Size()
if offset > size {
offset = size
}
if _, err := file.Seek(offset, io.SeekStart); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to seek file: %v", err),
})
}
var data []byte
switch {
case length == 0:
data = []byte{}
case length > 0:
buf := make([]byte, int(length))
n, readErr := file.Read(buf)
if readErr != nil && readErr != io.EOF {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read file: %v", readErr),
})
}
data = buf[:n]
default:
data, err = io.ReadAll(file)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read file: %v", err),
})
}
}
if strings.EqualFold(strings.TrimSpace(encoding), "bytes") ||
strings.EqualFold(strings.TrimSpace(encoding), "raw") {
// Return raw bytes as an ArrayBuffer to avoid base64 encode/decode of
// large payloads under the goja interpreter.
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": r.vm.NewArrayBuffer(data),
"bytes_read": len(data),
"offset": offset,
"size": size,
"eof": offset+int64(len(data)) >= size,
})
}
encoded, err := encodeRuntimeBytes(data, encoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": encoded,
"bytes_read": len(data),
"offset": offset,
"size": size,
"eof": offset+int64(len(data)) >= size,
})
}
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -783,108 +386,7 @@ func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path and data are required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
options := parseRuntimeOptionsArgument(call, 2)
appendMode := runtimeOptionBool(options, "append", false)
truncate := runtimeOptionBool(options, "truncate", false)
hasOffset := runtimeOptionHasKey(options, "offset")
offset := runtimeOptionInt64(options, "offset", 0)
encoding := runtimeOptionString(options, "encoding", "base64")
if appendMode && hasOffset {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "append and offset cannot be used together",
})
}
if offset < 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "offset must be >= 0",
})
}
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
flags := os.O_CREATE | os.O_WRONLY
if appendMode {
flags |= os.O_APPEND
}
if truncate {
flags |= os.O_TRUNC
}
file, err := os.OpenFile(fullPath, flags, 0644)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
defer file.Close()
if hasOffset && !appendMode {
if _, err := file.Seek(offset, io.SeekStart); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to seek file: %v", err),
})
}
}
written, err := file.Write(data)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
info, statErr := file.Stat()
size := int64(0)
if statErr == nil {
size = info.Size()
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullPath,
"bytes_written": written,
"size": size,
})
}
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -957,7 +459,7 @@ func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -1005,7 +507,7 @@ func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
+14 -37
View File
@@ -17,25 +17,7 @@ type HTTPResponse struct {
Headers map[string]string `json:"headers"`
}
const maxExtensionHTTPResponseBytes = 16 << 20
func readExtensionHTTPResponseBody(resp *http.Response) ([]byte, error) {
body, err := io.ReadAll(
io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes+1),
)
if err != nil {
return nil, err
}
if len(body) > maxExtensionHTTPResponseBytes {
return nil, fmt.Errorf(
"response body exceeds %d byte limit; use file.download for large media",
maxExtensionHTTPResponseBytes,
)
}
return body, nil
}
func (r *extensionRuntime) validateDomain(urlStr string) error {
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
@@ -44,8 +26,7 @@ func (r *extensionRuntime) validateDomain(urlStr string) error {
if parsed.Scheme == "" {
return fmt.Errorf("invalid URL: scheme is required")
}
if parsed.Scheme != "https" &&
!(parsed.Scheme == "http" && r.manifest.Permissions.AllowHTTP) {
if parsed.Scheme != "https" {
return fmt.Errorf("network access denied: only https is allowed")
}
if parsed.User != nil {
@@ -68,7 +49,7 @@ func (r *extensionRuntime) validateDomain(urlStr string) error {
return nil
}
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
@@ -100,7 +81,6 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -118,7 +98,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
body, err := readExtensionHTTPResponseBody(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
@@ -144,7 +124,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
@@ -195,7 +175,6 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -216,7 +195,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
body, err := readExtensionHTTPResponseBody(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
@@ -242,7 +221,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
@@ -305,7 +284,6 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -326,7 +304,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
body, err := readExtensionHTTPResponseBody(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
@@ -352,19 +330,19 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call)
}
func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call)
}
func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call)
}
func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
@@ -432,7 +410,6 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -452,7 +429,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}
defer resp.Body.Close()
body, err := readExtensionHTTPResponseBody(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
@@ -478,7 +455,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
})
}
func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock()
jar.cookies = make(map[string][]*http.Cookie)
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"github.com/dop251/goja"
)
func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(0.0)
}
@@ -22,7 +22,7 @@ func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
return r.vm.ToValue(similarity)
}
func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(false)
}
@@ -43,7 +43,7 @@ func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
return r.vm.ToValue(diff <= tolerance)
}
func (r *extensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
+8 -9
View File
@@ -12,7 +12,7 @@ import (
"github.com/dop251/goja"
)
func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.createFetchError("URL is required")
}
@@ -69,13 +69,12 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if err != nil {
return r.createFetchError(err.Error())
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
@@ -134,7 +133,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return responseObj
}
func (r *extensionRuntime) createFetchError(message string) goja.Value {
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
errorObj := r.vm.NewObject()
errorObj.Set("ok", false)
errorObj.Set("status", 0)
@@ -149,7 +148,7 @@ func (r *extensionRuntime) createFetchError(message string) goja.Value {
return errorObj
}
func (r *extensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
@@ -165,7 +164,7 @@ func (r *extensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded))
}
func (r *extensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
@@ -173,7 +172,7 @@ func (r *extensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
}
func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
encoder := call.This
encoder.Set("encoding", "utf-8")
@@ -253,7 +252,7 @@ func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
})
}
func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
urlObj := call.This
@@ -417,7 +416,7 @@ func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
})
}
func (r *extensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
jsonScript := `
if (typeof JSON === 'undefined') {
var JSON = {
+32 -22
View File
@@ -21,7 +21,7 @@ const (
storageFlushRetryDelay = 2 * time.Second
)
func (r *extensionRuntime) getStoragePath() string {
func (r *ExtensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json")
}
@@ -36,7 +36,7 @@ func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
return dst
}
func (r *extensionRuntime) ensureStorageLoaded() error {
func (r *ExtensionRuntime) ensureStorageLoaded() error {
r.storageMu.RLock()
if r.storageLoaded {
r.storageMu.RUnlock()
@@ -74,7 +74,7 @@ func (r *extensionRuntime) ensureStorageLoaded() error {
return nil
}
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
if err := r.ensureStorageLoaded(); err != nil {
return nil, err
}
@@ -84,7 +84,7 @@ func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
return cloneInterfaceMap(r.storageCache), nil
}
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
if r.storageClosed {
return
}
@@ -94,7 +94,7 @@ func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
}
func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
data, err := json.Marshal(storage)
if err != nil {
return err
@@ -106,13 +106,13 @@ func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}
return os.WriteFile(r.getStoragePath(), data, 0600)
}
func (r *extensionRuntime) flushStorageDirtyAsync() {
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
if err := r.flushStorageDirty(); err != nil {
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
}
}
func (r *extensionRuntime) flushStorageDirty() error {
func (r *ExtensionRuntime) flushStorageDirty() error {
r.storageMu.Lock()
if r.storageClosed {
r.storageTimer = nil
@@ -140,7 +140,7 @@ func (r *extensionRuntime) flushStorageDirty() error {
return nil
}
func (r *extensionRuntime) flushStorageNow() error {
func (r *ExtensionRuntime) flushStorageNow() error {
r.storageMu.Lock()
if r.storageTimer != nil {
r.storageTimer.Stop()
@@ -157,7 +157,7 @@ func (r *extensionRuntime) flushStorageNow() error {
return r.persistStorageSnapshot(snapshot)
}
func (r *extensionRuntime) closeStorageFlusher() {
func (r *ExtensionRuntime) closeStorageFlusher() {
r.storageMu.Lock()
r.storageClosed = true
r.storageDirty = false
@@ -168,7 +168,7 @@ func (r *extensionRuntime) closeStorageFlusher() {
r.storageMu.Unlock()
}
func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
}
@@ -193,7 +193,7 @@ func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value)
}
func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(false)
}
@@ -225,7 +225,7 @@ func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true)
}
func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
@@ -254,15 +254,15 @@ func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true)
}
func (r *extensionRuntime) getCredentialsPath() string {
func (r *ExtensionRuntime) getCredentialsPath() string {
return filepath.Join(r.dataDir, ".credentials.enc")
}
func (r *extensionRuntime) getSaltPath() string {
func (r *ExtensionRuntime) getSaltPath() string {
return filepath.Join(r.dataDir, ".cred_salt")
}
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath()
salt, err := os.ReadFile(saltPath)
@@ -282,7 +282,7 @@ func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
return salt, nil
}
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
salt, err := r.getOrCreateSalt()
if err != nil {
return nil, err
@@ -293,7 +293,7 @@ func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
return hash[:], nil
}
func (r *extensionRuntime) ensureCredentialsLoaded() error {
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
r.credentialsMu.RLock()
if r.credentialsLoaded {
r.credentialsMu.RUnlock()
@@ -340,7 +340,17 @@ func (r *extensionRuntime) ensureCredentialsLoaded() error {
return nil
}
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
if err := r.ensureCredentialsLoaded(); err != nil {
return nil, err
}
r.credentialsMu.RLock()
defer r.credentialsMu.RUnlock()
return cloneInterfaceMap(r.credentialsCache), nil
}
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
data, err := json.Marshal(creds)
if err != nil {
return err
@@ -367,7 +377,7 @@ func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
return nil
}
func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -404,7 +414,7 @@ func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
}
@@ -429,7 +439,7 @@ func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value)
}
func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
@@ -454,7 +464,7 @@ func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
return r.vm.ToValue(true)
}
func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
+7 -7
View File
@@ -11,7 +11,7 @@ import (
"github.com/dop251/goja"
)
func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value interface{}) {
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) {
t.Helper()
result := runtime.storageSet(goja.FunctionCall{
Arguments: []goja.Value{
@@ -39,7 +39,7 @@ func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
}
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
ext := &loadedExtension{
ext := &LoadedExtension{
ID: "storage-test",
Manifest: &ExtensionManifest{
Name: "storage-test",
@@ -47,7 +47,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime := NewExtensionRuntime(ext)
runtime.storageFlushDelay = 25 * time.Millisecond
runtime.RegisterAPIs(goja.New())
@@ -86,7 +86,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
}
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
ext := &loadedExtension{
ext := &LoadedExtension{
ID: "unload-storage-test",
Manifest: &ExtensionManifest{
Name: "unload-storage-test",
@@ -95,13 +95,13 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
VM: goja.New(),
}
runtime := newExtensionRuntime(ext)
runtime := NewExtensionRuntime(ext)
runtime.storageFlushDelay = time.Hour
runtime.RegisterAPIs(ext.VM)
ext.runtime = runtime
manager := &extensionManager{
extensions: map[string]*loadedExtension{
manager := &ExtensionManager{
extensions: map[string]*LoadedExtension{
ext.ID: ext,
},
}
@@ -1,747 +0,0 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/dop251/goja"
)
func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
vm := goja.New()
runtime := &extensionRuntime{
extensionID: "auth-ext",
manifest: &ExtensionManifest{
Name: "auth-ext",
Description: "Auth extension",
Version: "1.0.0",
Permissions: ExtensionPermissions{
Network: []string{"auth.example.com", "token.example.com", "api.example.com"},
},
},
settings: map[string]interface{}{},
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "token.example.com":
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"access_token":"access","refresh_token":"refresh","expires_in":3600}`)),
Request: req,
}, nil
case "api.example.com":
return &http.Response{
StatusCode: 200,
Header: http.Header{"X-Test": []string{"yes"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"items":[1,2]}`)),
Request: req,
}, nil
default:
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})},
vm: vm,
}
if err := validateExtensionAuthURL("https://user:pass@auth.example.com/login"); err == nil {
t.Fatal("expected embedded credential error")
}
if err := validateExtensionAuthURL("http://auth.example.com/login"); err == nil {
t.Fatal("expected non-https auth URL error")
}
if got := summarizeURLForLog("https://auth.example.com/login?token=secret"); got != "https://auth.example.com/login" {
t.Fatalf("summary = %q", got)
}
openResult := runtime.authOpenUrl(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("https://auth.example.com/login"),
vm.ToValue("app://callback"),
}}).Export().(map[string]interface{})
if openResult["success"] != true {
t.Fatalf("authOpenUrl = %#v", openResult)
}
if pending := GetPendingAuthRequest("auth-ext"); pending == nil || pending.AuthURL == "" {
t.Fatalf("pending auth = %#v", pending)
}
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() {
t.Fatal("authSetCode returned false")
}
if code := runtime.authGetCode(goja.FunctionCall{}).String(); code != "abc" {
t.Fatalf("code = %q", code)
}
if !runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
t.Fatal("expected authenticated runtime")
}
tokens := runtime.authGetTokens(goja.FunctionCall{}).Export().(map[string]interface{})
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{})
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"] == "" {
t.Fatalf("current pkce = %#v", current)
}
oauthConfig := map[string]interface{}{
"authUrl": "https://auth.example.com/oauth",
"clientId": "client",
"redirectUri": "app://callback",
"scope": "read",
"extraParams": map[string]interface{}{"prompt": "login"},
}
oauth := runtime.authStartOAuthWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(oauthConfig)}}).Export().(map[string]interface{})
if oauth["success"] != true || !strings.Contains(oauth["authUrl"].(string), "code_challenge") {
t.Fatalf("oauth = %#v", oauth)
}
tokenConfig := map[string]interface{}{
"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{})
if token["success"] != true || token["access_token"] != "access" {
t.Fatalf("token = %#v", token)
}
runtime.registerTextEncoderDecoder(vm)
runtime.registerURLClass(vm)
runtime.registerJSONGlobal(vm)
vm.Set("fetch", func(call goja.FunctionCall) goja.Value {
return runtime.fetchPolyfill(call)
})
vm.Set("atob", func(call goja.FunctionCall) goja.Value {
return runtime.atobPolyfill(call)
})
vm.Set("btoa", func(call goja.FunctionCall) goja.Value {
return runtime.btoaPolyfill(call)
})
value, err := vm.RunString(`
var encoded = btoa("hello");
var decoded = atob(encoded);
var te = new TextEncoder();
var bytes = te.encode("hi");
var into = te.encodeInto("hi", []);
var td = new TextDecoder();
var text = td.decode(bytes);
var url = new URL("/path?a=1&a=2#frag", "https://api.example.com/base");
var params = new URLSearchParams("?x=1");
params.append("x", "2");
params.set("y", "3");
var response = fetch("https://api.example.com/data", {method: "POST", body: {q: "x"}, headers: {"X-Client": "test"}});
JSON.stringify({
encoded: encoded,
decoded: decoded,
text: text,
read: into.read,
host: url.hostname,
first: url.searchParams.get("a"),
all: url.searchParams.getAll("a").length,
params: params.toString(),
ok: response.ok,
status: response.status,
jsonOk: response.json().ok,
bufferLen: response.arrayBuffer().length
});
`)
if err != nil {
t.Fatalf("polyfill script: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(value.String()), &result); err != nil {
t.Fatalf("decode polyfill result: %v", err)
}
if result["decoded"] != "hello" || result["host"] != "api.example.com" || result["ok"] != true {
t.Fatalf("polyfill result = %#v", result)
}
blocked := runtime.fetchPolyfill(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://blocked.example.com")}}).ToObject(vm)
if blocked.Get("ok").ToBoolean() {
t.Fatal("expected blocked fetch")
}
runtime.authClear(goja.FunctionCall{})
if runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
t.Fatal("expected auth cleared")
}
}
func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
dir := t.TempDir()
store := &extensionStore{
registryURL: "https://registry.example.com/registry.json",
cacheDir: dir,
cacheTTL: time.Hour,
cache: &storeRegistry{
Version: 1,
UpdatedAt: "2026-05-04",
Extensions: []storeExtension{
{
ID: "coverage-ext",
Name: "coverage-ext",
DisplayNameAlt: "Coverage Extension",
Version: "2.0.0",
Description: "Metadata and lyrics provider",
DownloadURLAlt: "https://registry.example.com/coverage.spotiflac-ext",
IconURLAlt: "https://registry.example.com/icon.png",
Category: CategoryMetadata,
Tags: []string{"metadata", "lyrics"},
Downloads: 10,
UpdatedAt: "2026-05-04",
MinAppVersionAlt: "4.5.0",
},
{
ID: "utility-ext",
Name: "utility-ext",
Version: "1.0.0",
Description: "Utility",
DownloadURL: "https://registry.example.com/utility.spotiflac-ext",
Category: CategoryUtility,
UpdatedAt: "2026-05-04",
},
},
},
cacheTime: time.Now(),
}
store.saveDiskCache()
loadedStore := &extensionStore{cacheDir: dir}
loadedStore.loadDiskCache()
if loadedStore.cache == nil || len(loadedStore.cache.Extensions) != 2 {
t.Fatalf("loaded cache = %#v", loadedStore.cache)
}
if got := store.getRegistryURL(); got != "https://registry.example.com/registry.json" {
t.Fatalf("registry URL = %q", got)
}
store.setRegistryURL("https://registry.example.com/new.json")
if store.cache != nil {
t.Fatal("expected cache reset after registry URL change")
}
store.cache = loadedStore.cache
store.cacheTime = time.Now()
manager := getExtensionManager()
manager.mu.Lock()
if manager.extensions == nil {
manager.extensions = map[string]*loadedExtension{}
}
manager.extensions["coverage-ext"] = &loadedExtension{
ID: "coverage-ext",
Manifest: &ExtensionManifest{
Name: "coverage-ext",
DisplayName: "Coverage Extension",
Version: "1.0.0",
Description: "Installed",
Types: []ExtensionType{ExtensionTypeMetadataProvider},
},
Enabled: true,
}
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
delete(manager.extensions, "coverage-ext")
manager.mu.Unlock()
}()
extensions, err := store.getExtensionsWithStatus(false)
if err != nil {
t.Fatalf("getExtensionsWithStatus: %v", err)
}
if len(extensions) != 2 || !extensions[0].IsInstalled || !extensions[0].HasUpdate {
t.Fatalf("extensions = %#v", extensions)
}
found, err := store.searchExtensions("lyrics", CategoryMetadata)
if err != nil || len(found) != 1 || found[0].ID != "coverage-ext" {
t.Fatalf("search = %#v/%v", found, err)
}
all, err := store.searchExtensions("", "")
if err != nil || len(all) != 2 {
t.Fatalf("all search = %#v/%v", all, err)
}
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")
}
if _, err := resolveRegistryURL(""); err == nil {
t.Fatal("expected empty registry URL error")
}
if resolved, err := resolveRegistryURL("http://github.com/owner/repo"); err != nil || !strings.Contains(resolved, "raw.githubusercontent.com/owner/repo") {
t.Fatalf("resolved registry = %q/%v", resolved, err)
}
store.clearCache()
if store.cache != nil {
t.Fatal("expected cleared store cache")
}
settingsStore := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
if err := settingsStore.SetDataDir(filepath.Join(dir, "settings")); err != nil {
t.Fatalf("SetDataDir: %v", err)
}
if err := settingsStore.Set("ext", "quality", "lossless"); err != nil {
t.Fatalf("settings Set: %v", err)
}
if value, err := settingsStore.Get("ext", "quality"); err != nil || value != "lossless" {
t.Fatalf("settings Get = %#v/%v", value, err)
}
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 {
t.Fatalf("settings SetAll: %v", err)
}
if all := settingsStore.GetAll("ext"); all["a"] != float64(1) {
t.Fatalf("settings all = %#v", all)
}
if err := settingsStore.Remove("ext", "a"); err != nil {
t.Fatalf("settings Remove: %v", err)
}
if err := settingsStore.RemoveAll("ext"); err != nil {
t.Fatalf("settings RemoveAll: %v", err)
}
if jsonText, err := settingsStore.GetAllExtensionSettingsJSON(); err != nil || jsonText == "" {
t.Fatalf("settings JSON = %q/%v", jsonText, err)
}
reloaded := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
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,
}
if err := os.MkdirAll(runtime.dataDir, 0755); err != nil {
t.Fatal(err)
}
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() {
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() {
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)
}
if ok := runtime.storageRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key")}}); !ok.ToBoolean() {
t.Fatal("storageRemove false")
}
runtime.closeStorageFlusher()
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("after_close"), vm.ToValue("x")}}); ok.ToBoolean() {
t.Fatal("expected storageSet false after close")
}
credRuntime := &extensionRuntime{
extensionID: "cred-ext",
dataDir: filepath.Join(dir, "creds"),
vm: vm,
}
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 {
t.Fatalf("credentialsStore = %#v", result)
}
if got := credRuntime.credentialsGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).String(); got != "secret" {
t.Fatalf("credential = %q", got)
}
if !credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
t.Fatal("expected credential")
}
if ok := credRuntime.credentialsRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}); !ok.ToBoolean() {
t.Fatal("credentialsRemove false")
}
if credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
t.Fatal("expected credential removed")
}
key, err := credRuntime.getEncryptionKey()
if err != nil {
t.Fatalf("getEncryptionKey: %v", err)
}
encrypted, err := encryptAES([]byte("plain"), key)
if err != nil {
t.Fatalf("encryptAES: %v", err)
}
decrypted, err := decryptAES(encrypted, key)
if err != nil || string(decrypted) != "plain" {
t.Fatalf("decryptAES = %q/%v", decrypted, err)
}
if _, err := decryptAES([]byte("short"), key); err == nil {
t.Fatal("expected short ciphertext error")
}
}
func TestExtensionRuntimeHTTPMatchingAndMetadataHelpers(t *testing.T) {
vm := goja.New()
jar, _ := newSimpleCookieJar()
runtime := &extensionRuntime{
extensionID: "http-ext",
manifest: &ExtensionManifest{
Name: "http-ext",
Description: "HTTP extension",
Version: "1.0.0",
Permissions: ExtensionPermissions{
Network: []string{"api.example.com"},
},
},
vm: vm,
cookieJar: jar,
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
var body []byte
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
}
header := make(http.Header)
header.Set("X-Method", req.Method)
if req.URL.Path == "/huge" {
return &http.Response{StatusCode: 200, Header: header, Body: io.NopCloser(io.LimitReader(strings.NewReader(strings.Repeat("x", maxExtensionHTTPResponseBytes+2)), maxExtensionHTTPResponseBytes+2)), Request: req}, nil
}
return &http.Response{
StatusCode: 201,
Header: header,
Body: io.NopCloser(strings.NewReader(req.Method + ":" + string(body))),
Request: req,
}, nil
})},
}
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
t.Fatalf("validateDomain allowed: %v", err)
}
for _, rawURL := range []string{"notaurl", "http://api.example.com", "https://user:pass@api.example.com", "https://127.0.0.1/x", "https://blocked.example.com/x"} {
if err := runtime.validateDomain(rawURL); err == nil {
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") {
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") {
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") {
t.Fatalf("httpRequest = %#v", got)
}
for _, method := range []struct {
name string
call func(goja.FunctionCall) goja.Value
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"})}},
} {
if got := method.call(goja.FunctionCall{Arguments: method.args}).Export().(map[string]interface{}); !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") {
t.Fatalf("huge response = %#v", got)
}
if !runtime.httpClearCookies(goja.FunctionCall{}).ToBoolean() {
t.Fatal("expected cookies cleared")
}
if runtime.matchingCompareStrings(goja.FunctionCall{}).ToFloat() != 0 {
t.Fatal("missing string compare args should be zero")
}
if runtime.matchingCompareStrings(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song"), vm.ToValue("song")}}).ToFloat() != 1 {
t.Fatal("expected exact string similarity")
}
if runtime.matchingCompareDuration(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(180000), vm.ToValue(182000)}}).ToBoolean() != true {
t.Fatal("expected duration match")
}
if runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String() != "song" {
t.Fatalf("normalized = %q", runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String())
}
if formatMusicBrainzGenre([]musicBrainzTag{{Count: 1, Name: "rock"}, {Count: 5, Name: "electronic"}, {Count: 10, Name: "rock"}}) != "Electronic" {
t.Fatal("unexpected genre selection")
}
credits := []musicBrainzArtistCredit{{Name: "A", JoinPhrase: " & "}, {Name: "B"}}
if formatMusicBrainzArtistCredit(credits) != "A & B" {
t.Fatal("artist credit format mismatch")
}
releases := []musicBrainzRelease{
{Title: "Other", ArtistCredit: []musicBrainzArtistCredit{{Name: "Fallback"}}},
{Title: "Album", ArtistCredit: credits},
}
if selectMusicBrainzAlbumArtist(releases, "Album") != "A & B" || selectMusicBrainzAlbumArtist(releases, "") != "Fallback" {
t.Fatal("album artist selection mismatch")
}
}
func TestExtensionRuntimeFileAPIs(t *testing.T) {
vm := goja.New()
dir := t.TempDir()
SetAllowedDownloadDirs(nil)
defer SetAllowedDownloadDirs(nil)
fileBody := "chunk"
runtime := &extensionRuntime{
extensionID: "file-ext",
manifest: &ExtensionManifest{
Name: "file-ext",
Description: "File extension",
Version: "1.0.0",
Permissions: ExtensionPermissions{
File: true,
Network: []string{"files.example.com"},
},
},
dataDir: dir,
vm: vm,
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("Range") == "" {
body := "downloaded"
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Request: req,
}, nil
}
rangeHeader := req.Header.Get("Range")
start, end := 0, len(fileBody)-1
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil {
start, end = 0, 1
}
if start < 0 {
start = 0
}
if end >= len(fileBody) {
end = len(fileBody) - 1
}
if start > len(fileBody) {
start = len(fileBody)
}
body := fileBody[start : end+1]
header := http.Header{"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(fileBody))}}
return &http.Response{StatusCode: 206, Header: header, Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
})},
}
runtime.downloadClient = runtime.httpClient
if _, err := (&extensionRuntime{manifest: &ExtensionManifest{}}).validatePath("x"); err == nil {
t.Fatal("expected file permission error")
}
if _, err := runtime.validatePath("../escape.txt"); err == nil {
t.Fatal("expected sandbox escape error")
}
AddAllowedDownloadDir(dir)
absolutePath := filepath.Join(dir, "allowed.txt")
if got, err := runtime.validatePath(absolutePath); err != nil || got != absolutePath {
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{})
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{})
if read["data"] != "hello" {
t.Fatalf("fileRead = %#v", read)
}
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{})
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{})
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{})
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 {
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 {
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{})
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{})
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{})
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{})
if deleteResult["success"] != true {
t.Fatalf("fileDelete = %#v", deleteResult)
}
download := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("https://files.example.com/file"),
vm.ToValue("downloads/file.bin"),
}}).Export().(map[string]interface{})
if download["success"] != true {
t.Fatalf("fileDownload = %#v", download)
}
if data, err := os.ReadFile(filepath.Join(dir, "downloads/file.bin")); err != nil || string(data) != "downloaded" {
t.Fatalf("downloaded data = %q/%v", data, err)
}
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{})
if chunked["success"] != true {
t.Fatalf("chunked fileDownload = %#v", chunked)
}
if data, err := os.ReadFile(filepath.Join(dir, "downloads/chunk.bin")); err != nil || string(data) != fileBody {
t.Fatalf("chunked data = %q/%v", data, err)
}
if missing := runtime.fileDownload(goja.FunctionCall{}).Export().(map[string]interface{}); missing["success"] != false {
t.Fatalf("expected missing download args error, got %#v", missing)
}
}
func TestExtensionRuntimeUtilityAPIs(t *testing.T) {
vm := goja.New()
runtime := &extensionRuntime{extensionID: "utils-ext", vm: vm}
if runtime.sha256Hash(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("abc")}}).String() == "" {
t.Fatal("expected sha256")
}
if runtime.hmacSHA256(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
t.Fatal("expected hmac sha256")
}
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 {
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{})
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") {
t.Fatalf("stringifyJSON = %q", text)
}
encrypted := runtime.cryptoEncrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("plain"), vm.ToValue("secret")}}).Export().(map[string]interface{})
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{})
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 {
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{})
if key["success"] != true || key["key"] == "" || key["hex"] == "" {
t.Fatalf("cryptoGenerateKey = %#v", key)
}
if runtime.randomUserAgent(goja.FunctionCall{}).String() == "" || runtime.appUserAgent(goja.FunctionCall{}).String() == "" {
t.Fatal("expected user agents")
}
SetAppVersion("9.9.9")
if runtime.appVersion(goja.FunctionCall{}).String() != "9.9.9" {
t.Fatal("appVersion mismatch")
}
if !runtime.sleep(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(0))}}).ToBoolean() {
t.Fatal("zero sleep should succeed")
}
itemID := "utils-item"
runtime.setActiveDownloadItemID(itemID)
initDownloadCancel(itemID)
if runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
t.Fatal("item should not be cancelled yet")
}
runtime.setDownloadStatus(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(itemProgressStatusDownloading)}})
cancelDownload(itemID)
if !runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
t.Fatal("item should be cancelled")
}
clearDownloadCancel(itemID)
runtime.clearActiveDownloadItemID()
requestID := "utils-request"
runtime.setActiveRequestID(requestID)
initExtensionRequestCancel(requestID)
if runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
t.Fatal("request should not be cancelled yet")
}
cancelExtensionRequest(requestID)
if !runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
t.Fatal("request should be cancelled")
}
clearExtensionRequestCancel(requestID)
runtime.clearActiveRequestID()
if msg := runtime.formatLogArgs([]goja.Value{vm.ToValue("a"), vm.ToValue(1)}); msg != "a 1" {
t.Fatalf("formatLogArgs = %q", msg)
}
runtime.logDebug(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("debug")}})
runtime.logInfo(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("info")}})
runtime.logWarn(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("warn")}})
runtime.logError(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("error")}})
if clean := runtime.sanitizeFilenameWrapper(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("A/B?")}}).String(); strings.ContainsAny(clean, "/?") {
t.Fatalf("sanitize wrapper = %q", clean)
}
}
+20 -187
View File
@@ -16,7 +16,7 @@ import (
"github.com/dop251/goja"
)
func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
@@ -24,7 +24,7 @@ func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
}
func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
@@ -36,7 +36,7 @@ func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded))
}
func (r *extensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
@@ -45,7 +45,7 @@ func (r *extensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:]))
}
func (r *extensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
@@ -54,7 +54,7 @@ func (r *extensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:]))
}
func (r *extensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue("")
}
@@ -66,7 +66,7 @@ func (r *extensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
}
func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue("")
}
@@ -78,7 +78,7 @@ func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
}
func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue([]byte{})
}
@@ -130,7 +130,7 @@ func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(jsArray)
}
func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
}
@@ -145,7 +145,7 @@ func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result)
}
func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
@@ -160,7 +160,7 @@ func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(data))
}
func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -187,7 +187,7 @@ func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -222,7 +222,7 @@ func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
length := 32
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok {
@@ -245,125 +245,35 @@ func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
})
}
func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent())
}
func (r *extensionRuntime) appVersion(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(GetAppVersion())
}
func (r *extensionRuntime) appUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(appUserAgent())
}
func (r *extensionRuntime) sleep(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(true)
}
sleepMs := 0
switch value := call.Arguments[0].Export().(type) {
case int64:
sleepMs = int(value)
case int32:
sleepMs = int(value)
case int:
sleepMs = value
case float64:
sleepMs = int(value)
default:
sleepMs = 0
}
if sleepMs <= 0 {
return r.vm.ToValue(true)
}
if sleepMs > 5*60*1000 {
sleepMs = 5 * 60 * 1000
}
itemID := r.getActiveDownloadItemID()
deadline := time.Now().Add(time.Duration(sleepMs) * time.Millisecond)
for {
if itemID != "" && isDownloadCancelled(itemID) {
return r.vm.ToValue(false)
}
remaining := time.Until(deadline)
if remaining <= 0 {
return r.vm.ToValue(true)
}
step := 100 * time.Millisecond
if remaining < step {
step = remaining
}
time.Sleep(step)
}
}
func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Value {
itemID := r.getActiveDownloadItemID()
if itemID == "" {
return r.vm.ToValue(false)
}
return r.vm.ToValue(isDownloadCancelled(itemID))
}
func (r *extensionRuntime) isRequestCancelled(call goja.FunctionCall) goja.Value {
requestID := r.getActiveRequestID()
if requestID == "" {
return r.vm.ToValue(false)
}
return r.vm.ToValue(isExtensionRequestCancelled(requestID))
}
func (r *extensionRuntime) setDownloadStatus(call goja.FunctionCall) goja.Value {
itemID := r.getActiveDownloadItemID()
if itemID == "" || len(call.Arguments) < 1 {
return goja.Undefined()
}
status := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
switch status {
case itemProgressStatusPreparing:
SetItemPreparing(itemID)
case itemProgressStatusDownloading:
SetItemDownloading(itemID)
case itemProgressStatusFinalizing:
SetItemFinalizing(itemID)
}
return goja.Undefined()
}
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *extensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *extensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *extensionRuntime) logError(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
parts := make([]string, len(args))
for i, arg := range args {
parts[i] = fmt.Sprintf("%v", arg.Export())
@@ -371,7 +281,7 @@ func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
return strings.Join(parts, " ")
}
func (r *extensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
@@ -379,7 +289,7 @@ func (r *extensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
return r.vm.ToValue(sanitizeFilename(input))
}
func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
gobackendObj := vm.Get("gobackend")
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
gobackendObj = vm.NewObject()
@@ -414,83 +324,6 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
"bitDepth": quality.BitDepth,
"sampleRate": quality.SampleRate,
"totalSamples": quality.TotalSamples,
"duration": quality.Duration,
"codec": quality.Codec,
})
})
obj.Set("getLyricsLRC", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 3 {
return vm.ToValue(map[string]interface{}{
"error": "spotifyID, trackName, and artistName are required",
})
}
spotifyID := strings.TrimSpace(call.Arguments[0].String())
trackName := strings.TrimSpace(call.Arguments[1].String())
artistName := strings.TrimSpace(call.Arguments[2].String())
filePath := ""
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
filePath = strings.TrimSpace(call.Arguments[3].String())
}
var durationMs int64
if len(call.Arguments) > 4 && !goja.IsUndefined(call.Arguments[4]) && !goja.IsNull(call.Arguments[4]) {
durationMs = call.Arguments[4].ToInteger()
}
lyrics, err := GetLyricsLRC(spotifyID, trackName, artistName, filePath, durationMs)
if err != nil {
return vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
return vm.ToValue(map[string]interface{}{
"lyrics": lyrics,
})
})
obj.Set("checkISRCExists", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return vm.ToValue(map[string]interface{}{
"error": "outputDir and isrc are required",
})
}
outputDir := strings.TrimSpace(call.Arguments[0].String())
isrc := strings.TrimSpace(call.Arguments[1].String())
if outputDir == "" || isrc == "" {
return vm.ToValue(map[string]interface{}{
"error": "outputDir and isrc are required",
})
}
filePath, exists := checkISRCExistsInternal(outputDir, isrc)
return vm.ToValue(map[string]interface{}{
"exists": exists,
"filePath": filePath,
})
})
obj.Set("addToISRCIndex", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 3 {
return vm.ToValue(map[string]interface{}{
"error": "outputDir, isrc, and filePath are required",
})
}
outputDir := strings.TrimSpace(call.Arguments[0].String())
isrc := strings.TrimSpace(call.Arguments[1].String())
filePath := strings.TrimSpace(call.Arguments[2].String())
if outputDir == "" || isrc == "" || filePath == "" {
return vm.ToValue(map[string]interface{}{
"error": "outputDir, isrc, and filePath are required",
})
}
AddToISRCIndex(outputDir, isrc, filePath)
return vm.ToValue(map[string]interface{}{
"success": true,
})
})
-664
View File
@@ -1,664 +0,0 @@
package gobackend
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
const signedSessionRefreshSkew = time.Hour
var (
pendingSignedSessionGrants = make(map[string]string)
pendingSignedSessionGrantsMu sync.Mutex
)
type signedSessionRecord struct {
InstallID string `json:"install_id"`
SessionID string `json:"session_id,omitempty"`
SessionSecret string `json:"session_secret,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Namespace string `json:"namespace,omitempty"`
BaseURL string `json:"base_url,omitempty"`
AppVersion string `json:"app_version,omitempty"`
Platform string `json:"platform,omitempty"`
}
type signedSessionExchangeResponse struct {
SessionID string `json:"session_id,omitempty"`
SessionSecret string `json:"session_secret,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
ChallengeID string `json:"challenge_id,omitempty"`
ChallengeURL string `json:"challenge_url,omitempty"`
AuthURL string `json:"auth_url,omitempty"`
}
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
if config == nil {
return SignedSessionConfig{}
}
resolved := *config
if resolved.AppVersion == "" {
resolved.AppVersion = "ext-1.0"
}
if resolved.Platform == "" {
resolved.Platform = "extension"
}
if resolved.CallbackURL == "" {
resolved.CallbackURL = "spotiflac://session-grant"
}
if resolved.SchemeLabel == "" {
resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1"
}
if resolved.HeaderPrefix == "" {
resolved.HeaderPrefix = "X-Sig-"
}
if resolved.TimeWindowSeconds <= 0 {
resolved.TimeWindowSeconds = 300
}
if resolved.Endpoints.Bootstrap == "" {
resolved.Endpoints.Bootstrap = "/bootstrap"
}
if resolved.Endpoints.Challenge == "" {
resolved.Endpoints.Challenge = "/challenge"
}
if resolved.Endpoints.Exchange == "" {
resolved.Endpoints.Exchange = "/session/exchange"
}
return resolved
}
func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) {
namespace := sanitizeSignedSessionNamespace(config.Namespace)
if namespace == "" {
return "", fmt.Errorf("signed session namespace is empty")
}
baseDir := filepath.Dir(r.dataDir)
if baseDir == "." || baseDir == "" {
baseDir = r.dataDir
}
dir := filepath.Join(baseDir, "signed_sessions")
if err := os.MkdirAll(dir, 0700); err != nil {
return "", err
}
scope := strings.Join([]string{
namespace,
strings.TrimSpace(strings.ToLower(config.BaseURL)),
strings.TrimSpace(strings.ToLower(config.AppVersion)),
strings.TrimSpace(strings.ToLower(config.Platform)),
}, "\n")
sum := sha256.Sum256([]byte(scope))
return filepath.Join(dir, namespace+"-"+hex.EncodeToString(sum[:])[:16]+".json"), nil
}
func sanitizeSignedSessionNamespace(namespace string) string {
namespace = strings.TrimSpace(strings.ToLower(namespace))
var b strings.Builder
for _, ch := range namespace {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' {
b.WriteRune(ch)
}
}
return strings.Trim(b.String(), ".-_")
}
func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
path, err := r.signedSessionFilePath(config)
if err != nil {
return nil, err
}
record := &signedSessionRecord{}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, record)
}
if strings.TrimSpace(record.InstallID) == "" {
record.InstallID = randomHex(16)
}
normalizeSignedSessionRecordScope(config, record)
if err := r.saveSignedSession(config, record); err != nil {
return nil, err
}
return record, nil
}
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) {
namespace := sanitizeSignedSessionNamespace(config.Namespace)
baseURL := strings.TrimSpace(config.BaseURL)
appVersion := strings.TrimSpace(config.AppVersion)
platform := strings.TrimSpace(config.Platform)
if record.Namespace == "" && record.BaseURL == "" && record.AppVersion == "" && record.Platform == "" {
record.Namespace = namespace
record.BaseURL = baseURL
record.AppVersion = appVersion
record.Platform = platform
return
}
if record.Namespace != namespace ||
record.BaseURL != baseURL ||
record.AppVersion != appVersion ||
record.Platform != platform {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
}
record.Namespace = namespace
record.BaseURL = baseURL
record.AppVersion = appVersion
record.Platform = platform
}
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
path, err := r.signedSessionFilePath(config)
if err != nil {
return err
}
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func randomHex(bytesLen int) string {
buf := make([]byte, bytesLen)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func parseSignedSessionTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.000Z",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": "signedSession is not configured"})
}
record, err := r.loadSignedSession(config)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": err.Error()})
}
authenticated := record.SessionID != "" && record.SessionSecret != ""
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok && time.Now().After(expiresAt) {
authenticated = false
}
return r.vm.ToValue(map[string]interface{}{
"authenticated": authenticated,
"expires_at": record.ExpiresAt,
"install_id": record.InstallID,
"session_id": record.SessionID,
"app_version": config.AppVersion,
"platform": config.Platform,
})
}
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
record, err := r.loadSignedSession(config)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
}
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
if err := r.saveSignedSession(config, record); err != nil {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
}
ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]interface{}{"success": true})
}
func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value {
grant := ""
if len(call.Arguments) > 0 {
grant = strings.TrimSpace(call.Arguments[0].String())
}
if grant == "" {
pendingSignedSessionGrantsMu.Lock()
grant = pendingSignedSessionGrants[r.extensionID]
delete(pendingSignedSessionGrants, r.extensionID)
pendingSignedSessionGrantsMu.Unlock()
}
if grant == "" {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": "no pending grant"})
}
if err := r.exchangeSignedSessionGrant(grant); err != nil {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
}
ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]interface{}{"success": true})
}
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
record, err := r.loadSignedSession(config)
if err != nil {
return err
}
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
if err != nil {
return err
}
payload := map[string]interface{}{
"grant": grant,
"install_id": record.InstallID,
"app_version": config.AppVersion,
"platform": config.Platform,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
resp, err := r.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
}
var exchanged signedSessionExchangeResponse
if err := json.Unmarshal(respBody, &exchanged); err != nil {
return fmt.Errorf("invalid session exchange response: %w", err)
}
if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" {
return fmt.Errorf("session exchange response missing session fields")
}
record.SessionID = exchanged.SessionID
record.SessionSecret = exchanged.SessionSecret
record.ExpiresAt = exchanged.ExpiresAt
return r.saveSignedSession(config, record)
}
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "method and path are required"})
}
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "signedSession is not configured"})
}
method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String()))
requestPath := call.Arguments[1].String()
body := []byte{}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
switch v := call.Arguments[2].Export().(type) {
case string:
body = []byte(v)
case map[string]interface{}, []interface{}:
encoded, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
}
body = encoded
default:
body = []byte(call.Arguments[2].String())
}
}
extraHeaders := map[string]string{}
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
if h, ok := call.Arguments[3].Export().(map[string]interface{}); ok {
for k, v := range h {
extraHeaders[k] = fmt.Sprintf("%v", v)
}
}
}
record, err := r.ensureSignedSession(config)
if err != nil {
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
return r.signedSessionVerificationRequiredValue(authURL)
}
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
}
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = r.saveSignedSession(config, record)
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
return r.signedSessionVerificationRequiredValue(authURL)
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(respBody),
"headers": respHeaders,
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp),
})
}
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
return r.vm.ToValue(map[string]interface{}{
"ok": false,
"needsVerification": true,
"error": "VERIFY_REQUIRED",
"open_auth_url": authURL,
"auth_url": authURL,
})
}
func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
record, err := r.loadSignedSession(config)
if err != nil {
return nil, err
}
if record.SessionID == "" || record.SessionSecret == "" {
return nil, fmt.Errorf("signed session is not authenticated")
}
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
if time.Now().After(expiresAt) {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = r.saveSignedSession(config, record)
return nil, fmt.Errorf("signed session expired")
}
if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew {
_ = r.refreshSignedSession(config, record)
}
}
return record, nil
}
func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
body, _ := json.Marshal(map[string]string{"install_id": record.InstallID})
resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode)
}
var refreshed signedSessionExchangeResponse
if err := json.Unmarshal(respBody, &refreshed); err != nil {
return err
}
changed := false
if refreshed.SessionID != "" {
record.SessionID = refreshed.SessionID
changed = true
}
if refreshed.SessionSecret != "" {
record.SessionSecret = refreshed.SessionSecret
changed = true
}
if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt {
record.ExpiresAt = refreshed.ExpiresAt
changed = true
}
if changed {
return r.saveSignedSession(config, record)
}
return nil
}
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) string {
record, err := r.loadSignedSession(config)
if err != nil {
return ""
}
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
if err != nil {
return ""
}
parsed, _ := url.Parse(bootstrapURL)
query := parsed.Query()
query.Set("app_version", config.AppVersion)
query.Set("install_id", record.InstallID)
parsed.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
if err != nil {
return ""
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
resp, err := r.httpClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ""
}
var boot signedSessionExchangeResponse
if err := json.Unmarshal(body, &boot); err != nil {
return ""
}
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
record.SessionID = boot.SessionID
record.SessionSecret = boot.SessionSecret
record.ExpiresAt = boot.ExpiresAt
_ = r.saveSignedSession(config, record)
return ""
}
authURL := boot.AuthURL
if authURL == "" && boot.ChallengeURL != "" {
authURL = boot.ChallengeURL
}
if authURL == "" && boot.ChallengeID != "" {
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
}
if authURL != "" {
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: authURL,
CallbackURL: config.CallbackURL,
}
pendingAuthRequestsMu.Unlock()
}
return authURL
}
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge)
if err != nil {
return ""
}
parsed, err := url.Parse(challengeURL)
if err != nil {
return ""
}
callback, err := url.Parse(config.CallbackURL)
if err != nil {
return ""
}
q := callback.Query()
q.Set("cb_version", "v2grant")
q.Set("state", r.extensionID)
callback.RawQuery = q.Encode()
query := parsed.Query()
query.Set("id", challengeID)
query.Set("cb", callback.String())
parsed.RawQuery = query.Encode()
return parsed.String()
}
func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) {
base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/")
if err != nil || base.Scheme != "https" || base.Host == "" {
return "", fmt.Errorf("invalid signed session baseUrl")
}
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return "", fmt.Errorf("signed session endpoint is empty")
}
if strings.HasPrefix(endpoint, "https://") {
return endpoint, nil
}
endpoint = strings.TrimLeft(endpoint, "/")
ref, _ := url.Parse(endpoint)
return base.ResolveReference(ref).String(), nil
}
func (r *extensionRuntime) doSignedSessionRequest(
config SignedSessionConfig,
record *signedSessionRecord,
method string,
requestPath string,
body []byte,
extraHeaders map[string]string,
) (*http.Response, []byte, map[string]interface{}, error) {
fullURL, err := signedSessionURL(config, requestPath)
if err != nil {
return nil, nil, nil, err
}
parsed, err := url.Parse(fullURL)
if err != nil {
return nil, nil, nil, err
}
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
nonce := randomHex(12)
bodyHashBytes := sha256.Sum256(body)
bodyHash := hex.EncodeToString(bodyHashBytes[:])
parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts)
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID)
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput)))
signingInput := strings.Join([]string{
config.SchemeLabel,
method,
parsed.EscapedPath(),
"",
bodyHash,
ts,
nonce,
record.SessionID,
config.AppVersion,
config.Platform,
}, "\n")
sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
req, err := http.NewRequest(method, fullURL, bytes.NewReader(body))
if err != nil {
return nil, nil, nil, err
}
req = r.bindDownloadCancelContext(req)
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
prefix := config.HeaderPrefix
req.Header.Set(prefix+"Session", record.SessionID)
req.Header.Set(prefix+"Timestamp", ts)
req.Header.Set(prefix+"Nonce", nonce)
req.Header.Set(prefix+"Body-SHA256", bodyHash)
req.Header.Set(prefix+"Signature", sig)
req.Header.Set(prefix+"App-Version", config.AppVersion)
req.Header.Set(prefix+"Platform", config.Platform)
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, nil, nil, err
}
defer resp.Body.Close()
respBody, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return nil, nil, nil, err
}
headers := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
headers[k] = v[0]
} else {
headers[k] = v
}
}
return resp, respBody, headers, nil
}
func signedSessionRetryAfterSeconds(resp *http.Response) int {
if resp == nil {
return 0
}
value := strings.TrimSpace(resp.Header.Get("Retry-After"))
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
if seconds < 0 {
return 0
}
return seconds
}
if retryAt, err := http.ParseTime(value); err == nil {
seconds := int(time.Until(retryAt).Seconds())
if seconds < 0 {
return 0
}
return seconds
}
return 0
}
func hmacSHA256Bytes(key, message []byte) []byte {
mac := hmac.New(sha256.New, key)
mac.Write(message)
return mac.Sum(nil)
}
func setPendingSignedSessionGrant(extensionID, grant string) {
extensionID = strings.TrimSpace(extensionID)
grant = strings.TrimSpace(grant)
if extensionID == "" || grant == "" {
return
}
pendingSignedSessionGrantsMu.Lock()
pendingSignedSessionGrants[extensionID] = grant
pendingSignedSessionGrantsMu.Unlock()
}
+15 -31
View File
@@ -26,6 +26,7 @@ type storeExtension struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"`
@@ -82,6 +83,7 @@ type storeExtensionResponse struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url"`
IconURL string `json:"icon_url,omitempty"`
@@ -101,6 +103,7 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
Name: e.Name,
DisplayName: e.getDisplayName(),
Version: e.Version,
Author: e.Author,
Description: e.Description,
DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(),
@@ -250,17 +253,7 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
LogInfo("ExtensionStore", "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)
return s.cache, nil
}
return nil, fmt.Errorf("failed to build registry request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
resp, err := client.Get(s.registryURL)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
@@ -302,7 +295,7 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
return nil, err
}
manager := getExtensionManager()
manager := GetExtensionManager()
installed := make(map[string]string) // id -> version
if manager != nil {
@@ -330,26 +323,22 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
return result, nil
}
func (s *extensionStore) findExtension(extensionID string) (*storeExtension, error) {
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
registry, err := s.fetchRegistry(false)
if err != nil {
return nil, err
return err
}
var ext *storeExtension
for _, e := range registry.Extensions {
if e.ID == extensionID {
ext := e
return &ext, nil
ext = &e
break
}
}
return nil, fmt.Errorf("extension %s not found in store", extensionID)
}
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
ext, err := s.findExtension(extensionID)
if err != nil {
return err
if ext == nil {
return fmt.Errorf("extension %s not found in store", extensionID)
}
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
@@ -359,13 +348,7 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := NewHTTPClientWithTimeout(5 * time.Minute)
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
if err != nil {
return fmt.Errorf("failed to build download request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
resp, err := client.Get(ext.getDownloadURL())
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
@@ -498,7 +481,8 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
if query != "" {
if !containsIgnoreCase(ext.Name, queryLower) &&
!containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) {
!containsIgnoreCase(ext.Description, queryLower) &&
!containsIgnoreCase(ext.Author, queryLower) {
found := false
for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) {
+16 -204
View File
@@ -1,12 +1,8 @@
package gobackend
import (
"context"
"errors"
"net/http"
"path/filepath"
"testing"
"time"
"github.com/dop251/goja"
)
@@ -16,6 +12,7 @@ func TestParseManifest_Valid(t *testing.T) {
"name": "test-provider",
"displayName": "Test Provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"],
"permissions": {
@@ -46,26 +43,10 @@ func TestParseManifest_Valid(t *testing.T) {
}
}
func TestExtensionManifestStopsProviderFallback(t *testing.T) {
modernManifest := &ExtensionManifest{StopProviderFallback: true}
if !modernManifest.StopsProviderFallback() {
t.Fatal("expected stopProviderFallback to stop provider fallback")
}
legacyManifest := &ExtensionManifest{SkipBuiltInFallback: true}
if !legacyManifest.StopsProviderFallback() {
t.Fatal("expected legacy skipBuiltInFallback to stop provider fallback")
}
defaultManifest := &ExtensionManifest{}
if defaultManifest.StopsProviderFallback() {
t.Fatal("expected default manifest to allow provider fallback")
}
}
func TestParseManifest_MissingName(t *testing.T) {
invalidManifest := `{
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"]
}`
@@ -80,6 +61,7 @@ func TestParseManifest_MissingType(t *testing.T) {
invalidManifest := `{
"name": "test-provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension"
}`
@@ -116,7 +98,8 @@ func TestIsDomainAllowed(t *testing.T) {
}
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
ext := &loadedExtension{
// Create a mock extension with limited network permissions
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
@@ -127,7 +110,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime := NewExtensionRuntime(ext)
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
@@ -144,21 +127,12 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
t.Error("Expected notallowed.com to be denied")
}
if err := runtime.validateDomain("http://api.allowed.com/path"); err == nil {
t.Error("Expected http URL to be denied without allowHttp")
}
ext.Manifest.Permissions.AllowHTTP = true
if err := runtime.validateDomain("http://api.allowed.com/path"); err != nil {
t.Errorf("Expected http URL to be allowed with allowHttp, got error: %v", err)
}
}
func TestExtensionRuntime_FileSandbox(t *testing.T) {
tempDir := t.TempDir()
ext := &loadedExtension{
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
@@ -169,7 +143,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
DataDir: tempDir,
}
runtime := newExtensionRuntime(ext)
runtime := NewExtensionRuntime(ext)
validPath, err := runtime.validatePath("test.txt")
if err != nil {
@@ -203,7 +177,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
t.Error("Expected absolute path to be blocked")
}
extNoFile := &loadedExtension{
extNoFile := &LoadedExtension{
ID: "test-ext-no-file",
Manifest: &ExtensionManifest{
Name: "test-ext-no-file",
@@ -213,7 +187,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
},
DataDir: tempDir,
}
runtimeNoFile := newExtensionRuntime(extNoFile)
runtimeNoFile := NewExtensionRuntime(extNoFile)
_, err = runtimeNoFile.validatePath("test.txt")
if err == nil {
t.Error("Expected file access to be denied without file permission")
@@ -221,7 +195,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
}
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
ext := &loadedExtension{
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
@@ -229,7 +203,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime := NewExtensionRuntime(ext)
vm := goja.New()
runtime.RegisterAPIs(vm)
@@ -261,177 +235,15 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
if err != nil {
t.Fatalf("stringifyJSON failed: %v", err)
}
// JSON output may vary in order, just check it's valid
if result.String() == "" {
t.Error("Expected non-empty JSON string")
}
result, err = vm.RunString(`utils.sleep(1)`)
if err != nil {
t.Fatalf("sleep failed: %v", err)
}
if !result.ToBoolean() {
t.Error("Expected sleep to complete successfully")
}
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
result, err = vm.RunString(`utils.isDownloadCancelled()`)
if err != nil {
t.Fatalf("isDownloadCancelled failed: %v", err)
}
if !result.ToBoolean() {
t.Error("Expected active download cancellation to be visible to JS")
}
SetAppVersion("4.2.2")
t.Cleanup(func() {
SetAppVersion("")
})
result, err = vm.RunString(`utils.appVersion()`)
if err != nil {
t.Fatalf("appVersion failed: %v", err)
}
if got := result.String(); got != "4.2.2" {
t.Fatalf("Expected appVersion 4.2.2, got %q", got)
}
result, err = vm.RunString(`utils.appUserAgent()`)
if err != nil {
t.Fatalf("appUserAgent failed: %v", err)
}
if got := result.String(); got != "SpotiFLAC-Mobile/4.2.2" {
t.Fatalf("Expected appUserAgent SpotiFLAC-Mobile/4.2.2, got %q", got)
}
result, err = vm.RunString(`utils.sleep(50)`)
if err != nil {
t.Fatalf("cancel-aware sleep failed: %v", err)
}
if result.ToBoolean() {
t.Error("Expected sleep to abort when download is cancelled")
}
}
func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
cancelDownload("test-item")
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected bound request context to be cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error after cancellation")
}
}
func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected pre-cancelled request context to stay cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error for pre-cancelled item")
}
}
func TestRunWithTimeoutContextCancelsExecution(t *testing.T) {
vm := goja.New()
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := RunWithTimeoutContextAndRecover(ctx, vm, `while (true) {}`, 5*time.Second)
if !errors.Is(err, ErrExtensionRequestCancelled) {
t.Fatalf("expected extension request cancellation, got %v", err)
}
}
func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
const requestID = "test-extension-request"
clearExtensionRequestCancel(requestID)
defer clearExtensionRequestCancel(requestID)
runtime.setActiveRequestID(requestID)
defer runtime.clearActiveRequestID()
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
cancelExtensionRequest(requestID)
select {
case <-req.Context().Done():
case <-time.After(time.Second):
t.Fatal("expected request context to be cancelled")
}
}
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
ext := &loadedExtension{
// Create extension with limited network permissions
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
@@ -442,7 +254,7 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime := NewExtensionRuntime(ext)
privateIPs := []string{
"http://localhost/admin",
+5 -27
View File
@@ -20,10 +20,6 @@ func (e *JSExecutionError) Error() string {
}
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
return RunWithTimeoutContext(context.Background(), vm, script, timeout)
}
func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
if vm == nil {
return nil, fmt.Errorf("extension runtime unavailable")
}
@@ -32,10 +28,7 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
timeout = DefaultJSTimeout
}
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, timeout)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
type result struct {
@@ -60,7 +53,7 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
IsTimeout: true,
}}
} else {
GoLog("[extensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
}
}
@@ -74,16 +67,11 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
case res := <-resultCh:
return res.value, res.err
case <-ctx.Done():
cancelled := ctx.Err() == context.Canceled
interruptMu.Lock()
interrupted = true
interruptMu.Unlock()
if cancelled {
vm.Interrupt("extension request cancelled")
} else {
vm.Interrupt("execution timeout")
}
vm.Interrupt("execution timeout")
// MUST wait for the goroutine to finish before returning.
// The Goja VM is NOT thread-safe — if we return while the goroutine
@@ -92,9 +80,6 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
// pointer dereference.
select {
case res := <-resultCh:
if cancelled {
return nil, ErrExtensionRequestCancelled
}
if res.err != nil {
return nil, res.err
}
@@ -105,10 +90,7 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
case <-time.After(60 * time.Second):
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
// Log a warning — the VM should NOT be reused after this.
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
if cancelled {
return nil, ErrExtensionRequestCancelled
}
GoLog("[ExtensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)",
IsTimeout: true,
@@ -120,11 +102,7 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
// This should be used when you want to continue using the VM after a timeout
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
return RunWithTimeoutContextAndRecover(context.Background(), vm, script, timeout)
}
func RunWithTimeoutContextAndRecover(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
result, err := RunWithTimeoutContext(ctx, vm, script, timeout)
result, err := RunWithTimeout(vm, script, timeout)
if vm != nil {
vm.ClearInterrupt()
+6 -64
View File
@@ -6,79 +6,35 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
var (
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
multiUnderscore = regexp.MustCompile(`_+`)
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}`)
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
yearPattern = regexp.MustCompile(`\d{4}`)
)
func sanitizeFilename(filename string) string {
sanitized := strings.ReplaceAll(filename, "/", " ")
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
sanitized := invalidChars.ReplaceAllString(filename, "_")
var builder strings.Builder
for _, r := range sanitized {
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
builder.WriteRune(r)
}
sanitized = builder.String()
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ". ")
sanitized = strings.Join(strings.Fields(sanitized), " ")
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
sanitized = strings.Trim(sanitized, "_ ")
sanitized = strings.Trim(sanitized, ".")
if !utf8.ValidString(sanitized) {
sanitized = strings.ToValidUTF8(sanitized, "_")
}
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
if len(sanitized) > 200 {
sanitized = truncateUTF8Bytes(sanitized, 200)
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
sanitized = strings.Trim(sanitized, "_ ")
sanitized = sanitized[:200]
}
if sanitized == "" {
return "Unknown"
sanitized = "untitled"
}
return sanitized
}
func truncateUTF8Bytes(value string, maxBytes int) string {
if maxBytes <= 0 || len(value) <= maxBytes {
return value
}
used := 0
for i, r := range value {
runeLen := utf8.RuneLen(r)
if runeLen < 0 {
runeLen = len(string(r))
}
if used+runeLen > maxBytes {
return value[:i]
}
used += runeLen
}
return value
}
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
if template == "" {
template = "{artist} - {title}"
@@ -99,11 +55,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
"{album}": getString(metadata, "album"),
"{track}": formatTrackNumber(getInt(metadata, "track")),
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
"{playlist_position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlist position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlistPosition}": formatTrackNumber(getPlaylistPosition(metadata)),
"{position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlist_position_raw}": formatRawNumber(getPlaylistPosition(metadata)),
"{year}": yearValue,
"{date}": dateValue,
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
@@ -125,9 +76,6 @@ 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 ""
@@ -185,8 +133,6 @@ func getInt(m map[string]interface{}, key string) int {
candidateKeys = append(candidateKeys, "track_number")
case "disc":
candidateKeys = append(candidateKeys, "disc_number")
case "playlist_position", "playlistPosition", "playlist position", "position":
candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position")
}
for _, candidate := range candidateKeys {
@@ -210,10 +156,6 @@ func getInt(m map[string]interface{}, key string) int {
return 0
}
func getPlaylistPosition(metadata map[string]interface{}) int {
return getInt(metadata, "playlist_position")
}
func formatTrackNumber(n int) string {
if n <= 0 {
return ""

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