Compare commits

..

45 Commits

Author SHA1 Message Date
zarzet 4e530ffbc3 chore: bump app version to v4.2.1 2026-04-04 21:48:19 +07:00
zarzet 14f6776fdc fix: remove stale audio service manifest entries causing crashes on some devices 2026-04-04 21:40:46 +07:00
zarzet da1c6e9171 fix: harden gomobile extension bindings and m4a cover retention 2026-04-04 21:30:11 +07:00
zarzet 9c3e934395 fix: preserve local convert format and library entries 2026-04-04 21:29:20 +07:00
zarzet 15d2c3b465 feat: enrich composer and track totals metadata 2026-04-04 18:50:05 +07:00
zarzet 8aaa6d5cbe fix: preserve embedded metadata details 2026-04-04 18:06:52 +07:00
zarzet 9158d0228d ci: pin iOS release builds to macOS 15 and Xcode 26.1.1 2026-04-04 15:53:46 +07:00
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
268 changed files with 28010 additions and 100958 deletions
+1 -4
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,6 +57,7 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
# Extension folder
extension/
@@ -67,10 +67,7 @@ AGENTS.md
# Temp/misc
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
```
+12 -11
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>
---
@@ -166,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) | |
---
-4
View File
@@ -36,11 +36,7 @@ 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
+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'
}
+1 -6
View File
@@ -20,10 +20,6 @@ android {
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
buildFeatures {
buildConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
@@ -123,6 +119,5 @@ dependencies {
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")
}
+1 -15
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,20 +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>
</activity>
<!-- Download Service -->
@@ -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
@@ -4,7 +4,6 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
@@ -43,9 +42,6 @@ class MainActivity: FlutterFragmentActivity() {
"com.zarz.spotiflac/library_scan_progress_stream"
private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L
private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L
private val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
@@ -54,7 +50,6 @@ class MainActivity: FlutterFragmentActivity() {
private var downloadProgressStreamJob: Job? = null
private var downloadProgressEventSink: EventChannel.EventSink? = null
private var lastDownloadProgressPayload: String? = null
private var lastDownloadProgressSeq = 0L
private var libraryScanProgressStreamJob: Job? = null
private var libraryScanProgressEventSink: EventChannel.EventSink? = null
private var lastLibraryScanProgressPayload: String? = null
@@ -303,7 +298,7 @@ class MainActivity: FlutterFragmentActivity() {
private fun mimeTypeForExt(ext: String?): String {
return when (normalizeExt(ext)) {
".m4a", ".mp4" -> "audio/mp4"
".m4a" -> "audio/mp4"
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".flac" -> "audio/flac"
@@ -313,62 +308,7 @@ class MainActivity: FlutterFragmentActivity() {
}
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()
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
}
private fun sanitizeRelativeDir(relativeDir: String): String {
@@ -428,43 +368,6 @@ class MainActivity: FlutterFragmentActivity() {
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
}
// SAF can auto-rename to "name (1)" when another writer wins the race
// between findFile() and createFile(). Prefer the exact sibling if it
// appeared, and discard the duplicate document we just 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 resetSafScanProgress() {
synchronized(safScanLock) {
safScanProgress = SafScanProgress()
@@ -531,46 +434,17 @@ class MainActivity: FlutterFragmentActivity() {
}
}
private fun bridgeJsonResult(payload: String): Any {
if (payload.toByteArray(Charsets.UTF_8).size < LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES) {
return payload
}
return try {
val file = File(cacheDir, "bridge_json_${System.nanoTime()}.json")
file.writeText(payload, Charsets.UTF_8)
mapOf(LARGE_JSON_RESULT_FILE_KEY to file.absolutePath)
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Failed to spill large bridge JSON result to file: ${e.message}",
)
payload
}
}
private fun updateDownloadProgressSeq(payload: String) {
try {
val seq = JSONObject(payload).optLong("seq", lastDownloadProgressSeq)
if (seq > lastDownloadProgressSeq) {
lastDownloadProgressSeq = seq
}
} catch (_: Exception) {}
}
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
stopDownloadProgressStream()
downloadProgressEventSink = sink
lastDownloadProgressPayload = null
lastDownloadProgressSeq = 0L
downloadProgressStreamJob = scope.launch {
while (isActive && downloadProgressEventSink === sink) {
try {
val payload = withContext(Dispatchers.IO) {
Gobackend.getAllDownloadProgressDelta(lastDownloadProgressSeq)
Gobackend.getAllDownloadProgress()
}
if (payload.isNotEmpty() && payload != lastDownloadProgressPayload) {
updateDownloadProgressSeq(payload)
if (payload != lastDownloadProgressPayload) {
lastDownloadProgressPayload = payload
sink.success(parseJsonPayload(payload))
}
@@ -590,7 +464,6 @@ class MainActivity: FlutterFragmentActivity() {
downloadProgressStreamJob = null
downloadProgressEventSink = null
lastDownloadProgressPayload = null
lastDownloadProgressSeq = 0L
}
private fun startLibraryScanProgressStream(sink: EventChannel.EventSink) {
@@ -637,17 +510,17 @@ class MainActivity: FlutterFragmentActivity() {
lastLibraryScanProgressPayload = null
}
private fun loadExistingFilesFromSnapshot(snapshotPath: String): MutableMap<String, Long> {
val result = mutableMapOf<String, Long>()
private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String {
if (snapshotPath.isBlank()) {
return result
return "{}"
}
val snapshotFile = File(snapshotPath)
if (!snapshotFile.exists()) {
return result
return "{}"
}
val result = JSONObject()
snapshotFile.forEachLine { line ->
if (line.isBlank()) return@forEachLine
val separatorIndex = line.indexOf('\t')
@@ -657,10 +530,10 @@ class MainActivity: FlutterFragmentActivity() {
val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L
val filePath = line.substring(separatorIndex + 1)
if (filePath.isNotEmpty()) {
result[filePath] = modTime
result.put(filePath, modTime)
}
}
return result
return result.toString()
}
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
@@ -724,6 +597,16 @@ class MainActivity: FlutterFragmentActivity() {
return obj.toString()
}
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return sanitizeFilename(provided)
val trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "")
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
return sanitizeFilename(baseName) + outputExt
}
private fun errorJson(message: String): String {
val obj = JSONObject()
obj.put("success", false)
@@ -771,8 +654,6 @@ class MainActivity: FlutterFragmentActivity() {
private fun extFromFileName(name: String): String {
return when {
name.endsWith(".m4a") -> ".m4a"
name.endsWith(".mp4") -> ".mp4"
name.endsWith(".aac") -> ".aac"
name.endsWith(".mp3") -> ".mp3"
name.endsWith(".opus") -> ".opus"
name.endsWith(".flac") -> ".flac"
@@ -784,10 +665,6 @@ class MainActivity: FlutterFragmentActivity() {
private fun extFromMimeType(mime: String?): String {
return when (mime) {
"audio/mp4" -> ".m4a"
"audio/aac" -> ".aac"
"audio/eac3" -> ".m4a"
"audio/ac3" -> ".m4a"
"audio/ac4" -> ".m4a"
"audio/mpeg" -> ".mp3"
"audio/ogg" -> ".opus"
"audio/flac" -> ".flac"
@@ -1010,6 +887,95 @@ class MainActivity: FlutterFragmentActivity() {
return true
}
private fun handleSafDownload(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 existingDir = findDocumentDir(treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
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(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory")
val existingFile = targetDir.findFile(fileName)
val document = existingFile ?: targetDir.createFile(mimeType, fileName)
?: return errorJson("Failed to create SAF file")
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
?: return errorJson("Failed to open SAF file")
var detachedFd: Int? = null
try {
// Prefer handing off a detached FD directly to Go.
// Some devices/providers reject re-opening /proc/self/fd/* with permission denied.
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)) {
// Extension providers write to a local temp path instead of the SAF FD.
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
!goFilePath.startsWith("/proc/self/fd/")
) {
try {
val srcFile = java.io.File(goFilePath)
if (srcFile.exists() && srcFile.length() > 0) {
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
}
srcFile.delete()
}
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}")
}
}
respObj.put("file_path", document.uri.toString())
respObj.put("file_name", document.name ?: fileName)
} else {
document.delete()
}
return respObj.toString()
} catch (e: Exception) {
document.delete()
return errorJson("SAF download failed: ${e.message}")
} finally {
// If detachFd() failed before handoff, close original ParcelFileDescriptor.
// Otherwise Go owns the detached raw FD and is responsible for closing it.
if (detachedFd == null) {
try {
pfd.close()
} catch (_: Exception) {}
}
}
}
/**
* Get the parent DocumentFile directory for a SAF document URI.
* The child URI must be a tree-based document URI (e.g. from SAF tree scan).
@@ -1068,7 +1034,7 @@ class MainActivity: FlutterFragmentActivity() {
}
private val cueSiblingAudioExtensions = listOf(
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
)
private fun getSafChildFileLookup(
@@ -1140,7 +1106,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
@@ -1398,22 +1364,6 @@ class MainActivity: FlutterFragmentActivity() {
* @return JSON object with new/changed files and removed URIs
*/
private fun scanSafTreeIncremental(treeUriStr: String, existingFilesJson: String): String {
val existingFiles = mutableMapOf<String, Long>()
try {
val obj = JSONObject(existingFilesJson)
val keys = obj.keys()
while (keys.hasNext()) {
val key = keys.next()
existingFiles[key] = obj.optLong(key, 0)
}
} catch (_: Exception) {}
return scanSafTreeIncremental(treeUriStr, existingFiles)
}
private fun scanSafTreeIncremental(
treeUriStr: String,
existingFiles: Map<String, Long>,
): String {
if (treeUriStr.isBlank()) {
val result = JSONObject()
result.put("files", JSONArray())
@@ -1433,6 +1383,16 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
val existingFiles = mutableMapOf<String, Long>()
try {
val obj = JSONObject(existingFilesJson)
val keys = obj.keys()
while (keys.hasNext()) {
val key = keys.next()
existingFiles[key] = obj.optLong(key, 0)
}
} catch (_: Exception) {}
resetSafScanProgress()
safScanCancel = false
safScanActive = true
@@ -1440,7 +1400,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
@@ -1971,54 +1931,9 @@ class MainActivity: FlutterFragmentActivity() {
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
override fun shouldHandleDeeplinking(): Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleExtensionOAuthIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleExtensionOAuthIntent(intent)
}
/**
* Deliver Spotify (or other) OAuth authorization code to the extension runtime
* and run its token exchange (e.g. completeSpotifyLogin). State must be the extension id.
*/
private fun handleExtensionOAuthIntent(intent: Intent?) {
val uri = intent?.data ?: return
if (!uri.scheme.equals("spotiflac", ignoreCase = true)) {
return
}
val host = (uri.host ?: "").lowercase(Locale.US)
val path = (uri.path ?: "").lowercase(Locale.US)
val isCallback =
host == "callback" ||
host == "spotify-callback" ||
path.contains("callback")
if (!isCallback) {
return
}
val code = uri.getQueryParameter("code")?.trim().orEmpty()
if (code.isEmpty()) {
return
}
val extId = uri.getQueryParameter("state")?.trim().orEmpty()
if (extId.isEmpty()) {
android.util.Log.w("SpotiFLAC", "Extension OAuth redirect missing state (extension id)")
return
}
intent.data = null
scope.launch(Dispatchers.IO) {
try {
Gobackend.setExtensionAuthCodeByID(extId, code)
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
}
}
}
override fun onDestroy() {
@@ -2034,7 +1949,6 @@ class MainActivity: FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Gobackend.setAppVersion(BuildConfig.VERSION_NAME)
// Always-enabled back callback to ensure back presses reach Flutter.
// Nested tab navigators can incorrectly set frameworkHandlesBack(false),
@@ -2108,7 +2022,7 @@ class MainActivity: FlutterFragmentActivity() {
"downloadByStrategy" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
SafDownloadHandler.handle(this@MainActivity, requestJson) { json ->
handleSafDownload(requestJson) { json ->
Gobackend.downloadByStrategy(json)
}
}
@@ -2219,6 +2133,7 @@ class MainActivity: FlutterFragmentActivity() {
result.error("saf_pending", "SAF picker already active", null)
return@launch
}
pendingSafTreeResult = result
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
@@ -2226,24 +2141,7 @@ class MainActivity: FlutterFragmentActivity() {
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
)
val resolver = intent.resolveActivity(packageManager)
if (resolver == null) {
result.error("saf_unavailable", "No folder picker available on this device", null)
return@launch
}
pendingSafTreeResult = result
try {
android.util.Log.i("SpotiFLAC", "Launching SAF picker via $resolver")
safTreeLauncher.launch(intent)
} catch (e: Exception) {
pendingSafTreeResult = null
android.util.Log.e("SpotiFLAC", "Failed to launch SAF picker: ${e.message}", e)
result.error(
"saf_launch_failed",
e.message ?: "Failed to launch folder picker",
null
)
}
safTreeLauncher.launch(intent)
}
"safExists" -> {
val uriStr = call.argument<String>("uri") ?: ""
@@ -2318,8 +2216,7 @@ class MainActivity: FlutterFragmentActivity() {
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
val existing = dir.findFile(fileName)
val createdNew = existing == null
val doc = createOrReuseDocumentFile(dir, mimeType, fileName)
?: return@withContext null
val doc = existing ?: dir.createFile(mimeType, fileName) ?: return@withContext null
if (!writeUriFromPath(doc.uri, srcPath)) {
if (createdNew) {
doc.delete()
@@ -2789,47 +2686,15 @@ class MainActivity: FlutterFragmentActivity() {
"updateDownloadServiceProgress" -> {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val progress = (call.argument<Number>("progress") ?: 0).toLong()
val total = (call.argument<Number>("total") ?: 0).toLong()
val queueCount = (call.argument<Number>("queue_count") ?: 0).toInt()
val status = call.argument<String>("status") ?: "downloading"
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount, status)
val progress = call.argument<Long>("progress") ?: 0L
val total = call.argument<Long>("total") ?: 0L
val queueCount = call.argument<Int>("queue_count") ?: 0
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount)
result.success(null)
}
"isDownloadServiceRunning" -> {
result.success(DownloadService.isServiceRunning())
}
"startNativeDownloadWorker" -> {
val requestsJson = call.argument<String>("requests_json") ?: "[]"
val settingsJson = call.argument<String>("settings_json") ?: "{}"
val requestsPath = call.argument<String>("requests_path") ?: ""
val settingsPath = call.argument<String>("settings_path") ?: ""
if (requestsPath.isNotBlank()) {
DownloadService.startNativeQueueFromFiles(
this@MainActivity,
requestsPath,
settingsPath
)
} else {
DownloadService.startNativeQueue(this@MainActivity, requestsJson, settingsJson)
}
result.success(null)
}
"pauseNativeDownloadWorker" -> {
DownloadService.pauseNativeQueue(this@MainActivity)
result.success(null)
}
"resumeNativeDownloadWorker" -> {
DownloadService.resumeNativeQueue(this@MainActivity)
result.success(null)
}
"cancelNativeDownloadWorker" -> {
DownloadService.cancelNativeQueue(this@MainActivity)
result.success(null)
}
"getNativeDownloadWorkerSnapshot" -> {
result.success(parseJsonPayload(DownloadService.getNativeWorkerSnapshot(this@MainActivity)))
}
"preWarmTrackCache" -> {
val tracksJson = call.argument<String>("tracks") ?: "[]"
withContext(Dispatchers.IO) {
@@ -2849,6 +2714,36 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"searchDeezerAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"searchTidalAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"searchQobuzAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"getDeezerRelatedArtists" -> {
val artistId = call.argument<String>("artist_id") ?: ""
val limit = call.argument<Int>("limit") ?: 12
@@ -2857,20 +2752,62 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getProviderMetadata" -> {
val providerId = call.argument<String>("provider_id") ?: ""
"getDeezerMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getProviderMetadataJSON(providerId, resourceType, resourceId)
Gobackend.getDeezerMetadata(resourceType, resourceId)
}
result.success(response)
}
"getQobuzMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getQobuzMetadata(resourceType, resourceId)
}
result.success(response)
}
"getTidalMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getTidalMetadata(resourceType, resourceId)
}
result.success(response)
}
"parseDeezerUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseDeezerURLExport(url)
}
result.success(response)
}
"parseQobuzUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseQobuzURLExport(url)
}
result.success(response)
}
"parseTidalUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseTidalURLExport(url)
}
result.success(response)
}
"convertTidalToSpotifyDeezer" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.convertTidalToSpotifyDeezer(url)
}
result.success(response)
}
"searchDeezerByISRC" -> {
val isrc = call.argument<String>("isrc") ?: ""
val itemId = call.argument<String>("item_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerByISRCForItemID(isrc, itemId)
Gobackend.searchDeezerByISRC(isrc)
}
result.success(response)
}
@@ -3028,13 +2965,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"setDownloadFallbackExtensionIds" -> {
val extensionIdsJson = call.argument<String>("extension_ids") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setExtensionFallbackProviderIDsJSON(extensionIdsJson)
}
result.success(null)
}
"setMetadataProviderPriority" -> {
val priorityJson = call.argument<String>("priority") ?: "[]"
withContext(Dispatchers.IO) {
@@ -3055,13 +2985,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"checkExtensionHealth" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkExtensionHealthJSON(extensionId)
}
result.success(response)
}
"setExtensionSettings" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val settingsJson = call.argument<String>("settings") ?: "{}"
@@ -3189,19 +3112,11 @@ class MainActivity: FlutterFragmentActivity() {
val extensionId = call.argument<String>("extension_id") ?: ""
val query = call.argument<String>("query") ?: ""
val optionsJson = call.argument<String>("options") ?: ""
val requestId = call.argument<String>("request_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.customSearchWithExtensionJSONWithRequestID(extensionId, query, optionsJson, requestId)
Gobackend.customSearchWithExtensionJSON(extensionId, query, optionsJson)
}
result.success(response)
}
"cancelExtensionRequest" -> {
val requestId = call.argument<String>("request_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.cancelExtensionRequestJSON(requestId)
}
result.success(null)
}
"getSearchProviders" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getSearchProvidersJSON()
@@ -3228,6 +3143,30 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getAlbumWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val albumId = call.argument<String>("album_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAlbumWithExtensionJSON(extensionId, albumId)
}
result.success(response)
}
"getPlaylistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val playlistId = call.argument<String>("playlist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId)
}
result.success(response)
}
"getArtistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val artistId = call.argument<String>("artist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getArtistWithExtensionJSON(extensionId, artistId)
}
result.success(response)
}
"runPostProcessing" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata") ?: ""
@@ -3334,9 +3273,8 @@ class MainActivity: FlutterFragmentActivity() {
}
"getExtensionHomeFeed" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val requestId = call.argument<String>("request_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionHomeFeedJSONWithRequestID(extensionId, requestId)
Gobackend.getExtensionHomeFeedJSON(extensionId)
}
result.success(response)
}
@@ -3358,7 +3296,7 @@ class MainActivity: FlutterFragmentActivity() {
val folderPath = call.argument<String>("folder_path") ?: ""
val response = withContext(Dispatchers.IO) {
safScanActive = false
bridgeJsonResult(Gobackend.scanLibraryFolderJSON(folderPath))
Gobackend.scanLibraryFolderJSON(folderPath)
}
result.success(response)
}
@@ -3367,9 +3305,7 @@ class MainActivity: FlutterFragmentActivity() {
val existingFiles = call.argument<String>("existing_files") ?: "{}"
val response = withContext(Dispatchers.IO) {
safScanActive = false
bridgeJsonResult(
Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles)
)
Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles)
}
result.success(response)
}
@@ -3378,11 +3314,9 @@ class MainActivity: FlutterFragmentActivity() {
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
val response = withContext(Dispatchers.IO) {
safScanActive = false
bridgeJsonResult(
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
folderPath,
snapshotPath,
)
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
folderPath,
snapshotPath,
)
}
result.success(response)
@@ -3390,7 +3324,7 @@ class MainActivity: FlutterFragmentActivity() {
"scanSafTree" -> {
val treeUri = call.argument<String>("tree_uri") ?: ""
val response = withContext(Dispatchers.IO) {
bridgeJsonResult(scanSafTree(treeUri))
scanSafTree(treeUri)
}
result.success(response)
}
@@ -3398,7 +3332,7 @@ class MainActivity: FlutterFragmentActivity() {
val treeUri = call.argument<String>("tree_uri") ?: ""
val existingFiles = call.argument<String>("existing_files") ?: "{}"
val response = withContext(Dispatchers.IO) {
bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles))
scanSafTreeIncremental(treeUri, existingFiles)
}
result.success(response)
}
@@ -3406,9 +3340,9 @@ class MainActivity: FlutterFragmentActivity() {
val treeUri = call.argument<String>("tree_uri") ?: ""
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
val response = withContext(Dispatchers.IO) {
val existingFiles =
loadExistingFilesFromSnapshot(snapshotPath)
bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles))
val existingFilesJson =
loadExistingFilesJsonFromSnapshot(snapshotPath)
scanSafTreeIncremental(treeUri, existingFilesJson)
}
result.success(response)
}
@@ -3480,7 +3414,7 @@ class MainActivity: FlutterFragmentActivity() {
} catch (_: Exception) { "" }
val cueBaseName = cueName.substringBeforeLast('.')
if (cueBaseName.isNotBlank()) {
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac")
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
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 -1
View File
@@ -1,2 +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
+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
+1 -1
View File
@@ -20,7 +20,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" 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.5.5",
"versionDate": "2026-05-14",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-ios-unsigned.ipa",
"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": 37191956
"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

-5
View File
@@ -6,7 +6,6 @@ files:
# Short codes for single-variant languages
de: de
es: es
es-ES: es_ES
fr: fr
hi: hi
id: id
@@ -14,11 +13,7 @@ files:
ko: ko
nl: nl
pt: pt
pt-PT: pt_PT
ru: ru
tr: tr
uk: uk
zh: zh
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
+4
View File
@@ -56,6 +56,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 +255,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 {
@@ -509,6 +511,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{}{}
@@ -536,6 +539,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")
}
}
@@ -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)
}
}
}
-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")
}
}
+446
View File
@@ -0,0 +1,446 @@
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,
TotalDiscs: req.TotalDiscs,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Composer: req.Composer,
}
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()
}
+926 -956
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)
}
}
-420
View File
@@ -1,420 +0,0 @@
package gobackend
import (
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
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"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
}
if _, err := buildStoreExtensionDestPath(dir, " "); 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 -262
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",
@@ -424,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"])
@@ -452,35 +225,10 @@ 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,
@@ -494,9 +242,6 @@ func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
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)
}
-332
View File
@@ -1,332 +0,0 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
)
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"`
}
func CheckExtensionHealthJSON(extensionID string) (string, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
result := CheckExtensionHealth(ext)
bytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(bytes), nil
}
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 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 {
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 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":
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)
if statusCode, ok := healthNumber(rawStatus); ok {
if statusCode >= 200 && statusCode < 300 {
return "online", strings.Join(messageParts, ": "), true
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return "degraded", strings.Join(messageParts, ": "), true
}
if statusCode == http.StatusInternalServerError && hasOK && okValue {
return "online", strings.Join(messageParts, ": "), true
}
return "offline", strings.Join(messageParts, ": "), true
}
if isExtensionHealthAuthRequired(detail) {
return "degraded", strings.Join(messageParts, ": "), true
}
if hasOK {
if okValue {
return "online", strings.Join(messageParts, ": "), true
}
return "offline", strings.Join(messageParts, ": "), true
}
if !hasStatus {
return "unknown", strings.Join(messageParts, ": "), true
}
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
switch statusString {
case "ok", "up", "online", "healthy", "operational":
return "online", strings.Join(messageParts, ": "), true
case "degraded", "partial", "warning", "warn":
return "degraded", strings.Join(messageParts, ": "), true
case "down", "offline", "error", "failed", "fail", "unhealthy":
return "offline", strings.Join(messageParts, ": "), true
default:
return "unknown", strings.Join(messageParts, ": "), 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 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,143 +0,0 @@
package gobackend
import (
"encoding/json"
"io"
"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 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")
}
}
+74 -182
View File
@@ -10,7 +10,6 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
@@ -157,12 +156,12 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
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()
@@ -187,16 +186,16 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
}
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()
@@ -214,9 +213,9 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
if versionCompare > 0 {
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)
}
}
@@ -224,7 +223,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
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)
@@ -343,90 +342,23 @@ func initializeVMLocked(ext *loadedExtension) error {
return nil
}
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
vm := goja.New()
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
}
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.RunString(string(jsCode)); 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 {
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(`
@@ -444,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
}
@@ -458,29 +392,14 @@ 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
@@ -488,56 +407,45 @@ func initializeExtensionWithSettingsLocked(
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) {
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
@@ -570,7 +478,7 @@ func (m *extensionManager) UnloadExtension(extensionID string) error {
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("extension not found")
return fmt.Errorf("Extension not found")
}
ext.VMMu.Lock()
@@ -589,7 +497,7 @@ func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, e
ext, exists := m.extensions[extensionID]
if !exists {
return nil, fmt.Errorf("extension not found")
return nil, fmt.Errorf("Extension not found")
}
return ext, nil
}
@@ -611,7 +519,7 @@ func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool)
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("extension not found")
return fmt.Errorf("Extension not found")
}
if enabled {
@@ -689,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 {
@@ -757,12 +665,12 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
// Only allows upgrades (new version > current version), not downgrades
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
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()
@@ -787,16 +695,16 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
}
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()
@@ -804,15 +712,15 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
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)
@@ -906,13 +814,13 @@ type ExtensionUpgradeInfo struct {
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
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()
@@ -939,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()
@@ -985,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"`
@@ -1000,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"`
}
@@ -1044,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,
@@ -1059,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,
}
}
@@ -1082,7 +988,7 @@ func (m *extensionManager) InitializeExtension(extensionID string, settings map[
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("extension not found")
return fmt.Errorf("Extension not found")
}
ext.VMMu.Lock()
@@ -1100,7 +1006,7 @@ func (m *extensionManager) CleanupExtension(extensionID string) error {
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("extension not found")
return fmt.Errorf("Extension not found")
}
if ext.VM == nil {
@@ -1149,29 +1055,15 @@ 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.
script := fmt.Sprintf(`
(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' };
}
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;
}
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
@@ -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")
}
}
+8 -46
View File
@@ -25,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 {
@@ -102,21 +101,11 @@ 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 ExtensionManifest struct {
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"`
@@ -127,13 +116,11 @@ type ExtensionManifest struct {
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
@@ -168,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"}
}
@@ -216,28 +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",
}
}
}
return nil
}
@@ -262,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
+38 -599
View File
@@ -1,87 +1,14 @@
package gobackend
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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)
}
@@ -92,538 +19,50 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
}
}
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 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"})
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)
}
}
+6 -109
View File
@@ -5,7 +5,6 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -94,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
@@ -140,60 +136,12 @@ 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) {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
@@ -212,59 +160,18 @@ func (r *extensionRuntime) getActiveDownloadItemID() string {
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")
}
@@ -470,9 +377,7 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
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)
@@ -502,16 +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("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()
+1 -2
View File
@@ -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 {
-360
View File
@@ -1,360 +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 []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(),
})
}
if parsedOptions.Mode != "cbc" {
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() {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
})
}
data := inputData
if !decrypt && parsedOptions.Padding == "pkcs7" {
data = applyPKCS7Padding(data, block.BlockSize())
}
if len(data)%block.BlockSize() != 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
})
}
output := make([]byte, len(data))
if decrypt {
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
if parsedOptions.Padding == "pkcs7" {
output, err = removePKCS7Padding(output, block.BlockSize())
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
}
} 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)
}
-185
View File
@@ -1,185 +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())
}
}
-1
View File
@@ -131,7 +131,6 @@ 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,
})
}
+10 -499
View File
@@ -8,7 +8,6 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
@@ -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,239 +271,6 @@ 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 != "" {
// Format: "bytes 0-1/12345"
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
}
// Non-success status
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),
})
}
// Read chunk body and write to file
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 {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
@@ -636,104 +346,6 @@ 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),
})
}
}
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 {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -774,107 +386,6 @@ 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 {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
+5 -28
View File
@@ -17,24 +17,6 @@ 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 {
parsed, err := url.Parse(urlStr)
if err != nil {
@@ -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 {
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
+1 -2
View File
@@ -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")
+10
View File
@@ -340,6 +340,16 @@ func (r *extensionRuntime) ensureCredentialsLoaded() error {
return nil
}
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 {
@@ -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)
}
}
-167
View File
@@ -249,96 +249,6 @@ 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 {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
@@ -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,
})
})
+7 -19
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)
@@ -355,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)
}
@@ -494,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) {
+6 -194
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,6 +98,7 @@ func TestIsDomainAllowed(t *testing.T) {
}
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
// Create a mock extension with limited network permissions
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
@@ -144,15 +127,6 @@ 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) {
@@ -261,176 +235,14 @@ 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) {
// Create extension with limited network permissions
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
+3 -25
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 {
@@ -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
}
@@ -106,9 +91,6 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
// 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
}
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()
+5 -49
View File
@@ -6,8 +6,6 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
var (
@@ -19,66 +17,24 @@ var (
)
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}"
+1 -30
View File
@@ -1,10 +1,6 @@
package gobackend
import (
"strings"
"testing"
"unicode/utf8"
)
import "testing"
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
metadata := map[string]interface{}{
@@ -87,28 +83,3 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestSanitizeFilenameMatchesDesktopSpacingBehavior(t *testing.T) {
got := sanitizeFilename(` "Text In Quotes"?%* / Demo `)
want := "Text In Quotes % Demo"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
got := sanitizeFilename(`<>:"/\|?*`)
if got != "Unknown" {
t.Fatalf("expected %q, got %q", "Unknown", got)
}
}
func TestSanitizeFilenameTruncatesWithoutSplittingUTF8(t *testing.T) {
got := sanitizeFilename(strings.Repeat("あ", 80))
if !utf8.ValidString(got) {
t.Fatalf("sanitizeFilename returned invalid UTF-8: %q", got)
}
if len(got) > 200 {
t.Fatalf("sanitizeFilename length = %d, want <= 200", len(got))
}
}
+8 -8
View File
@@ -2,7 +2,7 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0
toolchain go1.25.9
toolchain go1.25.8
require (
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
@@ -10,10 +10,9 @@ require (
github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2
golang.org/x/crypto v0.50.0
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b
golang.org/x/net v0.53.0
golang.org/x/text v0.36.0
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60
golang.org/x/net v0.52.0
golang.org/x/text v0.35.0
)
require (
@@ -22,8 +21,9 @@ require (
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/klauspost/compress v1.18.5 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/tools v0.43.0 // indirect
)
+14 -14
View File
@@ -30,22 +30,22 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b h1:Qt2eaXcZ8x20iAcoZ6AceeMMtnjuPHvC51KRCH1DKSQ=
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b/go.mod h1:5Fu78lew5ucMXt8w2KYcwvxu2rkC/liHzUvaoiI+H/M=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+2 -35
View File
@@ -16,19 +16,6 @@ import (
"time"
)
func userAgentForURL(u *url.URL) string {
if u == nil {
return getRandomUserAgent()
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
if host == "api.zarz.moe" {
return appUserAgent()
}
return getRandomUserAgent()
}
func getRandomUserAgent() string {
chromeVersion := rand.Intn(26) + 120
chromeBuild := rand.Intn(1500) + 6000
@@ -79,24 +66,6 @@ var sharedTransport = &http.Transport{
DisableCompression: true,
}
var extensionAPITransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: false,
}
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -149,7 +118,6 @@ func GetDownloadClient() *http.Client {
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
extensionAPITransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
}
@@ -162,7 +130,6 @@ func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
networkCompatibilityMu.Unlock()
applyTLSCompatibility(sharedTransport, insecureTLS)
applyTLSCompatibility(extensionAPITransport, insecureTLS)
applyTLSCompatibility(metadataTransport, insecureTLS)
CloseIdleConnections()
@@ -258,7 +225,7 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
}
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
@@ -288,7 +255,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(reqCopy)
if err != nil {
+1 -1
View File
@@ -11,7 +11,7 @@ func GetCloudflareBypassClient() *http.Client {
}
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
-158
View File
@@ -1,158 +0,0 @@
package gobackend
import (
"errors"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
)
func TestHTTPUtilityHelpers(t *testing.T) {
SetAppVersion("7.0.0")
apiURL := mustParseURL(t, "https://api.zarz.moe/test")
if ua := userAgentForURL(apiURL); !strings.Contains(ua, "7.0.0") {
t.Fatalf("api user agent = %q", ua)
}
if userAgentForURL(nil) == "" || userAgentForURL(mustParseURL(t, "https://example.com")) == "" {
t.Fatal("expected fallback user agent")
}
if NewHTTPClientWithTimeout(time.Second).Timeout != time.Second || NewMetadataHTTPClient(time.Second).Timeout != time.Second {
t.Fatal("client timeout mismatch")
}
if GetSharedClient() == nil || GetDownloadClient() == nil {
t.Fatal("expected shared clients")
}
SetNetworkCompatibilityOptions(true, true)
if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS {
t.Fatalf("network opts = %#v", opts)
}
SetNetworkCompatibilityOptions(false, false)
if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) {
t.Fatal("GET should fallback")
}
if canFallbackToHTTP(&http.Request{Method: http.MethodPost}) {
t.Fatal("POST without GetBody should not fallback")
}
req, _ := http.NewRequest(http.MethodPost, "https://example.com/path", strings.NewReader("body"))
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("body")), nil }
cloned, err := cloneRequestWithHTTPScheme(req, "http")
if err != nil || cloned.URL.Scheme != "http" || cloned.Body == nil {
t.Fatalf("cloneRequestWithHTTPScheme = %#v/%v", cloned, err)
}
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("User-Agent") == "" {
t.Fatal("missing User-Agent")
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("ok")), Request: req}, nil
})}
resp, err := DoRequestWithUserAgent(client, mustNewRequest(t, "https://example.com/ok"))
if err != nil || resp.StatusCode != 200 {
t.Fatalf("DoRequestWithUserAgent = %#v/%v", resp, err)
}
resp.Body.Close()
attempts := 0
retryClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
attempts++
switch attempts {
case 1:
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("server")), Request: req}, nil
case 2:
return &http.Response{StatusCode: 429, Header: http.Header{"Retry-After": []string{"0"}}, Body: io.NopCloser(strings.NewReader("rate")), Request: req}, nil
default:
return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
}
})}
resp, err = DoRequestWithRetry(retryClient, mustNewRequest(t, "https://example.com/retry"), RetryConfig{MaxRetries: 3, InitialDelay: 0, MaxDelay: time.Millisecond, BackoffFactor: 2})
if err != nil || resp.StatusCode != 204 || attempts != 3 {
t.Fatalf("DoRequestWithRetry = %#v/%v attempts=%d", resp, err, attempts)
}
resp.Body.Close()
blockingClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 403, Body: io.NopCloser(strings.NewReader("access denied by region")), Request: req}, nil
})}
if _, err := DoRequestWithRetry(blockingClient, mustNewRequest(t, "https://blocked.example.com"), RetryConfig{MaxRetries: 0}); err == nil {
t.Fatal("expected blocking retry error")
}
if _, err := ReadResponseBody(nil); err == nil {
t.Fatal("expected nil response body error")
}
if _, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader(""))}); err == nil {
t.Fatal("expected empty response body error")
}
if body, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader("ok"))}); err != nil || string(body) != "ok" {
t.Fatalf("ReadResponseBody = %q/%v", body, err)
}
if err := ValidateResponse(nil); err == nil {
t.Fatal("expected nil response validation error")
}
if err := ValidateResponse(&http.Response{StatusCode: 404, Status: "404 Not Found"}); err == nil {
t.Fatal("expected bad status validation error")
}
if err := ValidateResponse(&http.Response{StatusCode: 200}); err != nil {
t.Fatalf("ValidateResponse: %v", err)
}
if msg := BuildErrorMessage("api", 500, strings.Repeat("x", 120)); !strings.Contains(msg, "...") {
t.Fatalf("BuildErrorMessage = %q", msg)
}
if calculateNextDelay(10*time.Millisecond, RetryConfig{BackoffFactor: 3, MaxDelay: 20 * time.Millisecond}) != 20*time.Millisecond {
t.Fatal("calculateNextDelay mismatch")
}
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
t.Fatal("invalid retry-after should be zero")
}
if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
t.Fatalf("IsISPBlocking = %#v", isp)
}
if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") {
t.Fatal("expected logged ISP blocking")
}
if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
}
if WrapErrorWithISPCheck(nil, "", "test") != nil {
t.Fatal("nil wrap should stay nil")
}
if extractDomain("https://example.com/path") != "example.com" || extractDomain("bad://") != "unknown" || extractDomain("") != "unknown" {
t.Fatal("extractDomain mismatch")
}
}
func TestRateLimiterHelpers(t *testing.T) {
limiter := NewRateLimiter(1, time.Hour)
if limiter.Available() != 1 {
t.Fatalf("available = %d", limiter.Available())
}
if !limiter.TryAcquire() || limiter.TryAcquire() {
t.Fatal("TryAcquire mismatch")
}
if limiter.Available() != 0 {
t.Fatalf("available after acquire = %d", limiter.Available())
}
if GetSongLinkRateLimiter() == nil {
t.Fatal("expected global limiter")
}
}
func mustNewRequest(t *testing.T, rawURL string) *http.Request {
t.Helper()
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
t.Fatal(err)
}
return req
}
func mustParseURL(t *testing.T, rawURL string) *url.URL {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
return parsed
}
+8 -4
View File
@@ -10,13 +10,16 @@ import (
"net/http"
"net/url"
"strings"
"sync"
utls "github.com/refraction-networking/utls"
"golang.org/x/net/http2"
)
type utlsTransport struct {
dialer *net.Dialer
dialer *net.Dialer
mu sync.Mutex
h2Transports map[string]*http2.Transport
}
func newUTLSTransport() *utlsTransport {
@@ -25,6 +28,7 @@ func newUTLSTransport() *utlsTransport {
Timeout: 30 * Second,
KeepAlive: 30 * Second,
},
h2Transports: make(map[string]*http2.Transport),
}
}
@@ -97,7 +101,7 @@ func GetCloudflareBypassClient() *http.Client {
}
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req)
if err == nil {
@@ -125,7 +129,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
return cloudflareBypassClient.Do(reqCopy)
}
@@ -151,7 +155,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
return cloudflareBypassClient.Do(reqCopy)
}
+12 -68
View File
@@ -68,8 +68,6 @@ var (
var supportedAudioFormats = map[string]bool{
".flac": true,
".m4a": true,
".mp4": true,
".aac": true,
".mp3": true,
".opus": true,
".ogg": true,
@@ -89,23 +87,10 @@ type scannedCueFileInfo struct {
audioPath string
}
func isLibraryStagingFile(path string) bool {
name := strings.ToLower(filepath.Base(path))
if strings.HasSuffix(name, ".partial") {
return true
}
for ext := range supportedAudioFormats {
if strings.HasSuffix(name, ".partial"+ext) {
return true
}
}
return false
}
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
var files []libraryAudioFileInfo
err := filepath.WalkDir(folderPath, func(path string, entry os.DirEntry, err error) error {
err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
@@ -116,10 +101,7 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
default:
}
if entry.IsDir() {
return nil
}
if isLibraryStagingFile(path) {
if info.IsDir() {
return nil
}
@@ -128,11 +110,6 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
return nil
}
info, err := entry.Info()
if err != nil {
return nil
}
files = append(files, libraryAudioFileInfo{
path: path,
modTime: info.ModTime().UnixMilli(),
@@ -294,10 +271,18 @@ func ScanLibraryFolder(folderPath string) (string, error) {
return string(jsonBytes), nil
}
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
}
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
}
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
}
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
ext := resolveLibraryAudioExt(filePath, displayNameHint)
@@ -332,7 +317,7 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
switch ext {
case ".flac":
return scanFLACFile(filePath, result, displayNameHint)
case ".m4a", ".mp4", ".aac":
case ".m4a":
return scanM4AFile(filePath, result, displayNameHint)
case ".mp3":
return scanMP3File(filePath, result, displayNameHint)
@@ -412,6 +397,7 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
metadata, err := ReadM4ATags(filePath)
if err != nil {
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
if metadata != nil {
@@ -438,54 +424,12 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate
}
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
result.Format = format
if isLosslessLibraryFormat(format) {
result.Bitrate = 0
}
}
}
if metadata == nil {
return scanFromFilename(filePath, displayNameHint, result)
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func libraryFormatForM4ACodec(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "flac":
return "flac"
case "alac":
return "alac"
case "eac3", "ec-3":
return "eac3"
case "ac3", "ac-3":
return "ac3"
case "ac4", "ac-4":
return "ac4"
case "aac", "mp4a":
return "m4a"
default:
return ""
}
}
func isLosslessLibraryFormat(format string) bool {
switch strings.ToLower(strings.TrimSpace(format)) {
case "flac", "alac":
return true
default:
return false
}
}
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
-163
View File
@@ -1,163 +0,0 @@
package gobackend
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
dir := t.TempDir()
albumDir := filepath.Join(dir, "Album")
if err := os.MkdirAll(albumDir, 0755); err != nil {
t.Fatal(err)
}
mp3Path := filepath.Join(albumDir, "Artist - Song.mp3")
if err := os.WriteFile(mp3Path, []byte("not really mp3"), 0600); err != nil {
t.Fatal(err)
}
numberedPath := filepath.Join(albumDir, "01 - Intro.ogg")
if err := os.WriteFile(numberedPath, []byte("not really ogg"), 0600); err != nil {
t.Fatal(err)
}
apePath := filepath.Join(albumDir, "tagged.ape")
if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
if err := WriteAPETags(apePath, &APETag{Items: AudioMetadataToAPEItems(&AudioMetadata{
Title: "Tagged",
Artist: "APE Artist",
Album: "APE Album",
TrackNumber: 2,
TotalTracks: 3,
Date: "2026",
Genre: "Pop",
Composer: "Composer",
})}); err != nil {
t.Fatalf("write ape tags: %v", err)
}
cuePath, _ := writeExportCueFixture(t, albumDir)
if err := os.WriteFile(filepath.Join(albumDir, "ignored.txt"), []byte("ignore"), 0600); err != nil {
t.Fatal(err)
}
legacyPartialPath := filepath.Join(albumDir, "Artist - Song.partial.flac")
if err := os.WriteFile(legacyPartialPath, []byte("partial flac"), 0600); err != nil {
t.Fatal(err)
}
newPartialPath := filepath.Join(albumDir, "Artist - Song.flac.partial")
if err := os.WriteFile(newPartialPath, []byte("partial flac"), 0600); err != nil {
t.Fatal(err)
}
files, err := collectLibraryAudioFiles(dir, make(chan struct{}))
if err != nil {
t.Fatalf("collectLibraryAudioFiles: %v", err)
}
if len(files) < 4 {
t.Fatalf("files = %#v", files)
}
for _, file := range files {
if file.path == legacyPartialPath || file.path == newPartialPath {
t.Fatalf("staging file should be ignored: %#v", files)
}
}
cancelCh := make(chan struct{})
close(cancelCh)
if _, err := collectLibraryAudioFiles(dir, cancelCh); err == nil {
t.Fatal("expected cancelled collect")
}
jsonText, err := ScanLibraryFolder(dir)
if err != nil {
t.Fatalf("ScanLibraryFolder: %v", err)
}
var results []LibraryScanResult
if err := json.Unmarshal([]byte(jsonText), &results); err != nil {
t.Fatalf("decode scan results: %v", err)
}
if len(results) < 4 {
t.Fatalf("scan results = %#v", results)
}
foundTagged := false
for _, result := range results {
if result.FilePath == apePath {
foundTagged = result.TrackName == "Tagged" && result.ArtistName == "APE Artist"
}
}
if !foundTagged {
t.Fatalf("tagged APE not found in %#v", results)
}
if progress := GetLibraryScanProgress(); !strings.Contains(progress, `"IsComplete":true`) && !strings.Contains(progress, `"is_complete":true`) {
t.Fatalf("progress = %s", progress)
}
metaJSON, err := ReadAudioMetadataWithDisplayName(mp3Path, "Display Artist - Display Song.mp3")
if err != nil {
t.Fatalf("ReadAudioMetadataWithDisplayName: %v", err)
}
if !strings.Contains(metaJSON, "Display Song") {
t.Fatalf("metadata json = %s", metaJSON)
}
noExtPath := filepath.Join(albumDir, "noext")
if err := os.WriteFile(noExtPath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
noExtJSON, err := ReadAudioMetadataWithDisplayNameAndCoverCacheKey(noExtPath, "Artist - No Ext.mp3", "cache-key")
if err != nil {
t.Fatalf("ReadAudioMetadataWithDisplayNameAndCoverCacheKey: %v", err)
}
if !strings.Contains(noExtJSON, "No Ext") {
t.Fatalf("no ext metadata = %s", noExtJSON)
}
existing := map[string]int64{}
for _, file := range files {
existing[file.path] = file.modTime
}
if info, err := os.Stat(cuePath); err == nil {
existing[cuePath+"#track01"] = info.ModTime().UnixMilli()
}
incJSON, err := scanLibraryFolderIncrementalWithExistingFiles(dir, existing)
if err != nil {
t.Fatalf("incremental existing: %v", err)
}
var inc IncrementalScanResult
if err := json.Unmarshal([]byte(incJSON), &inc); err != nil {
t.Fatalf("decode incremental: %v", err)
}
if inc.SkippedCount == 0 {
t.Fatalf("incremental = %#v", inc)
}
if _, err := ScanLibraryFolderIncremental("", "{}"); err == nil {
t.Fatal("expected empty incremental folder error")
}
if incJSON, err := ScanLibraryFolderIncremental(dir, `not-json`); err != nil || incJSON == "" {
t.Fatalf("incremental invalid existing JSON = %q/%v", incJSON, err)
}
snapshot := filepath.Join(dir, "snapshot.txt")
if err := os.WriteFile(snapshot, []byte("bad\n123\t"+mp3Path+"\nnotint\tpath\n999\t"+filepath.Join(dir, "deleted.mp3")+"\n"), 0600); err != nil {
t.Fatal(err)
}
fromSnapshot, err := ScanLibraryFolderIncrementalFromSnapshot(dir, snapshot)
if err != nil {
t.Fatalf("snapshot incremental: %v", err)
}
if !strings.Contains(fromSnapshot, "deleted.mp3") {
t.Fatalf("snapshot result = %s", fromSnapshot)
}
if _, err := ScanLibraryFolder(""); err == nil {
t.Fatal("expected empty folder scan error")
}
fileInsteadOfFolder := filepath.Join(dir, "file.flac")
if err := os.WriteFile(fileInsteadOfFolder, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
if _, err := ScanLibraryFolder(fileInsteadOfFolder); err == nil {
t.Fatal("expected not folder error")
}
CancelLibraryScan()
SetLibraryCoverCacheDir("")
}
@@ -1,123 +0,0 @@
package gobackend
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/dop251/goja"
)
func TestLogBufferExportedHelpersAndRedaction(t *testing.T) {
ClearLogs()
SetLoggingEnabled(false)
LogInfo("test", "ignored access_token=secret")
LogError("test", "Authorization: Bearer secret-token api_key=value")
if GetLogCount() != 1 {
t.Fatalf("disabled logging should keep errors only, got %d", GetLogCount())
}
SetLoggingEnabled(true)
defer SetLoggingEnabled(false)
LogDebug("debug", "client_secret=secret")
LogWarn("warn", "warning password=secret")
GoLog("[GoTag] success token=abc")
var entries []LogEntry
if err := json.Unmarshal([]byte(GetLogs()), &entries); err != nil {
t.Fatalf("GetLogs JSON: %v", err)
}
if len(entries) < 4 {
t.Fatalf("expected log entries, got %#v", entries)
}
for _, entry := range entries {
if strings.Contains(entry.Message, "secret-token") || strings.Contains(entry.Message, "api_key=value") || strings.Contains(entry.Message, "password=secret") {
t.Fatalf("log was not redacted: %#v", entry)
}
}
sinceJSON := GetLogsSince(1)
if !strings.Contains(sinceJSON, `"next_index"`) || !strings.Contains(sinceJSON, `"logs"`) {
t.Fatalf("GetLogsSince = %q", sinceJSON)
}
if emptyJSON := GetLogsSince(999); !strings.Contains(emptyJSON, `"logs":[]`) {
t.Fatalf("GetLogsSince empty = %q", emptyJSON)
}
if negativeJSON := GetLogsSince(-5); !strings.Contains(negativeJSON, `"logs"`) {
t.Fatalf("GetLogsSince negative = %q", negativeJSON)
}
ClearLogs()
if GetLogCount() != 0 || GetLogs() != "[]" {
t.Fatalf("logs were not cleared: count=%d logs=%s", GetLogCount(), GetLogs())
}
}
func TestProgressItemHelpersAndWriter(t *testing.T) {
ClearAllItemProgress()
itemID := "progress-writer"
StartItemProgress(itemID)
SetItemBytesTotal(itemID, int64(progressUpdateThreshold*2))
SetItemBytesReceived(itemID, int64(progressUpdateThreshold))
progressJSON := GetItemProgress(itemID)
if !strings.Contains(progressJSON, `"bytes_received":131072`) || !strings.Contains(progressJSON, `"progress":0.5`) {
t.Fatalf("GetItemProgress = %q", progressJSON)
}
if missing := GetItemProgress("missing"); missing != "{}" {
t.Fatalf("missing progress = %q", missing)
}
var out bytes.Buffer
writer := NewItemProgressWriter(&out, itemID)
payload := bytes.Repeat([]byte("x"), progressUpdateThreshold+1)
n, err := writer.Write(payload)
if err != nil || n != len(payload) {
t.Fatalf("progress writer = %d/%v", n, err)
}
if out.Len() != len(payload) {
t.Fatalf("writer output length = %d", out.Len())
}
if progressJSON = GetItemProgress(itemID); !strings.Contains(progressJSON, `"bytes_received":131073`) {
t.Fatalf("progress after writer = %q", progressJSON)
}
cancelDownload(itemID)
defer clearDownloadCancel(itemID)
n, err = writer.Write([]byte("cancelled"))
if n != 0 || !errors.Is(err, ErrDownloadCancelled) {
t.Fatalf("cancelled writer = %d/%v", n, err)
}
ClearAllItemProgress()
}
func TestRunWithTimeoutBranches(t *testing.T) {
if _, err := RunWithTimeout(nil, "1 + 1", time.Millisecond); err == nil {
t.Fatal("expected nil VM error")
}
vm := goja.New()
value, err := RunWithTimeout(vm, "1 + 2", time.Second)
if err != nil || value.ToInteger() != 3 {
t.Fatalf("RunWithTimeout success = %v/%v", value, err)
}
timeoutVM := goja.New()
_, err = RunWithTimeoutAndRecover(timeoutVM, "for (;;) {}", 10*time.Millisecond)
if err == nil {
t.Fatal("expected timeout error")
}
if !IsTimeoutError(&JSExecutionError{Message: "timeout", IsTimeout: true}) {
t.Fatal("JSExecutionError should be recognized as timeout")
}
if IsTimeoutError(errors.New("plain")) {
t.Fatal("plain error should not be timeout")
}
if (&JSExecutionError{Message: "boom"}).Error() != "boom" {
t.Fatal("JSExecutionError Error mismatch")
}
}
+10 -124
View File
@@ -26,54 +26,25 @@ const (
LyricsProviderMusixmatch = "musixmatch"
LyricsProviderAppleMusic = "apple_music"
LyricsProviderQQMusic = "qqmusic"
LyricsProviderSpotify = "spotify"
LyricsProviderDeezer = "deezer"
LyricsProviderYouTube = "youtube"
LyricsProviderKugou = "kugou"
LyricsProviderGenius = "genius"
)
var DefaultLyricsProviders = []string{
LyricsProviderLRCLIB,
LyricsProviderMusixmatch,
LyricsProviderNetease,
LyricsProviderAppleMusic,
LyricsProviderQQMusic,
}
var (
lyricsProvidersMu sync.RWMutex
lyricsProviders []string // ordered list of enabled providers
appVersionMu sync.RWMutex
appVersion string
)
func SetAppVersion(version string) {
normalized := strings.TrimSpace(version)
appVersionMu.Lock()
defer appVersionMu.Unlock()
appVersion = normalized
}
func GetAppVersion() string {
appVersionMu.RLock()
defer appVersionMu.RUnlock()
return appVersion
}
func appUserAgent() string {
version := GetAppVersion()
if version == "" {
return "SpotiFLAC-Mobile"
}
return "SpotiFLAC-Mobile/" + version
}
type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
AppleElrcWordSync bool `json:"apple_elrc_word_sync"`
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
}
@@ -81,12 +52,9 @@ var defaultLyricsFetchOptions = LyricsFetchOptions{
IncludeTranslationNetease: false,
IncludeRomanizationNetease: false,
MultiPersonWordByWord: true,
AppleElrcWordSync: false,
MusixmatchLanguage: "",
}
var instrumentalTrackPattern = regexp.MustCompile(`(?i)(?:^|[\s\[(\-])(?:instrumental|inst\.?)(?:[\s\])]|$)`)
var (
lyricsFetchOptionsMu sync.RWMutex
lyricsFetchOptions = defaultLyricsFetchOptions
@@ -107,11 +75,6 @@ func SetLyricsProviderOrder(providers []string) {
LyricsProviderMusixmatch: true,
LyricsProviderAppleMusic: true,
LyricsProviderQQMusic: true,
LyricsProviderSpotify: true,
LyricsProviderDeezer: true,
LyricsProviderYouTube: true,
LyricsProviderKugou: true,
LyricsProviderGenius: true,
}
var valid []string
@@ -142,15 +105,10 @@ func GetLyricsProviderOrder() []string {
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics"},
{"id": LyricsProviderSpotify, "name": "Spotify", "has_proxy_dependency": true, "description": "Spotify synced lyrics"},
{"id": LyricsProviderDeezer, "name": "Deezer", "has_proxy_dependency": true, "description": "Deezer lyrics"},
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
}
}
@@ -168,18 +126,12 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
lyricsFetchOptionsMu.Lock()
defer lyricsFetchOptionsMu.Unlock()
changed := lyricsFetchOptions != normalized
lyricsFetchOptions = normalized
if changed {
globalLyricsCache.ClearAll()
}
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v apple_elrc=%v musixmatch_lang=%q\n",
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
normalized.IncludeTranslationNetease,
normalized.IncludeRomanizationNetease,
normalized.MultiPersonWordByWord,
normalized.AppleElrcWordSync,
normalized.MusixmatchLanguage,
)
}
@@ -433,16 +385,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
primaryArtist := normalizeArtistName(artistName)
fetchOptions := GetLyricsFetchOptions()
if isLikelyInstrumentalTrack(trackName) {
GoLog("[Lyrics] Track marked instrumental by title heuristic, skipping lyrics search: %s - %s\n", artistName, trackName)
instrumental := &LyricsResponse{
Instrumental: true,
Source: "Heuristic: Instrumental",
}
globalLyricsCache.Set(artistName, trackName, durationSec, instrumental)
return instrumental, nil
}
extManager := getExtensionManager()
var extensionProviders []*extensionProviderWrapper
if extManager != nil {
@@ -553,9 +495,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
case LyricsProviderAppleMusic:
appleClient := NewAppleMusicClient()
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
if err != nil && primaryArtist != artistName {
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
}
case LyricsProviderQQMusic:
@@ -565,53 +507,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
}
case LyricsProviderSpotify:
spotifyClient := NewSpotifyLyricsClient()
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = spotifyClient.FetchLyrics("", simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderDeezer:
deezerClient := NewDeezerLyricsClient()
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
case LyricsProviderYouTube:
youtubeClient := NewYouTubeLyricsClient()
lyrics, err = youtubeClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = youtubeClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = youtubeClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderKugou:
kugouClient := NewKugouLyricsClient()
lyrics, err = kugouClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = kugouClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = kugouClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderGenius:
geniusClient := NewGeniusLyricsClient()
lyrics, err = geniusClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = geniusClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
default:
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
continue
@@ -926,15 +821,6 @@ func normalizeArtistName(name string) string {
return strings.TrimSpace(result)
}
func isLikelyInstrumentalTrack(name string) bool {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return false
}
return instrumentalTrackPattern.MatchString(trimmed)
}
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
if lrcContent == "" {
return "", fmt.Errorf("empty LRC content")
+12 -14
View File
@@ -114,7 +114,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
@@ -147,8 +147,7 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -173,25 +172,25 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
return bodyStr, nil
}
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
var paxResp paxResponse
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
}
var directLyrics []paxLyrics
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord, preserveWordTiming), nil
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
}
return "", fmt.Errorf("failed to parse pax lyrics response")
}
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail, preserveWordTiming bool) {
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
lastStart := ""
for _, syllable := range details {
if preserveWordTiming && syllable.Timestamp != nil {
if syllable.Timestamp != nil {
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
if start != lastStart {
builder.WriteString(start)
@@ -204,13 +203,13 @@ func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail, pr
builder.WriteString(" ")
}
if preserveWordTiming && syllable.EndTime != nil {
if syllable.EndTime != nil {
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
}
}
}
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool, preserveWordTiming bool) string {
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
var sb strings.Builder
for i, line := range content {
@@ -230,11 +229,11 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
}
}
appendPaxLyricDetail(&sb, line.Text, preserveWordTiming)
appendPaxLyricDetail(&sb, line.Text)
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
sb.WriteString("\n[bg:")
appendPaxLyricDetail(&sb, line.BackgroundText, preserveWordTiming)
appendPaxLyricDetail(&sb, line.BackgroundText)
sb.WriteString("]")
}
} else {
@@ -253,7 +252,6 @@ func (c *AppleMusicClient) FetchLyrics(
artistName string,
durationSec float64,
multiPersonWordByWord bool,
preserveWordTiming bool,
) (*LyricsResponse, error) {
songID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
@@ -268,7 +266,7 @@ func (c *AppleMusicClient) FetchLyrics(
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
}
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
if err != nil {
lrcText = rawLyrics
}
+27 -1
View File
@@ -16,6 +16,32 @@ type MusixmatchClient struct {
baseURL string
}
type musixmatchSearchResponse struct {
ID int64 `json:"id"`
SongName string `json:"songName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
Artwork string `json:"artwork"`
ReleaseDate string `json:"releaseDate"`
Duration int `json:"duration"`
URL string `json:"url"`
AlbumID int64 `json:"albumId"`
HasSyncedLyrics bool `json:"hasSyncedLyrics"`
HasUnsyncedLyrics bool `json:"hasUnsyncedLyrics"`
AvailableLanguages []string `json:"availableLanguages"`
OriginalLanguage string `json:"originalLanguage"`
SyncedLyrics *musixmatchLyricsResponse `json:"syncedLyrics"`
UnsyncedLyrics *musixmatchLyricsResponse `json:"unsyncedLyrics"`
}
type musixmatchLyricsResponse struct {
ID int64 `json:"id"`
Duration int `json:"duration"`
Language string `json:"language"`
UpdatedTime string `json:"updatedTime"`
Lyrics string `json:"lyrics"`
}
func NewMusixmatchClient() *MusixmatchClient {
return &MusixmatchClient{
httpClient: NewMetadataHTTPClient(15 * time.Second),
@@ -46,7 +72,7 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+2 -2
View File
@@ -70,7 +70,7 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
for k, v := range neteaseHeaders {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -109,7 +109,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
for k, v := range neteaseHeaders {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {

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