Compare commits

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

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

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

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

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

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

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

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

Cleanup:
- Remove dead code in library_tracks_folder_screen.dart
- Fix use_build_context_synchronously in main_shell.dart
- Suppress false-positive use_null_aware_elements lints
- Update l10n label from 'Title, Artist, Album' to 'Album, Album Artist'
2026-03-31 18:21:45 +07:00
zarzet 7dba938299 fix: prefer local file for cover/lyrics save and update build dependencies
- Cover art: extract from downloaded file first, fall back to URL download
- Lyrics: check embedded lyrics/sidecar LRC before fetching online
- Add audioFilePath param to FetchAndSaveLyrics (Go, Kotlin, Swift, Dart)
- Handle SAF content:// URIs for lyrics extraction in Kotlin bridge
- Update Go 1.25.7 -> 1.25.8, Gradle 9.3.1 -> 9.4.1, Kotlin 2.2.21 -> 2.3.20
- Update NDK r27d -> r28b, Flutter FVM 3.41.4 -> 3.41.5
- Upgrade all Flutter and Go module dependencies to latest
2026-03-31 17:25:30 +07:00
zarzet 93e77aeb84 refactor: remove legacy API clients, Yoinkify fallback, and unused lyrics provider
- Delete dead metadata client and extract shared types to metadata_types.go
- Remove Yoinkify download fallback from Deezer, use MusicDL only
- Clean up retired settings fields and metadataSource
- Remove dead l10n keys for retired provider
- Add migration to strip retired provider from existing users' lyrics config
2026-03-30 23:26:37 +07:00
zarzet dd750b95ca chore: bump version to 4.1.3 (build 120) 2026-03-30 18:25:42 +07:00
zarzet e42e44f28b fix: Samsung SAF library scan, Qobuz album cover, M4A metadata save and log improvements
- Fix M4A/ALAC scan silently failing on Samsung by adding proper fallback
  to scanFromFilename when ReadM4ATags fails (consistent with MP3/FLAC/Ogg)
- Propagate displayNameHint to all format scanners so fd numbers (214, 207)
  no longer appear as track names when /proc/self/fd/ paths are used
- Cache /proc/self/fd/ readability in Kotlin to skip failed attempts after
  first failure, reducing error log noise and improving scan speed on Samsung
- Fix Qobuz download returning wrong album cover when track exists on
  multiple albums by preferring req.CoverURL over API default
- Fix FFmpeg M4A metadata save failing with 'codec not currently supported
  in container' by forcing mp4 muxer instead of ipod when cover art present
- Clean up FLAC SAF temp file after metadata write-back (was leaking)
- Update LRC lyrics tag to credit Paxsenix API
- Remove log message truncation, defer to UI preview truncation instead
2026-03-30 18:12:20 +07:00
zarzet 67daefdf60 feat: add artist tag mode setting with split Vorbis support and improve library scan progress
- Add artist_tag_mode setting (joined / split_vorbis) for FLAC/Opus multi-artist tags
- Split 'Artist A, Artist B' into separate ARTIST= Vorbis comments when split mode is enabled
- Join repeated ARTIST/ALBUMARTIST Vorbis comments when reading metadata
- Propagate artistTagMode through download pipeline, re-enrich, and metadata editor
- Improve library scan progress: separate polling intervals, finalizing state, indeterminate progress
- Add initial progress snapshot on library scan stream connect
- Use req.ArtistName consistently for Qobuz downloads instead of track.Performer.Name
- Add l10n keys for artist tag mode, library files unit, and scan finalizing status
2026-03-30 12:38:42 +07:00
zarzet fabaf0a3ff feat: add stable cover cache keys, Qobuz album-search fallback, metadata filters and extended sort options
- Introduce coverCacheKey parameter through Go backend and Kotlin bridge for stable SAF cover caching
- Add MetadataFromFilename flag to skip filename-only metadata and retry via temp-file copy
- Add Qobuz album-search fallback between API search and store scraping
- Extract buildReEnrichFFmpegMetadata to skip empty metadata fields
- Add metadata completeness filter (complete, missing year/genre/album artist)
- Add sort modes: artist, album, release date, genre (asc/desc)
- Prune stale library cover cache files after full scan
- Skip empty values and zero track/disc numbers in FFmpeg metadata
- Add new l10n keys for metadata filter and sort options
2026-03-30 11:41:11 +07:00
zarzet fb90c73f42 fix: use Tidal quality options as fallback instead of DEFAULT for extensions 2026-03-29 18:57:13 +07:00
zarzet c6cf65f075 fix: normalize DEFAULT quality to prevent Tidal/Qobuz API failures 2026-03-29 18:49:57 +07:00
zarzet 25de009ebc feat: replace batch operation snackbars with progress dialog
Add reusable BatchProgressDialog widget with circular/linear progress
indicators, cancel support, and track detail display. Uses ValueNotifier
pattern to communicate progress from caller to dialog across navigator
routes.
2026-03-29 18:04:38 +07:00
zarzet 8918d74bb5 refactor: extract and improve ReEnrich track selection with scoring-based matching 2026-03-29 17:45:51 +07:00
zarzet f9de8d45d9 fix: add attached_pic disposition to ALAC cover art embedding 2026-03-29 17:41:43 +07:00
zarzet 48eef0853d i18n: extract hardcoded strings into l10n keys
Move hardcoded UI strings across multiple screens and the notification
service into ARB-backed l10n keys so they can be translated via Crowdin.
Adds 62 new keys covering sort labels, dialog copy, metadata error
snackbars, folder-picker errors, home-tab error states, extensions home
feed selector, and all notification titles/bodies. NotificationService
now caches an AppLocalizations instance (injected from MainShell via
didChangeDependencies) and falls back to English literals when no locale
is available.
2026-03-29 17:02:12 +07:00
zarzet fc70a912bf refactor: route spotify URLs through extensions 2026-03-29 16:35:16 +07:00
zarzet cd3e5b4b28 chore: bump version to 4.1.2+119 2026-03-29 15:40:24 +07:00
zarzet 482ca82eb4 feat: improve track matching 2026-03-29 15:34:44 +07:00
zarzet 6d87ae5484 feat: add haptic feedback when swiping library tabs 2026-03-29 01:56:22 +07:00
zarzet bd3e2b999b feat: add play button to playlist/library track tiles
Show a play IconButton (matching local album style) next to the
more-options button when a track has a local file available.
Uses PlaybackController.playTrackList to resolve and open the file.
2026-03-29 01:54:27 +07:00
zarzet 186196e12b fix: use START_NOT_STICKY for DownloadService to prevent auto-restart
Prevents Android from automatically recreating the download service
after it is killed, avoiding duplicate or orphaned download processes.
2026-03-29 01:37:24 +07:00
447 changed files with 90481 additions and 219025 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.44.8"
"flutter": "3.41.5"
}
-27
View File
@@ -1,27 +0,0 @@
## Description
<!-- What does this PR do? Why is it needed? -->
## Related Issues
<!-- Link related issues, e.g. "Fixes #123" -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor (no functional change)
- [ ] Documentation
- [ ] Other (describe below)
## Checklist
- [ ] Code follows project conventions
- [ ] `flutter analyze` and `flutter test` pass
- [ ] `go vet ./...` and `go test ./...` pass in `go_backend/` (if Go code changed)
- [ ] Documentation updated (if needed)
- [ ] Commit messages follow the [conventional commits](https://www.conventionalcommits.org) format
## Screenshots
<!-- For UI changes, add before/after screenshots. Delete this section otherwise. -->
-185
View File
@@ -1,185 +0,0 @@
name: CI
on:
pull_request:
push:
branches:
- main
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
name: Detect changed paths
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
dart: ${{ steps.filter.outputs.dart }}
go: ${{ steps.filter.outputs.go }}
android: ${{ steps.filter.outputs.android }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Filter paths
uses: dorny/paths-filter@v3
id: filter
with:
filters: |
dart:
- 'lib/**'
- 'test/**'
- 'assets/**'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'l10n.yaml'
- '.github/workflows/ci.yml'
go:
- 'go_backend/**'
- '.github/workflows/ci.yml'
android:
- 'android/**'
- 'go_backend/**'
- 'lib/models/settings.dart'
- 'lib/providers/download_queue_provider*.dart'
- 'lib/services/download_request_payload.dart'
- 'lib/services/history_database.dart'
- 'lib/services/platform_bridge.dart'
- 'pubspec.yaml'
- 'pubspec.lock'
- '.github/workflows/ci.yml'
flutter:
name: Flutter analyze & test
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.dart == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Cache pub dependencies
uses: actions/cache@v5
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-${{ runner.os }}-
- name: Get Flutter dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests
run: flutter test
go:
name: Go vet & test
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.go == 'true'
defaults:
run:
working-directory: go_backend
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
# Keep in sync with release.yml
go-version: "1.26.5"
cache-dependency-path: go_backend/go.sum
- name: Check formatting
run: test -z "$(gofmt -l .)"
- name: Vet
run: go vet ./...
- name: Run tests
run: go test ./...
android:
name: Android compile & native tests
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.android == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "25"
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.26.5"
cache-dependency-path: go_backend/go.sum
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Cache Gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Install Android SDK & NDK
run: |
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
"ndk;29.0.14206865" \
"platforms;android-37" \
"build-tools;37.0.0"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
- name: Build Go backend for Android
working-directory: go_backend
run: |
go install golang.org/x/mobile/cmd/gomobile
gomobile init
mkdir -p ../android/app/libs
gomobile bind \
-target=android/arm,android/arm64 \
-androidapi 24 \
-o ../android/app/libs/gobackend.aar \
.
env:
CGO_ENABLED: 1
- name: Get Flutter dependencies
run: flutter pub get
- name: Configure Flutter SDK for Gradle
run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties
- name: Compile Kotlin and run native unit tests
run: ./android/gradlew -p android :app:compileDebugKotlin :app:testDebugUnitTest
+20 -61
View File
@@ -47,14 +47,15 @@ jobs:
steps:
- name: Free disk space
run: |
# Remove large unused tools (~15GB total). Docker prune was dropped:
# it took 1-2 minutes and the rm below already frees enough.
# Remove large unused tools (~15GB total)
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo rm -rf /usr/local/share/boost
sudo rm -rf /usr/share/swift
sudo rm -rf /usr/local/.ghcup
# Clean docker images
sudo docker image prune --all --force
# Show available space
df -h
@@ -65,12 +66,12 @@ jobs:
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "25"
java-version: "17"
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.26.5"
go-version: "1.25.8"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
@@ -83,12 +84,6 @@ jobs:
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Cache Android NDK
uses: actions/cache@v5
with:
path: /usr/local/lib/android/sdk/ndk/29.0.14206865
key: ndk-29.0.14206865
- name: Install Android SDK & NDK
run: |
# Use pre-installed Android SDK on GitHub runners
@@ -99,29 +94,22 @@ jobs:
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
# Install NDK r29 (supports 16KB page size for Android 15+)
# Keep the installed platform aligned with compileSdk/targetSdk.
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37" "build-tools;37.0.0"
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
# Set NDK path
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
- name: Install gomobile
working-directory: go_backend
run: |
# Installed from inside the module so the go.mod-pinned x/mobile
# version is used (reproducible + covered by the setup-go cache).
go install golang.org/x/mobile/cmd/gomobile
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
- name: Build Go backend for Android
working-directory: go_backend
run: |
mkdir -p ../android/app/libs
# arm/arm64 only: ndk.abiFilters in app/build.gradle.kts strips
# every other ABI from all outputs (universal APK included), so an
# amd64 slice would never reach a shipped APK — it only bloats the
# aar and slows this step.
gomobile bind -target=android/arm,android/arm64 -androidapi 24 -o ../android/app/libs/gobackend.aar .
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
env:
CGO_ENABLED: 1
@@ -131,13 +119,6 @@ jobs:
channel: "stable"
cache: true
- name: Cache pub dependencies
uses: actions/cache@v5
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-${{ runner.os }}-
- name: Get Flutter dependencies
run: flutter pub get
@@ -183,22 +164,17 @@ jobs:
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
build-ios:
runs-on: macos-15
runs-on: macos-latest
needs: get-version # Only depends on version, NOT android build!
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Select Xcode 26.1.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.1.app
xcodebuild -version
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.26.5"
go-version: "1.25.8"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
@@ -210,10 +186,8 @@ jobs:
restore-keys: pods-${{ runner.os }}-
- name: Install gomobile
working-directory: go_backend
run: |
# go.mod-pinned x/mobile version (reproducible + cached by setup-go).
go install golang.org/x/mobile/cmd/gomobile
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
- name: Build Go backend for iOS
@@ -231,9 +205,8 @@ jobs:
- name: Add XCFramework to Xcode project
run: |
# xcodeproj usually ships with the preinstalled CocoaPods; only
# install it when missing.
gem list -i xcodeproj >/dev/null || sudo gem install xcodeproj
# Install xcodeproj gem for modifying Xcode project
sudo gem install xcodeproj
# Create Ruby script to add framework
cat > add_framework.rb << 'EOF'
@@ -276,25 +249,9 @@ jobs:
channel: "stable"
cache: true
- name: Cache pub dependencies
uses: actions/cache@v5
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-${{ runner.os }}-
- name: Get Flutter dependencies
run: flutter pub get
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
run: |
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
while IFS= read -r -d '' f; do
perl -pi -e 's/\r$//' "$f"
chmod +x "$f"
echo "Normalized line endings: $f"
done
- name: Generate app icons
run: dart run flutter_launcher_icons
@@ -417,6 +374,8 @@ jobs:
### Installation
**Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6)
FOOTER
echo "Release body:"
@@ -426,7 +385,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
body_path: /tmp/release_body.txt
files: ./release/*
draft: false
@@ -592,7 +551,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM64_APK}" \
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
fi
# Upload arm32 APK to channel
@@ -601,7 +560,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM32_APK}" \
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
-F caption="SpotiFLAC ${VERSION} - arm32"
fi
# Upload iOS IPA to channel
@@ -611,7 +570,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${IOS_IPA}" \
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
fi
echo "Telegram notification sent!"
+2 -10
View File
@@ -44,7 +44,6 @@ go_backend/*.xcframework/
# Android
android/.gradle/
android/app/libs/gobackend.aar
android/app/libs/gobackend-sources.jar
android/local.properties
android/*.iml
android/key.properties
@@ -58,22 +57,17 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
# Extension folder
extension/*
extension/v2/
extension/v2/**
extension/
# Agent instructions
AGENTS.md
# Temp/misc
.tmp/
nul
NUL
network_requests.txt
*.bak
/AndroidManifest.xml
# Log files
*.log
@@ -84,8 +78,6 @@ flutter_*.log
tool/
.claude/settings.local.json
.playwright-mcp/
.rtk/
CLAUDE.md
# FVM Version Cache
.fvm/
Binary file not shown.
+16 -23
View File
@@ -49,10 +49,10 @@ Feature requests are welcome! Please use the feature request template and:
### Code Contributions
1. **Fork the repository** and create your branch from `main`
1. **Fork the repository** and create your branch from `dev`
2. **Make your changes** following our coding guidelines
3. **Test your changes** thoroughly
4. **Submit a pull request** to the `main` branch
4. **Submit a pull request** to the `dev` branch
### Translations
@@ -83,10 +83,10 @@ Translation files are located in `lib/l10n/arb/`.
2. **Add upstream remote**
```bash
git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
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
```
@@ -101,15 +101,11 @@ Translation files are located in `lib/l10n/arb/`.
dart run build_runner build --delete-conflicting-outputs
```
6. **Set up Go environment (Go Version: 1.25.9)**
Building the Go backend for Android requires the **Android NDK** (r29 is what CI uses). Make sure `ANDROID_NDK_HOME` points to it and `CGO_ENABLED=1`.
6. **Set up Go environment (Go Version: 1.25.7)**
```bash
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
cd go_backend
cd go_backend
mkdir -p ../android/app/libs
gomobile init
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
cd ..
```
@@ -176,18 +172,17 @@ flutter analyze
### State Management
We use **Riverpod** for state management, with hand-written `Notifier`s
(no `riverpod_annotation` code generation). Follow this pattern:
We use **Riverpod** for state management. Follow these patterns:
```dart
class MyNotifier extends Notifier<MyState> {
// Use code generation with riverpod_annotation
@riverpod
class MyNotifier extends _$MyNotifier {
@override
MyState build() => MyState();
// Methods to update state
}
final myProvider = NotifierProvider<MyNotifier, MyState>(MyNotifier.new);
```
### Localization
@@ -243,7 +238,7 @@ chore(deps): update flutter_riverpod to 3.1.0
1. **Update your fork**
```bash
git fetch upstream
git rebase upstream/main
git rebase upstream/dev
```
2. **Create a feature branch**
@@ -259,7 +254,7 @@ chore(deps): update flutter_riverpod to 3.1.0
```
5. **Create a Pull Request**
- Target the `main` branch
- Target the `dev` branch
- Fill in the PR template
- Link related issues
@@ -277,13 +272,11 @@ chore(deps): update flutter_riverpod to 3.1.0
- [ ] Commit messages follow guidelines
- [ ] PR description is clear and complete
CI runs `flutter analyze`, `flutter test`, `go vet`, and `go test` on every pull request — make sure they pass locally before pushing.
## Questions?
If you have questions, feel free to:
- Open a [Discussion](https://github.com/spotiflacapp/SpotiFLAC-Mobile/discussions)
- Check existing [Issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
Thank you for contributing! 💚
+19 -15
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>
---
@@ -52,14 +52,14 @@ Extensions let the community add new music sources and features without waiting
### Developing Extensions
> [!NOTE]
> Want to build your own extension? The [Extension Development Guide](https://spotiflac.zarz.moe/docs) has everything you need.
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
---
## Related Projects
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
@@ -80,7 +80,7 @@ Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository
<summary><b>Why is my download failing with "Song not found"?</b></summary>
<br>
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
</details>
@@ -88,7 +88,10 @@ The track may not be available from your enabled providers. Try enabling more pr
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
<br>
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
- **Tidal** up to 24-bit/192kHz
- **Qobuz** up to 24-bit/192kHz
- **Deezer** up to 16-bit/44.1kHz
</details>
@@ -163,8 +166,9 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
| | | | | |
|---|---|---|---|---|
| [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) |
| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | |
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
---
+7 -7
View File
@@ -9,9 +9,6 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
plugins:
riverpod_lint: 3.1.4-dev.3
analyzer:
exclude:
- build/**
@@ -22,6 +19,9 @@ analyzer:
strict-casts: true
strict-inference: true
strict-raw-types: true
plugins:
- custom_lint
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
@@ -36,13 +36,13 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
always_declare_return_types: true
avoid_dynamic_calls: true
avoid_types_as_parameter_names: true
strict_top_level_inference: true
type_annotate_public_apis: true
cancel_subscriptions: true
close_sinks: true
custom_lint:
rules:
- avoid_public_notifier_properties
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+71
View File
@@ -0,0 +1,71 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
namespace "com.zarz.spotiflac"
compileSdk flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "com.zarz.spotiflac"
minSdkVersion flutter.minSdkVersion
targetSdk flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
release {
signingConfig signingConfigs.debug
minifyEnabled false
shrinkResources false
}
}
}
flutter {
source '../..'
}
dependencies {
// Go backend library (gomobile generated)
implementation fileTree(dir: 'libs', include: ['*.aar'])
// Kotlin coroutines for async Go backend calls
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
}
+8 -35
View File
@@ -17,22 +17,18 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "com.zarz.spotiflac"
compileSdk = 37
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
buildFeatures {
buildConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
@@ -50,7 +46,7 @@ android {
defaultConfig {
applicationId = "com.zarz.spotiflac"
minSdk = flutter.minSdkVersion
targetSdk = 37
targetSdk = 36
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
@@ -61,20 +57,6 @@ android {
}
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
ndk {
debugSymbolLevel = "FULL"
}
}
getByName("profile") {
ndk {
debugSymbolLevel = "FULL"
}
}
release {
// For local builds: use release signing if key.properties exists
// For CI builds: APK is signed by GitHub Action after build
@@ -89,9 +71,6 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
ndk {
debugSymbolLevel = "FULL"
}
}
}
@@ -122,14 +101,8 @@ dependencies {
// Include all AAR and JAR files from libs folder
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
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")
// NativeDownloadFinalizer imports FFmpegKit APIs directly. The Flutter
// plugin owns the runtime AAR; compileOnly avoids packaging it twice here.
compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1")
testImplementation("junit:junit:4.13.2")
implementation("androidx.activity:activity-ktx:1.12.3")
}
+4 -31
View File
@@ -3,7 +3,6 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
@@ -19,12 +18,9 @@
<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:allowBackup="false"
android:fullBackupContent="@xml/backup_rules_legacy"
android:dataExtractionRules="@xml/backup_rules"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
@@ -90,26 +86,6 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="music.youtube.com" />
</intent-filter>
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="spotify-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="session-grant" />
</intent-filter>
</activity>
<!-- Download Service -->
@@ -118,15 +94,16 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Audio playback service for media notification / background audio -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
android:enabled="true">
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
@@ -151,10 +128,6 @@
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- FileProvider for APK installation -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -0,0 +1,5 @@
package com.example.temp_project
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
package com.zarz.spotiflac
internal object NativeWorkerPolicy {
const val MAX_RATE_LIMIT_RETRIES = 1
private const val DEFAULT_RATE_LIMIT_DELAY_SECONDS = 30
private const val MIN_RATE_LIMIT_DELAY_SECONDS = 5
private const val MAX_RATE_LIMIT_DELAY_SECONDS = 300
private val retryAfterPattern = Regex(
"""retry[- ]?after(?: seconds)?[:= ]+(\d+)""",
RegexOption.IGNORE_CASE,
)
fun shouldRetryRateLimit(
errorType: String?,
errorMessage: String?,
attempts: Int,
): Boolean {
if (attempts >= MAX_RATE_LIMIT_RETRIES) return false
if (errorType.equals("rate_limit", ignoreCase = true)) return true
val message = errorMessage.orEmpty()
return message.contains("429") ||
message.contains("rate limit", ignoreCase = true) ||
message.contains("too many requests", ignoreCase = true)
}
fun rateLimitDelaySeconds(
retryAfterSeconds: Int?,
errorMessage: String?,
): Int {
val parsedFromMessage = retryAfterPattern
.find(errorMessage.orEmpty())
?.groupValues
?.getOrNull(1)
?.toIntOrNull()
return (retryAfterSeconds?.takeIf { it > 0 }
?: parsedFromMessage
?: DEFAULT_RATE_LIMIT_DELAY_SECONDS)
.coerceIn(
MIN_RATE_LIMIT_DELAY_SECONDS,
MAX_RATE_LIMIT_DELAY_SECONDS,
)
}
fun isVerificationRequired(
errorType: String?,
errorMessage: String?,
): Boolean {
if (errorType.equals("verification_required", ignoreCase = true)) {
return true
}
val message = errorMessage.orEmpty()
return message.contains("verification required", ignoreCase = true) ||
message.contains("challenge required", ignoreCase = true)
}
fun requiresWifi(downloadNetworkMode: String?): Boolean =
downloadNetworkMode.equals("wifi_only", ignoreCase = true)
fun shouldPauseForNetwork(
downloadNetworkMode: String?,
hasWifi: Boolean,
): Boolean = requiresWifi(downloadNetworkMode) && !hasWifi
fun shouldNotifyQueueComplete(
cancelRequested: Boolean,
completed: Int,
failed: Int,
): Boolean = !cancelRequested && completed + failed > 0
}
@@ -1,715 +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.io.FileOutputStream
import java.io.OutputStream
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"
// Serializes the exists-check/create/write/publish sequence per target
// file so concurrent downloads that sanitize to the same display name
// cannot interleave writes into one document. Different names keep
// downloading in parallel; the second same-name caller blocks, then hits
// the exists check and reports already_exists.
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
data class UniqueWriteResult(val uri: String, val fileName: String)
private fun <T> withSafNameLock(
treeUriStr: String,
relativeDir: String,
fileName: String,
block: () -> T
): T {
val key = "$treeUriStr|$relativeDir|${fileName.lowercase(Locale.ROOT)}"
val lock = safNameLocks.computeIfAbsent(key) { Any() }
return synchronized(lock) { block() }
}
/**
* Flushes and fsyncs a SAF output stream so the copied bytes are durable
* before the staged document is renamed to its final name; without this a
* power loss right after the rename can leave a truncated "complete" file.
*/
fun syncOutputStream(output: OutputStream) {
try {
output.flush()
(output as? FileOutputStream)?.fd?.sync()
} catch (_: Exception) {
}
}
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
val req = JSONObject(requestJson)
val storageMode = req.optString("storage_mode", "")
val treeUriStr = req.optString("saf_tree_uri", "")
if (storageMode != "saf" || treeUriStr.isBlank()) {
return downloader(requestJson)
}
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
val fileName = buildSafFileName(req, outputExt)
return withSafNameLock(treeUriStr, relativeDir, fileName) {
handleSafLocked(context, req, downloader, treeUriStr, relativeDir, outputExt, fileName)
}
}
private fun handleSafLocked(
context: Context,
req: JSONObject,
downloader: (String) -> String,
treeUriStr: String,
relativeDir: String,
outputExt: String,
fileName: String
): String {
val treeUri = Uri.parse(treeUriStr)
val mimeType = mimeTypeForExt(outputExt)
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
// Downloads are always written under a staged ".partial" name so a
// killed process can never leave a half-written file under the final
// name (which the exists check would then accept as complete forever).
// Callers that set stage_saf_output own the promotion to the final
// name (the native finalizer); for everyone else — the foreground
// Dart queue — this handler promotes right after a successful write.
val finalizerPromotesStaged = req.optBoolean("stage_saf_output", false) && !deferSafPublish
val useStagedOutput = !deferSafPublish
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
val existingDir = findDocumentDir(context, treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
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}")
}
}
// Remove any stale partial from a previous killed attempt before
// creating the staged document: reusing it would let a shorter new
// write leave the old tail bytes in place (fd truncation is
// best-effort on some providers).
deleteStaleStagedFiles(targetDir, fileName, outputExt)
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
?: return errorJson("Failed to create SAF file")
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)) {
var finalFileName = fileName
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
}
finalFileName = actualFileName
}
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
syncOutputStream(output)
} ?: throw IllegalStateException("failed to open SAF output stream")
srcFile.delete()
} catch (e: Exception) {
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 (finalizerPromotesStaged) {
respObj.put("saf_staged_output", true)
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
} else if (useStagedOutput) {
// Legacy caller (foreground Dart queue): publish here by
// renaming the staged file to its final name.
val published = replaceFinalDocument(targetDir, document, finalFileName)
if (published == null) {
document.delete()
return errorJson("Failed to publish SAF download")
}
respObj.put("file_path", published.uri.toString())
respObj.put("file_name", published.name ?: finalFileName)
}
} else {
document.delete()
}
return respObj.toString()
} catch (e: Exception) {
document.delete()
return errorJson("SAF download failed: ${e.message}")
} finally {
if (detachedFd == null) {
try {
pfd.close()
} catch (_: Exception) {
}
}
}
}
/**
* Swaps [document] into place under [finalName] without a window where the
* previous file is gone but the new one is not yet in place. Any existing
* file is renamed aside first and only deleted once the staged file holds
* the final name; a failed swap restores it. Returns the published
* document, or null when the swap failed (the caller owns [document]).
*/
private fun replaceFinalDocument(
targetDir: DocumentFile,
document: DocumentFile,
finalName: String
): DocumentFile? {
val existingFinal = targetDir.findFile(finalName)
var aside: DocumentFile? = null
if (existingFinal != null && existingFinal.uri != document.uri) {
val asideName = buildReplacedSafFileName(finalName)
try {
targetDir.findFile(asideName)?.delete()
} catch (_: Exception) {
}
if (!existingFinal.renameTo(asideName)) {
return null
}
aside = targetDir.findFile(asideName) ?: existingFinal
}
if (!document.renameTo(finalName)) {
aside?.renameTo(finalName)
return null
}
aside?.delete()
return targetDir.findFile(finalName) ?: document
}
private fun buildReplacedSafFileName(fileName: String): String {
return "${sanitizeFilename(fileName)}.replaced"
}
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
return try {
val uri = Uri.parse(uriStr)
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? {
val finalName = sanitizeFilename(fileName)
return withSafNameLock(treeUriStr, sanitizeRelativeDir(relativeDir), finalName) {
writeFileToSafLocked(context, treeUriStr, relativeDir, finalName, srcPath)
}
}
fun writeFileToSafUnique(
context: Context,
treeUriStr: String,
relativeDir: String,
fileName: String,
mimeType: String,
srcPath: String,
preservedSuffix: String = "",
): UniqueWriteResult? {
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
val preferredName = sanitizeFilenamePreservingSuffix(fileName, preservedSuffix)
return withSafNameLock(treeUriStr, safeRelativeDir, preferredName) {
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) ?: return@withSafNameLock null
val availableName = findAvailableFileName(
targetDir,
preferredName,
preservedSuffix,
)
val uri = writeFileToSafLocked(
context,
treeUriStr,
safeRelativeDir,
availableName,
srcPath,
) ?: return@withSafNameLock null
UniqueWriteResult(uri = uri, fileName = availableName)
}
}
private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
val sanitized = sanitizeFilename(fileName)
val trimmedSuffix = suffix.trim()
if (trimmedSuffix.isEmpty() || sanitized.contains(trimmedSuffix)) return sanitized
val dotIndex = fileName.lastIndexOf('.')
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
val rawStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
val rawPrefix = rawStem.replace(trimmedSuffix, "").trim(' ', '_', '-')
val safeSuffix = sanitizeFilename(trimmedSuffix)
val reserved = " - $safeSuffix$extension"
val prefixBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reserved.toByteArray(Charsets.UTF_8).size)
.coerceAtLeast(1)
val safePrefix = truncateUtf8Bytes(sanitizeFilename(rawPrefix), prefixBytes)
.trim()
.trim('.', ' ', '_', '-')
.ifBlank { "track" }
return "$safePrefix$reserved"
}
private fun findAvailableFileName(
parent: DocumentFile,
preferredName: String,
preservedSuffix: String,
): String {
if (parent.findFile(preferredName) == null) return preferredName
for (counter in 2..9999) {
val candidate = appendFilenameCounter(
preferredName,
counter.toLong(),
preservedSuffix,
)
if (parent.findFile(candidate) == null) return candidate
}
return appendFilenameCounter(
preferredName,
System.currentTimeMillis(),
preservedSuffix,
)
}
private fun appendFilenameCounter(
fileName: String,
counter: Long,
preservedSuffix: String,
): String {
val dotIndex = fileName.lastIndexOf('.')
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
val originalStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
val safePreservedSuffix = preservedSuffix.trim()
val hasPreservedSuffix = safePreservedSuffix.isNotEmpty() && originalStem.contains(safePreservedSuffix)
val stem = if (hasPreservedSuffix) {
originalStem.replace(safePreservedSuffix, "").trim(' ', '_', '-')
} else {
originalStem
}
val suffix = if (hasPreservedSuffix) {
" - $safePreservedSuffix ($counter)"
} else {
" ($counter)"
}
val reservedBytes = extension.toByteArray(Charsets.UTF_8).size +
suffix.toByteArray(Charsets.UTF_8).size
val maxStemBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reservedBytes).coerceAtLeast(1)
val safeStem = truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').ifBlank { "track" }
return "$safeStem$suffix$extension"
}
private fun writeFileToSafLocked(
context: Context,
treeUriStr: String,
relativeDir: String,
finalName: String,
srcPath: String
): String? {
var stagedDocument: DocumentFile? = null
return try {
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
val 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)
}
syncOutputStream(output)
}
val published = replaceFinalDocument(targetDir, document, finalName)
if (published == null) {
document.delete()
return null
}
stagedDocument = null
published.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
}
}
internal fun normalizeExt(ext: String?): String {
val trimmed = ext?.trim().orEmpty()
if (trimmed.isEmpty()) return ""
return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}"
}
internal fun mimeTypeForExt(ext: String?): String {
return when (normalizeExt(ext)) {
".m4a", ".mp4" -> "audio/mp4"
".mp3" -> "audio/mpeg"
".opus", ".ogg" -> "audio/ogg"
".flac" -> "audio/flac"
".wav" -> "audio/wav"
".aiff", ".aif", ".aifc" -> "audio/aiff"
".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),
buildReplacedSafFileName(fileName)
)
for (stagedName in stagedNames) {
try {
parent.findFile(stagedName)?.delete()
} catch (_: Exception) {
}
}
}
internal 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()
}
internal fun sanitizeRelativeDir(relativeDir: String): String {
if (relativeDir.isBlank()) return ""
return relativeDir
.split("/")
.map { sanitizeFilename(it) }
.filter { it.isNotBlank() && it != "." && it != ".." }
.joinToString("/")
}
internal 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
}
}
internal 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
}
internal fun createOrReuseDocumentFile(
parent: DocumentFile,
mimeType: String,
fileName: String
): DocumentFile? {
val safeFileName = sanitizeFilename(fileName)
if (safeFileName.isBlank()) return null
synchronized(safDirLock) {
val existing = parent.findFile(safeFileName)
if (existing != null && existing.isFile) {
return existing
}
val created = parent.createFile(mimeType, safeFileName) ?: return null
val createdName = created.name ?: safeFileName
if (createdName == safeFileName) {
return created
}
val winner = parent.findFile(safeFileName)
if (winner != null && winner.isFile) {
if (winner.uri != created.uri) {
try {
created.delete()
} catch (_: Exception) {
}
}
return winner
}
return created
}
}
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
val trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "")
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
return forceFilenameExt(baseName, outputExt)
}
private fun errorJson(message: String): String {
val obj = JSONObject()
obj.put("success", false)
obj.put("error", message)
obj.put("message", message)
return obj.toString()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

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

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

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

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,8 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
@@ -1,8 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<!--
SpotiFLAC Mobile provides an explicit, schema-aware Backup & Restore
feature. Platform backup must not copy device-bound SAF grants, paths,
queues, extension credentials, or signed sessions to another install.
-->
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- Android 11 and lower use this legacy rule format. -->
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</full-backup-content>
@@ -1,152 +0,0 @@
package com.zarz.spotiflac
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeWorkerPolicyTest {
@Test
fun rateLimitRetriesExactlyOnce() {
assertTrue(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "rate_limit",
errorMessage = "Too many requests",
attempts = 0,
),
)
assertFalse(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "rate_limit",
errorMessage = "Too many requests",
attempts = 1,
),
)
}
@Test
fun rateLimitDetectionFallsBackToMessage() {
assertTrue(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "network",
errorMessage = "HTTP 429: retry later",
attempts = 0,
),
)
assertFalse(
NativeWorkerPolicy.shouldRetryRateLimit(
errorType = "network",
errorMessage = "Connection reset",
attempts = 0,
),
)
}
@Test
fun retryDelayUsesServerHintAndSafeBounds() {
assertEquals(
45,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = 45,
errorMessage = null,
),
)
assertEquals(
12,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = null,
errorMessage = "Retry-After seconds: 12",
),
)
assertEquals(
5,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = 1,
errorMessage = null,
),
)
assertEquals(
300,
NativeWorkerPolicy.rateLimitDelaySeconds(
retryAfterSeconds = 900,
errorMessage = null,
),
)
}
@Test
fun wifiOnlyPausesWithoutWifi() {
assertTrue(
NativeWorkerPolicy.shouldPauseForNetwork(
downloadNetworkMode = "wifi_only",
hasWifi = false,
),
)
assertFalse(
NativeWorkerPolicy.shouldPauseForNetwork(
downloadNetworkMode = "wifi_only",
hasWifi = true,
),
)
assertFalse(
NativeWorkerPolicy.shouldPauseForNetwork(
downloadNetworkMode = "any",
hasWifi = false,
),
)
}
@Test
fun verificationDetectionUsesTypeAndMessageFallback() {
assertTrue(
NativeWorkerPolicy.isVerificationRequired(
errorType = "verification_required",
errorMessage = null,
),
)
assertTrue(
NativeWorkerPolicy.isVerificationRequired(
errorType = "unknown",
errorMessage = "Challenge required before download",
),
)
assertFalse(
NativeWorkerPolicy.isVerificationRequired(
errorType = "network",
errorMessage = "Connection reset",
),
)
}
@Test
fun completionAlertSkipsCancelledOrEmptyRuns() {
assertTrue(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = false,
completed = 1,
failed = 0,
),
)
assertTrue(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = false,
completed = 0,
failed = 1,
),
)
assertFalse(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = true,
completed = 1,
failed = 0,
),
)
assertFalse(
NativeWorkerPolicy.shouldNotifyQueueComplete(
cancelRequested = false,
completed = 0,
failed = 0,
),
)
}
}
+3 -3
View File
@@ -11,8 +11,8 @@ subprojects {
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// Enable multidex for all subprojects
@@ -27,7 +27,7 @@ subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
}
+1 -5
View File
@@ -1,6 +1,2 @@
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -5
View File
@@ -1,9 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-all.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.3.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.10" apply false
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
+7 -7
View File
@@ -1,18 +1,18 @@
{
"name": "SpotiFLAC Mobile Source",
"name": "SpotiFLAC Source",
"identifier": "com.zarzet.spotiflac.source",
"subtitle": "FLAC Downloader for iOS",
"apps": [
{
"name": "SpotiFLAC Mobile",
"name": "SpotiFLAC",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.7.1",
"versionDate": "2026-07-01",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"version": "3.9.0",
"versionDate": "2026-03-25",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 37455821
"size": 34477323
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Before

Width:  |  Height:  |  Size: 811 KiB

After

Width:  |  Height:  |  Size: 811 KiB

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 70 KiB

+4 -8
View File
@@ -3,21 +3,17 @@ files:
translation: /lib/l10n/arb/app_%locale%.arb
languages_mapping:
locale:
# Keys MUST be the project's Crowdin language ids; values are the
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
# gen-l10n parses them — hyphenated filenames break gen-l10n).
ar: ar
# Short codes for single-variant languages
de: de
es-ES: es_ES
es: es
fr: fr
hi: hi
id: id
ja: ja
ko: ko
nl: nl
pt-PT: pt_PT
pt: pt
ru: ru
tr: tr
uk: uk
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
-408
View File
@@ -1,408 +0,0 @@
package gobackend
import (
"encoding/binary"
"fmt"
"io"
"os"
)
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
type mp4Box struct {
offset int64
size int64
hdr int64
typ string
}
func (b mp4Box) body() int64 { return b.offset + b.hdr }
func (b mp4Box) end() int64 { return b.offset + b.size }
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
n := int64(len(data))
if pos < 0 || pos+8 > n {
return mp4Box{}, false
}
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
typ := string(data[pos+4 : pos+8])
hdr := int64(8)
switch size {
case 1:
if pos+16 > n {
return mp4Box{}, false
}
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
hdr = 16
case 0:
size = n - pos
}
if size < hdr || pos+size > n {
return mp4Box{}, false
}
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
}
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
pos := start
for pos+8 <= end {
b, ok := readMP4Box(data, pos)
if !ok {
return mp4Box{}, false
}
if b.typ == typ {
return b, true
}
pos = b.end()
}
return mp4Box{}, false
}
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
pos := start
for pos+8 <= end {
b, ok := readMP4Box(data, pos)
if !ok {
return
}
if b.typ == typ && !fn(b) {
return
}
pos = b.end()
}
}
// findBoxBySignature scans [start,end) for a box of the given type, matching the
// 4-byte type tag and validating the preceding size field. Used to locate dac4
// which may be nested inside an encrypted (enca) sample entry.
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
if len(typ) != 4 {
return mp4Box{}, false
}
for i := start; i+8 <= end; i++ {
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
return b, true
}
}
}
return mp4Box{}, false
}
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
// entry header (from the box body start) before child boxes begin. ok is false
// for malformed/truncated entries whose declared header is not fully present.
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
base := entry.body()
if base+10 > entry.end() {
return 0, false
}
version := binary.BigEndian.Uint16(data[base+8 : base+10])
hdrLen = 8 + 20
switch version {
case 1:
hdrLen += 16
case 2:
hdrLen += 36
}
if base+hdrLen > entry.end() {
return 0, false
}
return hdrLen, true
}
type ac4Location struct {
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
entry mp4Box // the ac-4 sample entry
}
func locateAC4Entry(data []byte) (ac4Location, bool) {
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
if !ok {
return ac4Location{}, false
}
var found ac4Location
var ok2 bool
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
if !ok {
return true
}
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
if !ok {
return true
}
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
if !ok {
return true
}
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
if !ok {
return true
}
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
if !ok {
return true
}
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
ok2 = true
return false
})
return found, ok2
}
func growBoxSize(data []byte, b mp4Box, delta int64) {
if b.hdr == 16 {
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
} else {
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
}
}
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
// inserted into moov.
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
if !ok {
return true
}
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
if !ok {
return true
}
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
if !ok {
return true
}
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
base := stco.body() + 4
if base+4 <= stco.end() {
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
p := base + 4
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
if v >= insertPos {
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
}
p += 4
}
}
}
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
base := co64.body() + 4
if base+4 <= co64.end() {
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
p := base + 4
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
if v >= insertPos {
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
}
p += 8
}
}
}
return true
})
}
// normalizeQuickTimeBrandsInBuf rewrites a "qt " ftyp brand to isom/mp42 in
// place. Works on any buffer whose top level contains the ftyp box (whole file
// or the ftyp box alone). Returns whether anything changed.
func normalizeQuickTimeBrandsInBuf(data []byte) bool {
ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp")
if !ok {
return false
}
changed := false
if ftyp.body()+4 <= int64(len(data)) && string(data[ftyp.body():ftyp.body()+4]) != "mp42" {
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
changed = true
}
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
if string(data[p:p+4]) == "qt " {
copy(data[p:p+4], []byte("isom"))
changed = true
}
}
return changed
}
// normalizeQuickTimeAudioEntry rewrites a version-1 QuickTime sound sample
// entry into a plain version-0 AudioSampleEntry, dropping the 16-byte v1
// extension. base is the buffer's absolute file offset (0 for a whole-file
// buffer, the moov offset for a moov-only buffer).
func normalizeQuickTimeAudioEntry(data []byte, base int64) []byte {
loc, ok := locateAC4Entry(data)
if !ok {
return data
}
entry := loc.entry
verPos := entry.body() + 8
if verPos+2 > entry.end() {
return data
}
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
return data // already v0 (or v2, left untouched)
}
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
extStart := entry.body() + 8 + 20
extEnd := extStart + 16
if extEnd > entry.end() {
return data
}
delta := int64(-16)
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
shiftChunkOffsets(data, loc.chain[0], base+extStart, delta)
for _, b := range loc.chain {
growBoxSize(data, b, delta)
}
growBoxSize(data, entry, delta)
out := make([]byte, 0, len(data)-16)
out = append(out, data[:extStart]...)
out = append(out, data[extEnd:]...)
return out
}
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
// Windows Media Foundation (and other strict parsers) reject the QuickTime
// flavor for AC-4 even when dac4 is present.
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
normalizeQuickTimeBrandsInBuf(data)
return normalizeQuickTimeAudioEntry(data, 0)
}
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
// moov still carries it). No-op when the file has no AC-4 track. Only the ftyp
// and moov boxes are held in memory; the audio bulk is streamed.
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
f, err := os.Open(decryptedPath)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
f.Close()
return err
}
// A non-MP4 decrypt output (e.g. a raw FLAC stream) is not an AC-4 file;
// bail out before the box parser reports it as a corrupt MP4. A real
// ISO-BMFF box type is four printable ASCII bytes.
var head [8]byte
if _, err := f.ReadAt(head[:], 0); err != nil {
f.Close()
if err == io.EOF || err == io.ErrUnexpectedEOF {
return nil
}
return err
}
for _, c := range head[4:8] {
if c < 0x20 || c > 0x7e {
f.Close()
return nil
}
}
moovBuf, moovOffset, moovFound, err := loadTopLevelMP4Box(f, info.Size(), "moov")
if err != nil || !moovFound {
f.Close()
return err // parse/read failure, or no moov: nothing to do
}
ftypBuf, ftypOffset, ftypFound, err := loadTopLevelMP4Box(f, info.Size(), "ftyp")
f.Close()
if err != nil {
return err
}
moovLen := int64(len(moovBuf))
if _, ok := locateAC4Entry(moovBuf); !ok {
return nil // not an AC-4 file; nothing to do
}
ftypChanged := ftypFound && normalizeQuickTimeBrandsInBuf(ftypBuf)
dst := normalizeQuickTimeAudioEntry(moovBuf, moovOffset)
loc, ok := locateAC4Entry(dst)
if !ok {
return nil
}
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
if !ok {
return fmt.Errorf("malformed ac-4 sample entry")
}
childStart := loc.entry.body() + hdrLen
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
// Already has dac4; still persist any normalization changes.
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, dst, moovOffset, moovLen)
}
srcF, err := os.Open(sourcePath)
if err != nil {
return err
}
srcInfo, err := srcF.Stat()
if err != nil {
srcF.Close()
return err
}
srcMoovBuf, _, srcFound, err := loadTopLevelMP4Box(srcF, srcInfo.Size(), "moov")
srcF.Close()
if err != nil {
return err
}
if !srcFound {
return fmt.Errorf("source has no moov")
}
srcMoov, ok := readMP4Box(srcMoovBuf, 0)
if !ok {
return fmt.Errorf("source has no moov")
}
dac4Box, ok := findBoxBySignature(srcMoovBuf, srcMoov.body(), srcMoov.end(), "dac4")
if !ok {
return fmt.Errorf("dac4 not found in source")
}
dac4 := append([]byte{}, srcMoovBuf[dac4Box.offset:dac4Box.end()]...)
insertPos := childStart
delta := int64(len(dac4))
shiftChunkOffsets(dst, loc.chain[0], moovOffset+insertPos, delta)
for _, b := range loc.chain {
growBoxSize(dst, b, delta)
}
growBoxSize(dst, loc.entry, delta)
out := make([]byte, 0, len(dst)+len(dac4))
out = append(out, dst[:insertPos]...)
out = append(out, dac4...)
out = append(out, dst[insertPos:]...)
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, out, moovOffset, moovLen)
}
// writeAC4Sections streams the edited moov (and, when changed, ftyp) back into
// the file.
func writeAC4Sections(path string, ftypChanged bool, ftypBuf []byte, ftypOffset int64, moovBuf []byte, moovOffset, origMoovLen int64) error {
sections := []fileSection{
{start: moovOffset, end: moovOffset + origMoovLen, data: moovBuf},
}
if ftypChanged {
sections = append(sections, fileSection{
start: ftypOffset,
end: ftypOffset + int64(len(ftypBuf)),
data: ftypBuf,
})
}
return replaceFileSectionsStreaming(path, sections)
}
-76
View File
@@ -1,76 +0,0 @@
package gobackend
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func mp4TestBox(typ string, body []byte) []byte {
out := make([]byte, 8+len(body))
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
copy(out[4:8], typ)
copy(out[8:], body)
return out
}
func mp4TestAC4Tree(entryBody []byte) []byte {
entry := mp4TestBox("ac-4", entryBody)
stsdBody := append([]byte{
0, 0, 0, 0, // version/flags
0, 0, 0, 1, // entry_count
}, entry...)
stsd := mp4TestBox("stsd", stsdBody)
stbl := mp4TestBox("stbl", stsd)
minf := mp4TestBox("minf", stbl)
mdia := mp4TestBox("mdia", minf)
trak := mp4TestBox("trak", mdia)
moov := mp4TestBox("moov", trak)
return moov
}
func shortAC4SampleEntryBody(version uint16) []byte {
body := make([]byte, 10)
binary.BigEndian.PutUint16(body[8:10], version)
return body
}
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
defer func() {
if r := recover(); r != nil {
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
}
}()
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
if !bytes.Equal(got, input) {
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
}
}
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
dir := t.TempDir()
decryptedPath := filepath.Join(dir, "decrypted.mp4")
sourcePath := filepath.Join(dir, "source.mp4")
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
t.Fatal(err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
}
}()
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
t.Fatal("expected malformed AC-4 sample entry error")
}
}
-201
View File
@@ -1,201 +0,0 @@
package gobackend
import (
"encoding/binary"
"encoding/json"
"os"
"strconv"
"strings"
)
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
// fields are strings because they arrive as a JSON-encoded map of strings.
type ac4Metadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumArtist string `json:"albumArtist"`
Date string `json:"date"`
Genre string `json:"genre"`
Composer string `json:"composer"`
TrackNumber string `json:"trackNumber"`
TotalTracks string `json:"totalTracks"`
DiscNumber string `json:"discNumber"`
TotalDiscs string `json:"totalDiscs"`
ISRC string `json:"isrc"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Lyrics string `json:"lyrics"`
}
func atoiSafe(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0
}
return n
}
func itunesTextTag(atomType, value string) []byte {
data := make([]byte, 8+len(value))
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
copy(data[8:], []byte(value))
return buildM4AAtom(atomType, buildM4AAtom("data", data))
}
func itunesNumberPairTag(atomType string, number, total int) []byte {
payload := make([]byte, 8)
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
data := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
copy(data[8:], payload)
return buildM4AAtom(atomType, buildM4AAtom("data", data))
}
func itunesCoverTag(image []byte) []byte {
typeCode := uint32(13) // JPEG
if len(image) >= 8 &&
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
typeCode = 14 // PNG
}
data := make([]byte, 8+len(image))
binary.BigEndian.PutUint32(data[0:4], typeCode)
copy(data[8:], image)
return buildM4AAtom("covr", buildM4AAtom("data", data))
}
func itunesMetadataHandler() []byte {
payload := make([]byte, 0, 25)
payload = append(payload, 0, 0, 0, 0) // version + flags
payload = append(payload, 0, 0, 0, 0) // pre_defined
payload = append(payload, []byte("mdir")...) // handler type
payload = append(payload, []byte("appl")...) // reserved[0]
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
payload = append(payload, 0) // empty name
return buildM4AAtom("hdlr", payload)
}
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
ilst := make([]byte, 0, 256)
add := func(atomType, value string) {
if strings.TrimSpace(value) != "" {
ilst = append(ilst, itunesTextTag(atomType, value)...)
}
}
add("\xa9nam", md.Title)
add("\xa9ART", md.Artist)
add("\xa9alb", md.Album)
add("aART", md.AlbumArtist)
add("\xa9day", md.Date)
add("\xa9gen", md.Genre)
add("\xa9wrt", md.Composer)
if tn := atoiSafe(md.TrackNumber); tn > 0 {
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
}
if dn := atoiSafe(md.DiscNumber); dn > 0 {
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
}
if strings.TrimSpace(md.ISRC) != "" {
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
}
if strings.TrimSpace(md.Label) != "" {
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
}
if strings.TrimSpace(md.Copyright) != "" {
add("cprt", md.Copyright)
}
if strings.TrimSpace(md.Lyrics) != "" {
add("\xa9lyr", md.Lyrics)
}
if len(cover) > 0 {
ilst = append(ilst, itunesCoverTag(cover)...)
}
ilstBox := buildM4AAtom("ilst", ilst)
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
metaPayload = append(metaPayload, ilstBox...)
meta := buildM4AAtom("meta", metaPayload)
return buildM4AAtom("udta", meta)
}
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
// the moov of an MP4 buffer and returns the rewritten bytes. base is the
// buffer's absolute file offset (0 for a whole-file buffer) so stco/co64
// shifts compare against the absolute positions the entries hold.
func writeMP4iTunesMetadata(data []byte, base int64, md ac4Metadata, cover []byte) []byte {
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
if !ok {
return data
}
newUdta := buildITunesUdta(md, cover)
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
delta := int64(len(newUdta)) - udta.size
shiftChunkOffsets(data, moov, base+udta.offset, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:udta.offset]...)
out = append(out, newUdta...)
out = append(out, data[udta.end():]...)
return out
}
delta := int64(len(newUdta))
insertPos := moov.end()
shiftChunkOffsets(data, moov, base+insertPos, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:insertPos]...)
out = append(out, newUdta...)
out = append(out, data[insertPos:]...)
return out
}
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
// true when the file was an AC-4 track and metadata was written; false when the
// file is not AC-4 (the caller should fall back to its normal metadata path).
// Only the moov box is held in memory; the audio bulk is streamed.
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
f, err := os.Open(decryptedPath)
if err != nil {
return false, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return false, err
}
moovBuf, moovOffset, found, err := loadTopLevelMP4Box(f, info.Size(), "moov")
f.Close()
if err != nil {
return false, err
}
if !found {
return false, nil
}
moovLen := int64(len(moovBuf))
if _, ok := locateAC4Entry(moovBuf); !ok {
return false, nil
}
var md ac4Metadata
if strings.TrimSpace(metadataJSON) != "" {
_ = json.Unmarshal([]byte(metadataJSON), &md)
}
var cover []byte
if strings.TrimSpace(coverPath) != "" {
if b, err := os.ReadFile(coverPath); err == nil {
cover = b
}
}
out := writeMP4iTunesMetadata(moovBuf, moovOffset, md, cover)
if err := replaceFileSectionsStreaming(decryptedPath, []fileSection{
{start: moovOffset, end: moovOffset + moovLen, data: out},
}); err != nil {
return false, err
}
return true, nil
}
+19 -15
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"strconv"
"strings"
)
@@ -16,8 +17,9 @@ const (
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
apeTagFlagReadOnly = 1 << 0
// Item flags: bits 1-2 encode content type
// (00: UTF-8 text, 01: binary data, 10: external link)
apeItemFlagBinary = 1 << 1
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
apeItemFlagBinary = 1 << 1 // 01: binary data
apeItemFlagLink = 2 << 1 // 10: external link
)
// APETagItem represents a single key-value item in an APEv2 tag.
@@ -55,6 +57,7 @@ func ReadAPETags(filePath string) (*APETag, error) {
return nil, fmt.Errorf("file too small for APE tag")
}
// Try to find APE tag footer at the end of file.
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
if err == nil {
@@ -253,6 +256,7 @@ func findExistingAPETagSize(filePath string) (int64, error) {
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
// Check if there's also a header (tagSize only covers items + footer)
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
totalSize := tagSize
if hasHeader {
@@ -313,6 +317,7 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
footerFlags := uint32(1 << 31)
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
// Final layout: header + items + footer
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
result = append(result, header...)
result = append(result, itemsData...)
@@ -362,9 +367,12 @@ func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
case "DATE":
metadata.Date = value
case "TRACK", "TRACKNUMBER":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
// APE track format can be "3" or "3/12"
trackNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
metadata.TrackNumber = trackNum
case "DISC", "DISCNUMBER":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
discNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
metadata.DiscNumber = discNum
case "ISRC":
metadata.ISRC = value
case "LYRICS", "UNSYNCEDLYRICS":
@@ -417,10 +425,10 @@ func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
addItem("Year", metadata.Year)
}
if metadata.TrackNumber > 0 {
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
addItem("Track", strconv.Itoa(metadata.TrackNumber))
}
if metadata.DiscNumber > 0 {
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
addItem("Disc", strconv.Itoa(metadata.DiscNumber))
}
addItem("ISRC", metadata.ISRC)
addItem("Lyrics", metadata.Lyrics)
@@ -445,7 +453,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
"artist": "ARTIST",
"album": "ALBUM",
"album_artist": "ALBUM ARTIST",
"date": "DATE",
"date": "YEAR",
"genre": "GENRE",
"track_number": "TRACK",
"disc_number": "DISC",
@@ -467,7 +475,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
}
}
// Some fields have reader aliases that must also be cleared when the
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
// canonical key is updated (e.g. "Year" writer ↔ DATE/YEAR reader,
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
if _, present := fields["date"]; present {
@@ -476,15 +484,9 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
if _, present := fields["disc_number"]; present {
result["DISCNUMBER"] = struct{}{}
}
if _, present := fields["disc_total"]; present {
result["DISCNUMBER"] = struct{}{}
}
if _, present := fields["track_number"]; present {
result["TRACKNUMBER"] = struct{}{}
}
if _, present := fields["track_total"]; present {
result["TRACKNUMBER"] = struct{}{}
}
if _, present := fields["album_artist"]; present {
result["ALBUMARTIST"] = struct{}{}
}
@@ -507,6 +509,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
// deletion: the caller sends an empty value which is not serialized into
// newItems, but the old value must still be dropped.
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
// Build a set of keys being updated (upper-case for case-insensitive match)
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
for k := range overrideKeys {
combined[strings.ToUpper(k)] = struct{}{}
@@ -534,6 +537,7 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
return nil, fmt.Errorf("file too small for APE tag")
}
// Try footer at end of file
footer := make([]byte, apeTagHeaderSize)
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
return nil, fmt.Errorf("failed to read APE footer: %w", err)
@@ -562,7 +566,7 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
return nil, fmt.Errorf("no APEv2 tag found")
}
func parseAPETagFromFooter(r io.ReaderAt, _, footerOffset int64, footer []byte) (*APETag, error) {
func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) {
version := binary.LittleEndian.Uint32(footer[8:12])
tagSize := binary.LittleEndian.Uint32(footer[12:16])
itemCount := binary.LittleEndian.Uint32(footer[16:20])
-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")
}
}
+154 -112
View File
@@ -2,7 +2,6 @@ package gobackend
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
@@ -22,9 +21,7 @@ type AudioMetadata struct {
Year string
Date string
TrackNumber int
TotalTracks int
DiscNumber int
TotalDiscs int
ISRC string
Lyrics string
Label string
@@ -176,9 +173,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
case "TCO":
metadata.Genre = cleanGenre(value)
case "TRK":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
metadata.TrackNumber = parseTrackNumber(value)
case "TPA":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
metadata.DiscNumber = parseTrackNumber(value)
case "TCM":
metadata.Composer = value
case "TPB":
@@ -186,7 +183,7 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
case "TCR":
metadata.Copyright = value
case "ULT":
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXX":
@@ -295,9 +292,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
case "TCON":
metadata.Genre = cleanGenre(value)
case "TRCK":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
metadata.TrackNumber = parseTrackNumber(value)
case "TPOS":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
metadata.DiscNumber = parseTrackNumber(value)
case "TSRC":
metadata.ISRC = value
case "TCOM":
@@ -307,11 +304,11 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
case "TCOP":
metadata.Copyright = value
case "COMM":
if v := extractLangTextFrame(frameData); v != "" {
if v := extractCommentFrame(frameData); v != "" {
metadata.Comment = v
}
case "USLT":
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXXX":
@@ -391,9 +388,7 @@ func extractTextFrame(data []byte) string {
}
}
// extractLangTextFrame decodes ID3 frames with an encoding byte, 3-byte
// language code, and null-terminated descriptor before the text (COMM, USLT).
func extractLangTextFrame(data []byte) string {
func extractCommentFrame(data []byte) string {
if len(data) < 5 {
return ""
}
@@ -428,6 +423,42 @@ func extractLangTextFrame(data []byte) string {
return extractTextFrame(framed)
}
func extractLyricsFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
rest := data[4:]
var text []byte
switch encoding {
case 1, 2:
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:]
break
}
}
default:
idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:]
} else {
text = rest
}
}
if len(text) == 0 {
return ""
}
framed := make([]byte, 1+len(text))
framed[0] = encoding
copy(framed[1:], text)
return extractTextFrame(framed)
}
func extractUserTextFrame(data []byte) (string, string) {
if len(data) < 2 {
return "", ""
@@ -548,22 +579,13 @@ func cleanGenre(genre string) string {
return genre
}
func parseIndexPair(s string) (int, int) {
func parseTrackNumber(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0, 0
}
first := s
second := ""
if idx := strings.Index(s, "/"); idx > 0 {
first = s[:idx]
second = s[idx+1:]
s = s[:idx]
}
num, _ := strconv.Atoi(strings.TrimSpace(first))
total, _ := strconv.Atoi(strings.TrimSpace(second))
return num, total
num, _ := strconv.Atoi(s)
return num
}
func removeUnsync(data []byte) []byte {
@@ -1015,9 +1037,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
case "GENRE":
metadata.Genre = value
case "TRACKNUMBER", "TRACK":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
metadata.TrackNumber = parseTrackNumber(value)
case "DISCNUMBER", "DISC":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
metadata.DiscNumber = parseTrackNumber(value)
case "ISRC":
metadata.ISRC = value
case "COMPOSER":
@@ -1040,21 +1062,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
metadata.ReplayGainAlbumGain = value
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = value
// Opus gain tags (RFC 7845): Q7.8 fixed point on the R128 -23 LUFS
// reference. Exposed as ReplayGain 2 dB (-18 LUFS reference) so
// consumers see one representation; explicit REPLAYGAIN_* wins.
case "R128_TRACK_GAIN":
if metadata.ReplayGainTrackGain == "" {
if db, ok := r128ToReplayGainDb(value); ok {
metadata.ReplayGainTrackGain = db
}
}
case "R128_ALBUM_GAIN":
if metadata.ReplayGainAlbumGain == "" {
if db, ok := r128ToReplayGainDb(value); ok {
metadata.ReplayGainAlbumGain = db
}
}
}
}
@@ -1066,17 +1073,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
}
}
// r128ToReplayGainDb converts an R128_*_GAIN value (integer, 1/256 dB steps,
// -23 LUFS reference) to a ReplayGain 2 dB string (-18 LUFS reference):
// rg = q/256 + 5. Inverse of the writer's replayGainDbToR128.
func r128ToReplayGainDb(raw string) (string, bool) {
q, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return "", false
}
return fmt.Sprintf("%.2f dB", float64(q)/256.0+5.0), true
}
func GetOggQuality(filePath string) (*OggQuality, error) {
file, err := os.Open(filePath)
if err != nil {
@@ -1448,20 +1444,13 @@ func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
key := "METADATA_BLOCK_PICTURE="
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
cleaned := strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', ' ', '\t':
return -1
}
return r
}, string(comment[len(key):]))
decoded, err := base64.StdEncoding.DecodeString(cleaned)
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(cleaned)
}
b64Data := comment[len(key):]
decoded := make([]byte, base64StdDecodeLen(len(b64Data)))
n, err := base64StdDecode(decoded, b64Data)
if err != nil {
continue
}
decoded = decoded[:n]
imageData, mimeType := parseFLACPictureBlock(decoded)
if len(imageData) > 0 {
@@ -1515,6 +1504,71 @@ func parseFLACPictureBlock(data []byte) ([]byte, string) {
return imageData, mimeType
}
func base64StdDecodeLen(n int) int {
return n * 6 / 8
}
func base64StdDecode(dst, src []byte) (int, error) {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
decodeMap := make([]byte, 256)
for i := range decodeMap {
decodeMap[i] = 0xFF
}
for i := 0; i < len(alphabet); i++ {
decodeMap[alphabet[i]] = byte(i)
}
si, di := 0, 0
for si < len(src) {
for si < len(src) && (src[si] == '\n' || src[si] == '\r' || src[si] == ' ' || src[si] == '\t') {
si++
}
if si >= len(src) {
break
}
var vals [4]byte
var valCount int
for valCount < 4 && si < len(src) {
c := src[si]
si++
if c == '=' {
vals[valCount] = 0
valCount++
} else if c == '\n' || c == '\r' || c == ' ' || c == '\t' {
continue
} else if decodeMap[c] != 0xFF {
vals[valCount] = decodeMap[c]
valCount++
}
}
if valCount < 2 {
break
}
if di < len(dst) {
dst[di] = vals[0]<<2 | vals[1]>>4
di++
}
if valCount >= 3 && di < len(dst) {
dst[di] = vals[1]<<4 | vals[2]>>2
di++
}
if valCount >= 4 && di < len(dst) {
dst[di] = vals[2]<<6 | vals[3]
di++
}
}
return di, nil
}
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
return extractAnyCoverArtWithHint(filePath, "")
}
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == "" {
@@ -1554,14 +1608,19 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
}
return data, mimeType, nil
case ".wav", ".aiff", ".aif", ".aifc":
return extractWAVAIFFCover(filePath)
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
}
}
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
}
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
}
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
@@ -1575,56 +1634,39 @@ func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
return cacheKey
}
func libraryCoverCachePaths(cacheDir, cacheKey string) (string, string) {
hash := hashString(cacheKey)
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
return jpgPath, pngPath
}
func existingLibraryCoverCachePath(cacheDir, cacheKey string) string {
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
if _, err := os.Stat(jpgPath); err == nil {
return jpgPath
}
if _, err := os.Stat(pngPath); err == nil {
return pngPath
}
return ""
}
func saveLibraryCoverDataToCache(cacheDir, cacheKey string, imageData []byte, mimeType string) (string, error) {
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
return existing, nil
}
if len(imageData) == 0 {
return "", fmt.Errorf("cover data is empty")
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", fmt.Errorf("failed to create cache dir: %w", err)
}
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
cachePath := jpgPath
if strings.Contains(mimeType, "png") {
cachePath = pngPath
}
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover: %w", err)
}
return cachePath, nil
}
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
return existing, nil
hash := hashString(cacheKey)
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
if _, err := os.Stat(jpgPath); err == nil {
return jpgPath, nil
}
if _, err := os.Stat(pngPath); err == nil {
return pngPath, nil
}
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
if err != nil {
return "", err
}
return saveLibraryCoverDataToCache(cacheDir, cacheKey, imageData, mimeType)
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", fmt.Errorf("failed to create cache dir: %w", err)
}
var cachePath string
if strings.Contains(mimeType, "png") {
cachePath = pngPath
} else {
cachePath = jpgPath
}
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover: %w", err)
}
return cachePath, nil
}
@@ -1,507 +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 := 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)
}
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)
}
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)
}
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()
}
func TestR128ToReplayGainDb(t *testing.T) {
// -1280/256 = -5 dB on the R128 (-23 LUFS) scale -> 0 dB ReplayGain (-18).
if db, ok := r128ToReplayGainDb("-1280"); !ok || db != "0.00 dB" {
t.Fatalf("got %q ok=%v", db, ok)
}
if db, ok := r128ToReplayGainDb(" -2944 "); !ok || db != "-6.50 dB" {
t.Fatalf("got %q ok=%v", db, ok)
}
if _, ok := r128ToReplayGainDb("abc"); ok {
t.Fatal("expected failure for non-numeric input")
}
}
+1 -145
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,41 +27,14 @@ 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
}
func downloadCancelContext(itemID string) context.Context {
if itemID == "" {
return context.Background()
}
cancelMu.Lock()
defer cancelMu.Unlock()
if entry, ok := cancelMap[itemID]; ok && entry.ctx != nil {
return entry.ctx
}
return context.Background()
}
func cancelDownload(itemID string) {
if itemID == "" {
return
@@ -103,120 +67,12 @@ func isDownloadCancelled(itemID string) bool {
return canceled
}
// resetDownloadCancel removes a cancellation entry that has no active
// download attached (refs <= 0). Such entries exist to catch an item that is
// just about to start, but if the item never starts the flag lingers and the
// next explicit retry would consume it and abort immediately.
func resetDownloadCancel(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
if entry, ok := cancelMap[itemID]; ok && entry.refs <= 0 {
delete(cancelMap, itemID)
}
cancelMu.Unlock()
}
func clearDownloadCancel(itemID string) {
if itemID == "" {
return
}
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 extensionRequestCancelContext(requestID string) context.Context {
if requestID == "" {
return context.Background()
}
extensionRequestCancelMu.Lock()
defer extensionRequestCancelMu.Unlock()
if entry, ok := extensionRequestCancelMap[requestID]; ok && entry.ctx != nil {
return entry.ctx
}
return context.Background()
}
func cancelExtensionRequest(requestID string) {
if requestID == "" {
return
}
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()
}
+15 -114
View File
@@ -6,8 +6,6 @@ import (
"net/http"
"regexp"
"strings"
"sync"
"time"
)
const (
@@ -21,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)
@@ -54,115 +50,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
GoLog("[Cover] Final URL: %s", downloadURL)
data, err := fetchCoverCached(downloadURL)
if err != nil {
return nil, err
}
// Cached bytes are shared across goroutines and must never be mutated;
// hand callers their own copy.
return append([]byte(nil), data...), nil
}
const (
coverCacheMaxBytes = 24 * 1024 * 1024
coverCacheTTL = 15 * time.Minute
)
type coverCacheEntry struct {
data []byte
expiresAt time.Time
}
type coverInflightCall struct {
wg sync.WaitGroup
data []byte
err error
}
var (
coverMu sync.Mutex
coverCache = map[string]*coverCacheEntry{}
coverCacheBytes int
coverInflight = map[string]*coverInflightCall{}
coverFetch = fetchCoverBytes
)
func clearCoverMemoryCache() {
coverMu.Lock()
coverCache = map[string]*coverCacheEntry{}
coverCacheBytes = 0
coverMu.Unlock()
}
// fetchCoverCached returns cover bytes for a final URL, collapsing concurrent
// requests for the same URL into a single fetch (singleflight) and caching
// results in memory for the duration of an album batch. The returned slice is
// shared; callers must copy before mutating.
func fetchCoverCached(downloadURL string) ([]byte, error) {
coverMu.Lock()
if e, ok := coverCache[downloadURL]; ok {
if time.Now().Before(e.expiresAt) {
data := e.data
coverMu.Unlock()
return data, nil
}
delete(coverCache, downloadURL)
coverCacheBytes -= len(e.data)
}
if call, ok := coverInflight[downloadURL]; ok {
coverMu.Unlock()
call.wg.Wait()
return call.data, call.err
}
call := &coverInflightCall{}
// Default error so a panicking fetch never strands waiters with a
// (nil, nil) "success"; overwritten on normal completion.
call.err = fmt.Errorf("cover fetch aborted")
call.wg.Add(1)
coverInflight[downloadURL] = call
coverMu.Unlock()
defer func() {
call.wg.Done()
coverMu.Lock()
delete(coverInflight, downloadURL)
coverMu.Unlock()
}()
data, err := coverFetch(downloadURL)
call.data, call.err = data, err
if err == nil {
coverCachePut(downloadURL, data)
}
return data, err
}
func coverCachePut(downloadURL string, data []byte) {
if len(data) == 0 || len(data) > coverCacheMaxBytes {
return
}
coverMu.Lock()
defer coverMu.Unlock()
if e, ok := coverCache[downloadURL]; ok {
coverCacheBytes -= len(e.data)
}
coverCache[downloadURL] = &coverCacheEntry{data: data, expiresAt: time.Now().Add(coverCacheTTL)}
coverCacheBytes += len(data)
for coverCacheBytes > coverCacheMaxBytes && len(coverCache) > 1 {
var oldestKey string
var oldest time.Time
first := true
for k, e := range coverCache {
if first || e.expiresAt.Before(oldest) {
oldest, oldestKey, first = e.expiresAt, k, false
}
}
coverCacheBytes -= len(coverCache[oldestKey].data)
delete(coverCache, oldestKey)
}
}
func fetchCoverBytes(downloadURL string) ([]byte, error) {
client := NewHTTPClientWithTimeout(DefaultTimeout)
req, err := http.NewRequest("GET", downloadURL, nil)
@@ -248,9 +135,23 @@ 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")
}
return upgraded
}
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
return ""
}
result := convertSmallToMedium(imageURL)
if maxQuality {
result = upgradeToMaxQuality(result)
}
return result
}
-96
View File
@@ -1,96 +0,0 @@
package gobackend
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func resetCoverCache() {
coverMu.Lock()
coverCache = map[string]*coverCacheEntry{}
coverInflight = map[string]*coverInflightCall{}
coverCacheBytes = 0
coverMu.Unlock()
}
func TestFetchCoverCachedSingleflight(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
var calls int32
entered := make(chan struct{})
release := make(chan struct{})
coverFetch = func(string) ([]byte, error) {
if atomic.AddInt32(&calls, 1) == 1 {
close(entered)
}
<-release
return []byte("coverbytes"), nil
}
const url = "https://cdn.example/cover_max.jpg"
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if _, err := fetchCoverCached(url); err != nil {
t.Errorf("leader fetch error: %v", err)
}
}()
<-entered // leader has registered inflight and is blocked in coverFetch
wg.Add(1)
go func() {
defer wg.Done()
if _, err := fetchCoverCached(url); err != nil {
t.Errorf("follower fetch error: %v", err)
}
}()
close(release)
wg.Wait()
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected 1 fetch for concurrent requests, got %d", got)
}
}
func TestFetchCoverCachedTTLExpiry(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
var calls int32
coverFetch = func(string) ([]byte, error) {
atomic.AddInt32(&calls, 1)
return []byte("data"), nil
}
const url = "https://cdn.example/ttl.jpg"
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("first fetch error: %v", err)
}
// second call served from cache
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("second fetch error: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected cache hit, got %d fetches", got)
}
// expire the entry and confirm a refetch
coverMu.Lock()
coverCache[url].expiresAt = time.Now().Add(-time.Minute)
coverMu.Unlock()
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("third fetch error: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("expected refetch after TTL expiry, got %d fetches", got)
}
}
-402
View File
@@ -1,402 +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",
explicit: true
};
}
registerExtension({
searchTracks: function(query, limit) {
return { tracks: [track("search-1")], total: 1 };
},
customSearch: function(query, options) {
var t = track("custom-1");
t.name = "Custom " + query;
return [t];
},
getHomeFeed: function() {
return [{ id: "home-1", title: "Home", tracks: [track("home-track")] }];
},
getBrowseCategories: function() {
return [{ id: "cat-1", title: "Category" }];
},
getTrack: function(id) {
return track(id);
},
getAlbum: function(id) {
return {
id: id,
name: "Album " + id,
artists: "Artist",
artistId: "artist-1",
coverUrl: "https://example.test/album.jpg",
releaseDate: "2026-05-04",
totalTracks: 1,
albumType: "album",
tracks: [track("album-track")]
};
},
getPlaylist: function(id) {
return {
id: id,
name: "Playlist " + id,
artists: "Owner",
coverUrl: "https://example.test/playlist.jpg",
totalTracks: 1,
tracks: [track("playlist-track")]
};
},
getArtist: function(id) {
return {
id: id,
name: "Artist",
imageUrl: "https://example.test/artist.jpg",
headerImage: "https://example.test/header.jpg",
listeners: 123,
albums: [{ id: "album-1", name: "Album", artists: "Artist", totalTracks: 1 }],
releases: [{ id: "release-1", name: "Release", artists: "Artist", totalTracks: 1, tracks: [track("release-track")] }],
topTracks: [track("top-track")]
};
},
enrichTrack: function(input) {
var t = track(input.id || "enriched");
t.name = "Enriched";
return t;
},
checkAvailability: function(isrc, name, artist, ids) {
return { available: true, reason: "ok", trackId: "download-track", skipFallback: true };
},
getDownloadUrl: function(id, quality) {
return { url: "https://example.test/audio.flac", format: "flac", bitDepth: 24, sampleRate: 96000 };
},
download: function(id, quality, outputPath, onProgress) {
if (onProgress) onProgress(100);
return {
success: true,
filePath: "EXISTS:" + outputPath,
alreadyExists: false,
bitDepth: 24,
sampleRate: 96000,
title: "Downloaded",
artist: "Artist",
album: "Album",
albumArtist: "Album Artist",
trackNumber: 1,
totalTracks: 10,
discNumber: 1,
totalDiscs: 1,
releaseDate: "2026-05-04",
coverUrl: "https://example.test/cover.jpg",
isrc: "USRC17607839",
genre: "Pop",
label: "Label",
copyright: "Copyright",
composer: "Composer",
lyricsLrc: "[00:00.00]Hello",
decryptionKey: "001122",
decryption: { strategy: "mp4_decryption_key", options: { kid: "1" } }
};
},
fetchLyrics: function(name, artist, album, duration) {
return { syncType: "LINE_SYNCED", provider: "coverage-ext", lines: [{ startTimeMs: 0, endTimeMs: 1000, words: "Hello" }] };
},
handleUrl: function(url) {
return { type: "track", name: "Handled", coverUrl: "https://example.test/cover.jpg", track: track("url-track"), tracks: [track("url-track")], album: this.getAlbum("url-album"), artist: this.getArtist("url-artist") };
},
matchTrack: function(req) {
return { matched: true, trackId: "download-track", confidence: 0.95, reason: "exact" };
},
postProcess: function(path, req) {
return { success: true, newFilePath: path, bitDepth: 24, sampleRate: 96000 };
},
postProcessV2: function(input, metadata, hookId) {
return { success: true, newFilePath: input.path || input.uri, newFileUri: input.uri || "", bitDepth: 24, sampleRate: 96000 };
}
});
`
func mustReadFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file: %v", err)
}
return data
}
func buildID3v23Tag(frames ...[]byte) []byte {
body := bytes.Join(frames, nil)
header := []byte{'I', 'D', '3', 3, 0, 0, 0, 0, 0, 0}
copy(header[6:10], syncsafeBytes(len(body)))
return append(header, body...)
}
func id3TextFrame(id, value string) []byte {
return id3v23Frame(id, append([]byte{3}, []byte(value)...))
}
func id3CommentFrame(id, value string) []byte {
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
return id3v23Frame(id, payload)
}
func id3UserTextFrame(id, desc, value string) []byte {
payload := append([]byte{3}, []byte(desc)...)
payload = append(payload, 0)
payload = append(payload, []byte(value)...)
return id3v23Frame(id, payload)
}
func id3v23Frame(id string, payload []byte) []byte {
frame := make([]byte, 10+len(payload))
copy(frame[0:4], id)
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
copy(frame[10:], payload)
return frame
}
func buildID3v22Tag(frames ...[]byte) []byte {
body := bytes.Join(frames, nil)
header := []byte{'I', 'D', '3', 2, 0, 0, 0, 0, 0, 0}
copy(header[6:10], syncsafeBytes(len(body)))
return append(header, body...)
}
func id3v22TextFrame(id, value string) []byte {
return id3v22Frame(id, append([]byte{3}, []byte(value)...))
}
func id3v22CommentFrame(id, value string) []byte {
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
return id3v22Frame(id, payload)
}
func id3v22Frame(id string, payload []byte) []byte {
frame := make([]byte, 6+len(payload))
copy(frame[0:3], id)
size := len(payload)
frame[3] = byte(size >> 16)
frame[4] = byte(size >> 8)
frame[5] = byte(size)
copy(frame[6:], payload)
return frame
}
func syncsafeBytes(size int) []byte {
return []byte{
byte((size >> 21) & 0x7f),
byte((size >> 14) & 0x7f),
byte((size >> 7) & 0x7f),
byte(size & 0x7f),
}
}
func buildID3v1Tag(title, artist, album, year string, track, genre byte) []byte {
tag := make([]byte, 128)
copy(tag[0:3], "TAG")
copyPadded(tag[3:33], title)
copyPadded(tag[33:63], artist)
copyPadded(tag[63:93], album)
copyPadded(tag[93:97], year)
tag[125] = 0
tag[126] = track
tag[127] = genre
return tag
}
func copyPadded(dst []byte, value string) {
for i := range dst {
dst[i] = ' '
}
copy(dst, value)
}
func writeExportCueFixture(t *testing.T, dir string) (string, string) {
t.Helper()
audioPath := filepath.Join(dir, "exports.wav")
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
t.Fatalf("write export audio: %v", err)
}
cuePath := filepath.Join(dir, "exports.cue")
cue := "PERFORMER \"Artist\"\nTITLE \"Album\"\nFILE \"exports.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"Song\"\n INDEX 01 00:00:00\n"
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
t.Fatalf("write export cue: %v", err)
}
return cuePath, audioPath
}
func escapeJSONPath(path string) string {
data, _ := json.Marshal(path)
return strings.Trim(string(data), `"`)
}
func fakeDeezerResponse(path, rawQuery string) string {
switch {
case path == "/2.0/search/track":
if strings.Contains(rawQuery, "MISSING") {
return `{"data":[]}`
}
return `{"data":[` + fakeDeezerTrackJSON(101, true) + `]}`
case path == "/2.0/search/artist":
return `{"data":[{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123}]}`
case path == "/2.0/search/album":
return `{"data":[{"id":201,"title":"Album","cover_xl":"album-xl","nb_tracks":2,"release_date":"2026-05-04","record_type":"compile","artist":{"id":301,"name":"Artist"}}]}`
case path == "/2.0/search/playlist":
return `{"data":[{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"user":{"name":"Owner"}}]}`
case path == "/2.0/track/101", path == "/2.0/track/isrc:USRC17607839":
return fakeDeezerTrackJSON(101, true)
case path == "/2.0/track/102":
return fakeDeezerTrackJSON(102, true)
case path == "/2.0/track/isrc:MISSING":
return `{"id":0}`
case path == "/2.0/album/201":
return `{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","nb_tracks":2,"record_type":"compile","label":"Label","copyright":"Copyright","genres":{"data":[{"name":"Pop"},{"name":"Dance"}]},"artist":{"id":301,"name":"Album Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
case path == "/2.0/artist/301":
return `{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123,"nb_album":1}`
case path == "/2.0/artist/301/albums":
return `{"data":[{"id":201,"title":"Album","release_date":"2026-05-04","nb_tracks":0,"cover_xl":"album-xl","record_type":"compile"}]}`
case path == "/2.0/artist/301/related":
return `{"data":[{"id":302,"name":"Related","picture_xl":"related-xl","nb_fan":10}]}`
case path == "/2.0/playlist/401":
return `{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"creator":{"name":"Owner"},"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
default:
return ""
}
}
func fakeDeezerTrackJSON(id int, withISRC bool) string {
isrc := ""
if withISRC {
isrc = `,"isrc":"USRC17607839"`
if id == 102 {
isrc = `,"isrc":"USRC17607840"`
}
}
return fmt.Sprintf(`{"id":%d,"title":"Track %d","duration":180,"track_position":%d,"disk_number":1%s,"link":"https://deezer.test/track/%d","release_date":"2026-05-04","artist":{"id":301,"name":"Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"album":{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","record_type":"album"}}`, id, id, id-100, isrc, id)
}
func createTestExtensionPackage(t *testing.T, path, name, version, js string, extraFiles map[string]string) {
t.Helper()
out, err := os.Create(path)
if err != nil {
t.Fatalf("create extension package: %v", err)
}
defer out.Close()
zw := zip.NewWriter(out)
defer zw.Close()
manifest := fmt.Sprintf(`{
"name": %q,
"displayName": %q,
"version": %q,
"description": "Packaged test extension",
"type": ["metadata_provider", "download_provider", "lyrics_provider"],
"permissions": {"network": ["example.test"], "storage": true, "file": true},
"icon": "icon.png",
"settings": [{"key":"quality","type":"string","label":"Quality"}],
"qualityOptions": [{"id":"lossless","label":"Lossless","description":"Lossless"}],
"searchBehavior": {"enabled": true, "placeholder": "Search", "primary": true},
"urlHandler": {"enabled": true, "patterns": ["https://example.test/"]},
"trackMatching": {"customMatching": true},
"postProcessing": {"enabled": true, "hooks": [{"id":"hook","name":"Hook"}]},
"serviceHealth": [{"id":"main","url":"https://example.test/health"}],
"capabilities": {"homeFeed": true}
}`, name, name, version)
for fileName, content := range map[string]string{
"manifest.json": manifest,
"index.js": js,
"icon.png": "png",
} {
writer, err := zw.Create(fileName)
if err != nil {
t.Fatalf("zip create %s: %v", fileName, err)
}
if _, err := writer.Write([]byte(content)); err != nil {
t.Fatalf("zip write %s: %v", fileName, err)
}
}
for fileName, content := range extraFiles {
writer, err := zw.Create(fileName)
if err != nil {
t.Fatalf("zip create extra %s: %v", fileName, err)
}
if _, err := writer.Write([]byte(content)); err != nil {
t.Fatalf("zip write extra %s: %v", fileName, err)
}
}
}
-442
View File
@@ -1,442 +0,0 @@
package gobackend
import (
"encoding/json"
"sort"
"strings"
"sync"
)
type CrossExtensionShareResult struct {
ExtensionID string `json:"extension_id"`
DisplayName string `json:"display_name"`
Found bool `json:"found"`
URL string `json:"url,omitempty"`
ItemName string `json:"item_name,omitempty"`
ItemArtists string `json:"item_artists,omitempty"`
Error string `json:"error,omitempty"`
}
var crossExtensionShareResultCache = struct {
sync.RWMutex
entries map[string]string
order []string
}{
entries: make(map[string]string),
}
const crossExtensionShareResultCacheLimit = 128
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
var req struct {
Name string `json:"name"`
Artists string `json:"artists"`
Type string `json:"type"`
SourceExtensionID string `json:"source_extension_id"`
}
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", err
}
req.Name = strings.TrimSpace(req.Name)
req.Artists = strings.TrimSpace(req.Artists)
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
if req.Name == "" {
return "[]", nil
}
if req.Type == "" {
req.Type = "album"
}
providers := getExtensionManager().GetMetadataProviders()
work := make([]*extensionProviderWrapper, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
if provider.extension.ID == req.SourceExtensionID {
continue
}
work = append(work, provider)
}
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
return cached, nil
}
query := req.Name
if req.Artists != "" {
query += " " + req.Artists
}
results := make([]CrossExtensionShareResult, len(work))
var wg sync.WaitGroup
for i, provider := range work {
wg.Add(1)
go func(index int, p *extensionProviderWrapper) {
defer wg.Done()
results[index] = findCollectionForExtension(
p,
req.Type,
req.Name,
req.Artists,
query,
)
}(i, provider)
}
wg.Wait()
data, err := json.Marshal(results)
if err != nil {
return "[]", err
}
response := string(data)
if crossExtensionShareResultsCacheable(results) {
setCrossExtensionShareCache(cacheKey, response)
}
return response, nil
}
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
providerKeys := make([]string, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
ext := provider.extension
displayName := ""
if ext.Manifest != nil {
displayName = ext.Manifest.DisplayName
}
providerKeys = append(providerKeys, strings.Join([]string{
strings.TrimSpace(ext.ID),
strings.TrimSpace(displayName),
strings.TrimSpace(ext.SourceDir),
}, "\x1f"))
}
sort.Strings(providerKeys)
return strings.Join([]string{
normalizeLooseTitle(itemType),
normalizeLooseTitle(name),
normalizeLooseArtistName(artists),
strings.TrimSpace(sourceExtensionID),
strings.Join(providerKeys, "\x1e"),
}, "\x1d")
}
func getCrossExtensionShareCache(key string) string {
if key == "" {
return ""
}
crossExtensionShareResultCache.RLock()
defer crossExtensionShareResultCache.RUnlock()
return crossExtensionShareResultCache.entries[key]
}
func setCrossExtensionShareCache(key string, value string) {
if key == "" || value == "" {
return
}
crossExtensionShareResultCache.Lock()
defer crossExtensionShareResultCache.Unlock()
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
}
crossExtensionShareResultCache.entries[key] = value
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
oldest := crossExtensionShareResultCache.order[0]
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
delete(crossExtensionShareResultCache.entries, oldest)
}
}
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
for _, result := range results {
if result.Found {
continue
}
errText := strings.ToLower(strings.TrimSpace(result.Error))
if errText == "" ||
errText == "no results" ||
errText == "unsupported collection type" ||
strings.HasSuffix(errText, " not found") ||
strings.Contains(errText, "found without shareable link") {
continue
}
return false
}
return true
}
func findCollectionForExtension(
provider *extensionProviderWrapper,
itemType string,
name string,
artists string,
query string,
) CrossExtensionShareResult {
result := CrossExtensionShareResult{
ExtensionID: provider.extension.ID,
}
if provider.extension.Manifest != nil {
result.DisplayName = provider.extension.Manifest.DisplayName
}
if result.DisplayName == "" {
result.DisplayName = provider.extension.ID
}
searchResult, err := searchCollectionCandidates(provider, itemType, query)
if err != nil {
result.Error = err.Error()
return result
}
if searchResult == nil || len(searchResult.Tracks) == 0 {
result.Error = "no results"
return result
}
var best *ExtTrackMetadata
switch itemType {
case "artist":
best = bestArtistTrack(searchResult.Tracks, name)
case "album":
best = bestAlbumTrack(searchResult.Tracks, name, artists)
default:
result.Error = "unsupported collection type"
return result
}
if best == nil {
result.Error = itemType + " not found"
return result
}
url := resolveCollectionShareURL(provider.extension, itemType, best)
if url == "" {
result.Error = itemType + " found without shareable link"
return result
}
result.Found = true
result.URL = url
if itemType == "artist" {
result.ItemName = collectionArtistName(*best)
} else {
result.ItemName = collectionAlbumName(*best)
result.ItemArtists = best.Artists
}
return result
}
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
filter := ""
switch itemType {
case "album":
filter = "albums"
case "artist":
filter = "artists"
}
if filter != "" {
tracks, err := provider.CustomSearch(query, map[string]any{
"filter": filter,
"limit": 10,
})
if err == nil && len(tracks) > 0 {
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
}
}
return provider.SearchTracks(query, 10)
}
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
targetAlbum := normalizeLooseTitle(albumName)
targetArtists := normalizeLooseArtistName(artists)
bestScore := 0
bestIndex := -1
for i := range tracks {
track := tracks[i]
album := normalizeLooseTitle(collectionAlbumName(track))
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
score := 0
if isCollectionItemType(track, "album") {
score += 25
}
if album == targetAlbum {
score += 100
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
score += 50
}
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
score += 30
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 50 {
return nil
}
return &tracks[bestIndex]
}
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
targetArtist := normalizeLooseArtistName(artistName)
bestScore := 0
bestIndex := -1
for i := range tracks {
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
score := 0
if isCollectionItemType(tracks[i], "artist") {
score += 25
}
if artist == targetArtist {
score += 100
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
score += 60
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 60 {
return nil
}
return &tracks[bestIndex]
}
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
if track == nil {
return ""
}
if itemType == "album" {
if isCollectionItemType(*track, "album") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.AlbumURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
return url
}
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
return url
}
return ""
}
if isCollectionItemType(*track, "artist") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.ArtistURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
return url
}
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
return url
}
return ""
}
func collectionAlbumName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "album") {
return track.Name
}
return track.AlbumName
}
func collectionArtistName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "artist") {
return track.Name
}
return track.Artists
}
func collectionID(track ExtTrackMetadata, itemType string) string {
if isCollectionItemType(track, itemType) {
return track.ID
}
return ""
}
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
}
func normalizeShareURL(value string) string {
trimmed := strings.TrimSpace(value)
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
return trimmed
}
return ""
}
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
for key, value := range links {
if strings.Contains(strings.ToLower(key), preferredKey) {
if url := normalizeShareURL(value); url != "" {
return url
}
}
}
return ""
}
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return ""
}
id = stripProviderPrefix(strings.TrimSpace(id))
if id == "" {
return ""
}
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]any)
if !ok {
return ""
}
rawTemplate, ok := templates[itemType].(string)
if !ok {
return ""
}
rawTemplate = strings.TrimSpace(rawTemplate)
if rawTemplate == "" {
return ""
}
return strings.ReplaceAll(rawTemplate, "{id}", id)
}
func stripProviderPrefix(id string) string {
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
return id[index+1:]
}
return id
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
-100
View File
@@ -1,100 +0,0 @@
package gobackend
import "testing"
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]any{
"shareUrlTemplates": map[string]any{
"album": "https://music.apple.com/us/album/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "1440783617",
Name: "Nevermind",
Artists: "Nirvana",
ItemType: "album",
},
}
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
if best == nil {
t.Fatal("expected album collection item to match")
}
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
t.Fatalf("album share URL = %q", url)
}
}
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]any{
"shareUrlTemplates": map[string]any{
"artist": "https://music.youtube.com/browse/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "UCrPe3hLA51968GwxHSZ1llw",
Name: "Nirvana",
ItemType: "artist",
},
}
best := bestArtistTrack(tracks, "Nirvana")
if best == nil {
t.Fatal("expected artist collection item to match")
}
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
t.Fatalf("artist share URL = %q", url)
}
}
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
apple := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "apple",
SourceDir: "/extensions/apple",
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
},
}
qobuz := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "qobuz",
SourceDir: "/extensions/qobuz",
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
},
}
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
if first != second {
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
}
}
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
cacheable := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "album not found"},
{ExtensionID: "tidal", Error: "no results"},
}
if !crossExtensionShareResultsCacheable(cacheable) {
t.Fatal("expected found and deterministic not-found results to be cacheable")
}
transient := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "request failed: timeout"},
}
if crossExtensionShareResultsCacheable(transient) {
t.Fatal("expected transient extension errors to skip cache")
}
}
-267
View File
@@ -1,267 +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 writeTestFlacWithISRC(t *testing.T, path, isrc string) {
t.Helper()
le32 := func(v int) []byte {
return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)}
}
vendor := "test-vendor"
comments := [][]byte{
[]byte("TITLE=Song"),
[]byte("ISRC=" + isrc),
}
var vorbis []byte
vorbis = append(vorbis, le32(len(vendor))...)
vorbis = append(vorbis, vendor...)
vorbis = append(vorbis, le32(len(comments))...)
for _, comment := range comments {
vorbis = append(vorbis, le32(len(comment))...)
vorbis = append(vorbis, comment...)
}
blockHeader := func(last bool, blockType byte, length int) []byte {
first := blockType
if last {
first |= 0x80
}
return []byte{first, byte(length >> 16), byte(length >> 8), byte(length)}
}
var data []byte
data = append(data, "fLaC"...)
data = append(data, blockHeader(false, 0, 34)...) // STREAMINFO
data = append(data, make([]byte, 34)...)
picture := make([]byte, 4096) // stands in for embedded cover art
data = append(data, blockHeader(false, 6, len(picture))...)
data = append(data, picture...)
data = append(data, blockHeader(true, 4, len(vorbis))...)
data = append(data, vorbis...)
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
}
func TestISRCIndexIncrementalRebuild(t *testing.T) {
dir := t.TempDir()
trackA := filepath.Join(dir, "a.flac")
trackB := filepath.Join(dir, "b.flac")
writeTestFlacWithISRC(t, trackA, "USAA00000001")
writeTestFlacWithISRC(t, trackB, "USBB00000002")
defer InvalidateISRCCache(dir)
if got := readFlacISRC(trackA); got != "USAA00000001" {
t.Fatalf("readFlacISRC = %q", got)
}
if got := readFlacISRC(filepath.Join(dir, "missing.flac")); got != "" {
t.Fatalf("expected empty ISRC for missing file, got %q", got)
}
idx := buildISRCIndex(dir)
if path, ok := idx.lookup("usaa00000001"); !ok || path != trackA {
t.Fatalf("lookup A = %q/%v", path, ok)
}
if path, ok := idx.lookup("USBB00000002"); !ok || path != trackB {
t.Fatalf("lookup B = %q/%v", path, ok)
}
// Change one file's tag (and mtime); an incremental rebuild must pick up
// the change while adopting the untouched file from the cache.
writeTestFlacWithISRC(t, trackB, "USBB00000099")
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(trackB, future, future); err != nil {
t.Fatal(err)
}
rebuilt := buildISRCIndex(dir)
if _, ok := rebuilt.lookup("USBB00000002"); ok {
t.Fatal("expected stale ISRC to disappear after rebuild")
}
if path, ok := rebuilt.lookup("USBB00000099"); !ok || path != trackB {
t.Fatalf("rebuilt lookup = %q/%v", path, ok)
}
if path, ok := rebuilt.lookup("USAA00000001"); !ok || path != trackA {
t.Fatalf("cached entry lost on rebuild: %q/%v", path, ok)
}
// Add() keeps the index fresh so the TTL never forces a rebuild while
// downloads are actively maintaining it.
rebuilt.buildTime.Store(time.Now().Add(-isrcIndexTTL - time.Minute).UnixNano())
if rebuilt.isFresh() {
t.Fatal("expected stale index")
}
rebuilt.Add("USCC00000003", trackA)
if !rebuilt.isFresh() {
t.Fatal("expected Add to refresh the index timestamp")
}
}
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "song.flac")
if err := os.WriteFile(filePath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir}
idx.buildTime.Store(time.Now().UnixNano())
idx.Add("usrc17607839", filePath)
if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath {
t.Fatalf("lookup = %q/%v", got, ok)
}
if got, err := idx.Lookup("usrc17607839"); err != nil || got != filePath {
t.Fatalf("Lookup = %q/%v", got, err)
}
idx.remove("usrc17607839")
if _, ok := idx.lookup("usrc17607839"); ok {
t.Fatal("expected removed ISRC")
}
isrcIndexCacheMu.Lock()
isrcIndexCache[dir] = idx
isrcIndexCacheMu.Unlock()
defer InvalidateISRCCache(dir)
AddToISRCIndex(dir, "USRC17607839", filePath)
if found, err := CheckISRCExists(dir, "USRC17607839"); err != nil || found != filePath {
t.Fatalf("CheckISRCExists = %q/%v", found, err)
}
if !CheckFileExists(filePath) || CheckFileExists(dir) || CheckFileExists(filepath.Join(dir, "missing.flac")) {
t.Fatal("unexpected file existence result")
}
tracksJSON := `[{"isrc":"USRC17607839","track_name":"Song","artist_name":"Artist"},{"isrc":"MISSING","track_name":"Other","artist_name":"Artist"}]`
resultJSON, err := CheckFilesExistParallel(dir, tracksJSON)
if err != nil {
t.Fatalf("CheckFilesExistParallel: %v", err)
}
var results []FileExistenceResult
if err := json.Unmarshal([]byte(resultJSON), &results); err != nil {
t.Fatalf("decode results: %v", err)
}
if !results[0].Exists || results[0].FilePath != filePath || results[1].Exists {
t.Fatalf("results = %#v", results)
}
if _, err := CheckFilesExistParallel(dir, `not-json`); err == nil {
t.Fatal("expected invalid json error")
}
if err := PreBuildISRCIndex(""); err == nil {
t.Fatal("expected empty dir error")
}
}
+1 -9
View File
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
}
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, baseName+ext)
if _, err := os.Stat(candidate); err == nil {
@@ -513,11 +513,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
album = "Unknown Album"
}
composer := track.Composer
if composer == "" {
composer = sheet.Composer
}
var duration int
if i+1 < len(sheet.Tracks) {
nextStart := sheet.Tracks[i+1].StartTime
@@ -544,15 +539,12 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
ScannedAt: scanTime,
ISRC: track.ISRC,
TrackNumber: track.Number,
TotalTracks: len(sheet.Tracks),
DiscNumber: 1,
TotalDiscs: 1,
Duration: duration,
ReleaseDate: sheet.Date,
BitDepth: bitDepth,
SampleRate: sampleRate,
Genre: sheet.Genre,
Composer: composer,
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
}
+25 -64
View File
@@ -3,7 +3,6 @@ package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -163,26 +162,17 @@ func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
}
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
Duration int `json:"duration"`
TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
Link string `json:"link"`
ReleaseDate string `json:"release_date"`
ExplicitLyrics bool `json:"explicit_lyrics"`
ExplicitContentLyrics int `json:"explicit_content_lyrics"`
Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"`
}
// deezerTrackIsExplicit maps Deezer's parental-advisory fields to a boolean:
// explicit_lyrics is the boolean flag, explicit_content_lyrics uses 1 to mean
// explicit (0 = clean, 2 = unknown).
func deezerTrackIsExplicit(track deezerTrack) bool {
return track.ExplicitLyrics || track.ExplicitContentLyrics == 1
ID int64 `json:"id"`
Title string `json:"title"`
Duration int `json:"duration"`
TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
Link string `json:"link"`
ReleaseDate string `json:"release_date"`
Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"`
}
type deezerArtist struct {
@@ -254,7 +244,6 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
ISRC: track.ISRC,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
Explicit: deezerTrackIsExplicit(track),
}
}
@@ -641,12 +630,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
totalDiscs := 0
for _, track := range allTracks {
if track.DiskNumber > totalDiscs {
totalDiscs = track.DiskNumber
}
}
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
albumType := album.RecordType
@@ -675,12 +658,10 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
TrackNumber: trackNum,
TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber,
TotalDiscs: totalDiscs,
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
AlbumType: albumType,
Explicit: deezerTrackIsExplicit(track),
})
}
@@ -795,6 +776,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
// not include this field. Albums whose track count is already known (non-zero)
// are skipped.
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
// Find albums that need track counts
type indexedID struct {
idx int
albumID string
@@ -990,7 +972,6 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
Explicit: deezerTrackIsExplicit(track),
})
}
@@ -1263,16 +1244,12 @@ func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc strin
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
}
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst any) error {
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
var lastErr error
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if attempt > 0 {
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
var apiErr *deezerAPIError
if errors.As(lastErr, &apiErr) && apiErr.RetryAfter > 0 {
delay = apiErr.RetryAfter
}
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay)
}
@@ -1283,7 +1260,16 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst any) er
}
lastErr = err
if !isDeezerRetryableError(err) {
errStr := err.Error()
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429")
if !isRetryable {
return err
}
@@ -1293,38 +1279,13 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst any) er
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
}
type deezerAPIError struct {
StatusCode int
Body string
RetryAfter time.Duration
}
func (e *deezerAPIError) Error() string {
return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body)
}
func isDeezerRetryableError(err error) bool {
if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
var apiErr *deezerAPIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
}
return false
}
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst any) error {
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
// Without an explicit language Deezer localizes artist/genre names by
// the caller's IP geolocation (issue #480: Arabic metadata on an
// English device). Follow the app's display language instead.
req.Header.Set("Accept-Language", metadataAcceptLanguage())
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -1338,7 +1299,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst any)
}
if resp.StatusCode != http.StatusOK {
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: getRetryAfterDuration(resp)}
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
}
return json.Unmarshal(body, dst)
+444
View File
@@ -0,0 +1,444 @@
package gobackend
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr"
type DeezerDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
}
func isLikelySpotifyTrackID(value string) bool {
if len(value) != 22 {
return false
}
for _, r := range value {
switch {
case r >= 'A' && r <= 'Z':
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
default:
return false
}
}
return true
}
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
deezerID := strings.TrimSpace(req.DeezerID)
if deezerID == "" {
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
deezerID = strings.TrimSpace(prefixed)
}
}
if deezerID != "" {
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
// Don't reject direct IDs from request payload — they're presumably correct.
}
return trackURL, nil
}
spotifyID := strings.TrimSpace(req.SpotifyID)
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
songlink := NewSongLinkClient()
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
if err == nil && availability.Deezer && availability.DeezerURL != "" {
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
// Fall through to ISRC search instead of using wrong track.
} else {
return availability.DeezerURL, nil
}
} else {
return availability.DeezerURL, nil
}
}
}
isrc := strings.TrimSpace(req.ISRC)
if isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
if err == nil && track != nil {
resolvedID := songLinkExtractDeezerTrackID(track)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
}
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
}
}
}
return "", fmt.Errorf("could not resolve Deezer track URL")
}
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
if err != nil {
return nil // Can't verify — don't block the download.
}
resolved := resolvedTrackInfo{
Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists,
ISRC: trackResp.Track.ISRC,
Duration: trackResp.Track.DurationMS / 1000,
SkipNameVerification: skipNameVerification,
}
if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
}
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
return nil
}
type deezerMusicDLRequest struct {
Platform string `json:"platform"`
URL string `json:"url"`
}
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
payload := deezerMusicDLRequest{
Platform: "deezer",
URL: deezerTrackURL,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("MusicDL request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
}
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
return "", fmt.Errorf("MusicDL error: %s", errMsg)
}
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
}
if data, ok := raw["data"].(map[string]any); ok {
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
}
}
return "", fmt.Errorf("no download URL found in MusicDL response")
}
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
if err != nil {
return err
}
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return err
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush output: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close output: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
return nil
}
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
deezerClient := GetDeezerClient()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
var outputPath string
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
}
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
coverURL := req.CoverURL
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
embedLyrics = false
}
parallelResult = FetchCoverAndLyricsParallel(
coverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
embedLyrics,
int64(req.DurationMS),
)
}()
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
if deezerURLErr != nil {
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed: could not resolve Deezer URL: %w",
deezerURLErr,
)
}
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
downloadErr := deezerClient.DownloadFromMusicDL(
deezerTrackURL,
outputPath,
req.OutputFD,
req.ItemID,
)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
return DeezerDownloadResult{}, ErrDownloadCancelled
}
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed via MusicDL: %w",
downloadErr,
)
}
<-parallelDone
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
}
if isSafOutput || !req.EmbedMetadata {
if !req.EmbedMetadata {
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
} else {
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
}
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
}
}
}
}
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
}
bitDepth, sampleRate := 0, 0
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
}
lyricsLRC := ""
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return DeezerDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
}, nil
}
-153
View File
@@ -1,153 +0,0 @@
package gobackend
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
)
func TestDeezerClientWithFakeHTTP(t *testing.T) {
client := &DeezerClient{
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
status := http.StatusOK
if body == "" {
status = http.StatusNotFound
body = `{"error":"missing"}`
}
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})},
searchCache: map[string]*cacheEntry{},
albumCache: map[string]*cacheEntry{},
artistCache: map[string]*cacheEntry{},
isrcCache: map[string]string{},
cacheCleanupInterval: time.Millisecond,
}
ctx := context.Background()
search, err := client.SearchAll(ctx, "artist song", 2, 2, "")
if err != nil {
t.Fatalf("SearchAll: %v", err)
}
if len(search.Tracks) != 1 || len(search.Artists) != 1 || len(search.Albums) != 1 || len(search.Playlists) != 1 {
t.Fatalf("search = %#v", search)
}
cached, err := client.SearchAll(ctx, "artist song", 2, 2, "")
if err != nil || cached != search {
t.Fatalf("cached SearchAll = %#v/%v", cached, err)
}
if filtered, err := client.SearchAll(ctx, "artist song", 1, 1, "track"); err != nil || len(filtered.Tracks) != 1 || len(filtered.Artists) != 0 {
t.Fatalf("filtered search = %#v/%v", filtered, err)
}
track, err := client.GetTrack(ctx, "101")
if err != nil {
t.Fatalf("GetTrack: %v", err)
}
if track.Track.SpotifyID != "deezer:101" || track.Track.Artists != "Contributor A, Contributor B" {
t.Fatalf("track = %#v", track)
}
album, err := client.GetAlbum(ctx, "201")
if err != nil {
t.Fatalf("GetAlbum: %v", err)
}
if album.AlbumInfo.Name != "Album" || len(album.TrackList) != 2 || album.TrackList[1].ISRC == "" {
t.Fatalf("album = %#v", album)
}
if cachedAlbum, err := client.GetAlbum(ctx, "201"); err != nil || cachedAlbum != album {
t.Fatalf("cached album = %#v/%v", cachedAlbum, err)
}
artist, err := client.GetArtist(ctx, "301")
if err != nil {
t.Fatalf("GetArtist: %v", err)
}
if artist.ArtistInfo.Name != "Artist" || len(artist.Albums) != 1 || artist.Albums[0].TotalTracks == 0 {
t.Fatalf("artist = %#v", artist)
}
if cachedArtist, err := client.GetArtist(ctx, "301"); err != nil || cachedArtist != artist {
t.Fatalf("cached artist = %#v/%v", cachedArtist, err)
}
related, err := client.GetRelatedArtists(ctx, "deezer:301", 3)
if err != nil {
t.Fatalf("GetRelatedArtists: %v", err)
}
if len(related) != 1 || related[0].ID != "deezer:302" {
t.Fatalf("related = %#v", related)
}
if _, err := client.GetRelatedArtists(ctx, "", 0); err == nil {
t.Fatal("expected invalid related artist ID")
}
playlist, err := client.GetPlaylist(ctx, "401")
if err != nil {
t.Fatalf("GetPlaylist: %v", err)
}
if playlist.PlaylistInfo.Tracks.Total != 2 || len(playlist.TrackList) != 2 {
t.Fatalf("playlist = %#v", playlist)
}
byISRC, err := client.SearchByISRC(ctx, "USRC17607839")
if err != nil {
t.Fatalf("SearchByISRC: %v", err)
}
if byISRC.SpotifyID != "deezer:101" {
t.Fatalf("by ISRC = %#v", byISRC)
}
if _, err := client.SearchByISRC(ctx, "MISSING"); err == nil {
t.Fatal("expected missing ISRC error")
}
isrc, err := client.GetTrackISRC(ctx, "102")
if err != nil || isrc != "USRC17607840" {
t.Fatalf("GetTrackISRC = %q/%v", isrc, err)
}
albumID, err := client.GetTrackAlbumID(ctx, "101")
if err != nil || albumID != "201" {
t.Fatalf("GetTrackAlbumID = %q/%v", albumID, err)
}
extended, err := client.GetAlbumExtendedMetadata(ctx, "201")
if err != nil {
t.Fatalf("GetAlbumExtendedMetadata: %v", err)
}
if extended.Genre != "Pop, Dance" || extended.Label != "Label" {
t.Fatalf("extended = %#v", extended)
}
if byTrack, err := client.GetExtendedMetadataByTrackID(ctx, "101"); err != nil || byTrack.Label != "Label" {
t.Fatalf("metadata by track = %#v/%v", byTrack, err)
}
if byISRCMeta, err := client.GetExtendedMetadataByISRC(ctx, "USRC17607839"); err != nil || byISRCMeta.Label != "Label" {
t.Fatalf("metadata by isrc = %#v/%v", byISRCMeta, err)
}
if _, err := client.GetExtendedMetadataByISRC(ctx, ""); err == nil {
t.Fatal("expected empty ISRC metadata error")
}
if typ, id, err := parseDeezerURL("https://www.deezer.com/us/track/101"); err != nil || typ != "track" || id != "101" {
t.Fatalf("parseDeezerURL = %q/%q/%v", typ, id, err)
}
if _, _, err := parseDeezerURL("https://example.com/track/101"); err == nil {
t.Fatal("expected non-Deezer URL error")
}
client.cacheMu.Lock()
client.searchCache["expired"] = &cacheEntry{expiresAt: time.Now().Add(-time.Hour)}
client.searchCache["keep1"] = &cacheEntry{expiresAt: time.Now().Add(time.Hour)}
client.searchCache["keep2"] = &cacheEntry{expiresAt: time.Now().Add(2 * time.Hour)}
client.pruneExpiredCacheEntriesLocked(client.searchCache, time.Now())
client.trimCacheEntriesLocked(client.searchCache, 1)
client.isrcCache["1"] = "A"
client.isrcCache["2"] = "B"
client.trimStringCacheEntriesLocked(client.isrcCache, 1)
client.cacheMu.Unlock()
}
-237
View File
@@ -1,237 +0,0 @@
package gobackend
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
"golang.org/x/net/dns/dnsmessage"
)
// DNS-over-HTTPS fallback for DNS-level ISP blocking. The OS resolver stays
// the primary path; only when it fails with a DNS error (NXDOMAIN, SERVFAIL,
// refused, resolver timeout) is the host re-resolved over DoH to hardcoded
// resolver IPs and dialed directly. TLS verification still runs against the
// original hostname, so a bad answer cannot silently redirect traffic.
var dohUpstreams = []string{
"https://1.1.1.1/dns-query",
"https://8.8.8.8/dns-query",
}
// Upstream URLs are literal IPs, so this client never needs DNS itself.
var dohClient = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
MaxIdleConnsPerHost: 1,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ForceAttemptHTTP2: true,
TLSClientConfig: newTLSCompatibilityConfig(false),
},
Timeout: 10 * time.Second,
}
const (
dohCacheMaxEntries = 256
dohCacheMinTTL = time.Minute
dohCacheMaxTTL = 30 * time.Minute
dohCacheErrorTTL = 30 * time.Second
)
type dohCacheEntry struct {
ips []net.IP
expiresAt time.Time
}
var (
dohMu sync.Mutex
dohCache = map[string]dohCacheEntry{}
)
// dialWithDoHFallback dials addr normally and, when the failure is a DNS
// error, retries with DoH-resolved addresses. Non-DNS failures pass through
// untouched.
func dialWithDoHFallback(ctx context.Context, dialer *net.Dialer, network, addr string) (net.Conn, error) {
conn, err := dialer.DialContext(ctx, network, addr)
if err == nil {
return conn, nil
}
var dnsErr *net.DNSError
if !errors.As(err, &dnsErr) {
return nil, err
}
host, port, splitErr := net.SplitHostPort(addr)
if splitErr != nil || net.ParseIP(host) != nil {
return nil, err
}
ips, dohErr := dohResolve(ctx, host)
if dohErr != nil {
// Surface the OS resolver's error, not the fallback's.
return nil, err
}
GoLog("[DoH] OS resolver failed for %s (%v), dialing DoH answer\n", host, err)
lastErr := err
for _, ip := range ips {
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
}
// dohResolve resolves host over DoH, IPv4 first. Failures are negative-cached
// briefly so a burst of dials does not hammer the resolvers.
func dohResolve(ctx context.Context, host string) ([]net.IP, error) {
if ips, ok := dohCachedIPs(host); ok {
if len(ips) == 0 {
return nil, fmt.Errorf("doh: cached failure for %s", host)
}
return ips, nil
}
var lastErr error
for _, upstream := range dohUpstreams {
ips, ttl, err := dohQuery(ctx, upstream, host, dnsmessage.TypeA)
if err == nil && len(ips) == 0 {
ips, ttl, err = dohQuery(ctx, upstream, host, dnsmessage.TypeAAAA)
}
if err != nil {
lastErr = err
continue
}
ips = filterDialableIPs(ips)
if len(ips) == 0 {
break
}
dohCachePut(host, ips, min(max(ttl, dohCacheMinTTL), dohCacheMaxTTL))
return ips, nil
}
dohCachePut(host, nil, dohCacheErrorTTL)
if lastErr == nil {
lastErr = fmt.Errorf("doh: no address for %s", host)
}
return nil, lastErr
}
func dohQuery(ctx context.Context, upstream, host string, qtype dnsmessage.Type) ([]net.IP, time.Duration, error) {
name, err := dnsmessage.NewName(host + ".")
if err != nil {
return nil, 0, fmt.Errorf("doh: invalid host %q: %w", host, err)
}
msg := dnsmessage.Message{
Header: dnsmessage.Header{RecursionDesired: true},
Questions: []dnsmessage.Question{{
Name: name,
Type: qtype,
Class: dnsmessage.ClassINET,
}},
}
packed, err := msg.Pack()
if err != nil {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstream, bytes.NewReader(packed))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/dns-message")
req.Header.Set("Accept", "application/dns-message")
resp, err := dohClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("doh: %s answered HTTP %d", upstream, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil, 0, err
}
var reply dnsmessage.Message
if err := reply.Unpack(body); err != nil {
return nil, 0, err
}
if reply.RCode != dnsmessage.RCodeSuccess {
return nil, 0, fmt.Errorf("doh: rcode %v for %s", reply.RCode, host)
}
var ips []net.IP
ttl := dohCacheMaxTTL
for _, ans := range reply.Answers {
var ip net.IP
switch r := ans.Body.(type) {
case *dnsmessage.AResource:
ip = net.IP(r.A[:])
case *dnsmessage.AAAAResource:
ip = net.IP(r.AAAA[:])
default:
continue
}
ips = append(ips, ip)
if t := time.Duration(ans.Header.TTL) * time.Second; t < ttl {
ttl = t
}
}
return ips, ttl, nil
}
// filterDialableIPs drops private/loopback/link-local answers unless the user
// opted into private-network access — a DoH answer must not bypass the SSRF
// guard the OS-resolver path enforces.
func filterDialableIPs(ips []net.IP) []net.IP {
if IsPrivateNetworkAllowed() {
return ips
}
kept := ips[:0]
for _, ip := range ips {
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
continue
}
kept = append(kept, ip)
}
return kept
}
func dohCachedIPs(host string) ([]net.IP, bool) {
dohMu.Lock()
defer dohMu.Unlock()
e, ok := dohCache[host]
if !ok || time.Now().After(e.expiresAt) {
return nil, false
}
return e.ips, true
}
func dohCachePut(host string, ips []net.IP, ttl time.Duration) {
dohMu.Lock()
defer dohMu.Unlock()
if len(dohCache) >= dohCacheMaxEntries {
now := time.Now()
for k, e := range dohCache {
if now.After(e.expiresAt) {
delete(dohCache, k)
}
}
for k := range dohCache {
if len(dohCache) < dohCacheMaxEntries {
break
}
delete(dohCache, k)
}
}
dohCache[host] = dohCacheEntry{ips: ips, expiresAt: time.Now().Add(ttl)}
}
-33
View File
@@ -1,33 +0,0 @@
package gobackend
import (
"path/filepath"
"strings"
"sync"
)
// downloadPathLocks serializes writes per final output path so concurrent
// downloads that resolve to the same file cannot interleave bytes into one
// output or race the staged-promote rename. Keys are normalized case-folded
// cleaned paths; entries live for the process lifetime (bounded by the number
// of distinct output files in a session).
var downloadPathLocks sync.Map
// lockDownloadOutputPath locks the given final output path and returns the
// unlock function. Different paths keep downloading in parallel; a second
// download of the same path blocks until the first finishes.
func lockDownloadOutputPath(path string) func() {
key := strings.ToLower(filepath.Clean(path))
value, _ := downloadPathLocks.LoadOrStore(key, &sync.Mutex{})
mu := value.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
// stagedDownloadPath returns the sibling name downloads are streamed into
// before being promoted to the final path with an atomic rename. The suffix
// keeps the staged file invisible to extension-based duplicate checks, which
// match on the final audio extension.
func stagedDownloadPath(finalPath string) string {
return finalPath + ".partial"
}
-129
View File
@@ -1,129 +0,0 @@
package gobackend
import (
"strings"
"sync"
"time"
)
const (
downloadPreparationCacheTTL = 5 * time.Minute
downloadPreparationCacheMax = 128
)
type preparedDownloadRequestEntry struct {
key string
request DownloadRequest
metadataPrepared bool
createdAt time.Time
}
var (
preparedDownloadRequests = make(map[string]preparedDownloadRequestEntry)
preparedDownloadRequestsMu sync.Mutex
)
func downloadPreparationKey(req DownloadRequest) string {
return strings.Join([]string{
strings.TrimSpace(req.ItemID),
strings.ToLower(strings.TrimSpace(req.Service)),
strings.ToLower(strings.TrimSpace(req.Source)),
strings.TrimSpace(req.SpotifyID),
strings.TrimSpace(req.TidalID),
strings.TrimSpace(req.QobuzID),
strings.TrimSpace(req.DeezerID),
strings.ToLower(strings.TrimSpace(req.TrackName)),
strings.ToLower(strings.TrimSpace(req.ArtistName)),
}, "\n")
}
func prunePreparedDownloadRequestsLocked(now time.Time) {
for itemID, entry := range preparedDownloadRequests {
if now.Sub(entry.createdAt) >= downloadPreparationCacheTTL {
delete(preparedDownloadRequests, itemID)
}
}
for len(preparedDownloadRequests) >= downloadPreparationCacheMax {
var oldestID string
var oldestAt time.Time
for itemID, entry := range preparedDownloadRequests {
if oldestID == "" || entry.createdAt.Before(oldestAt) {
oldestID = itemID
oldestAt = entry.createdAt
}
}
if oldestID == "" {
break
}
delete(preparedDownloadRequests, oldestID)
}
}
func cacheDownloadRequestForVerification(key string, req DownloadRequest, metadataPrepared bool) {
itemID := strings.TrimSpace(req.ItemID)
if itemID == "" || strings.TrimSpace(key) == "" {
return
}
preparedDownloadRequestsMu.Lock()
defer preparedDownloadRequestsMu.Unlock()
now := time.Now()
prunePreparedDownloadRequestsLocked(now)
preparedDownloadRequests[itemID] = preparedDownloadRequestEntry{
key: key,
request: req,
metadataPrepared: metadataPrepared,
createdAt: now,
}
}
func cachePreparedDownloadRequest(key string, req DownloadRequest) {
cacheDownloadRequestForVerification(key, req, true)
}
func cacheUnpreparedDownloadRequest(key string, req DownloadRequest) {
cacheDownloadRequestForVerification(key, req, false)
}
func takePreparedDownloadRequest(key string, fresh DownloadRequest) (DownloadRequest, bool, bool) {
itemID := strings.TrimSpace(fresh.ItemID)
if itemID == "" || strings.TrimSpace(key) == "" {
return fresh, false, false
}
preparedDownloadRequestsMu.Lock()
defer preparedDownloadRequestsMu.Unlock()
now := time.Now()
prunePreparedDownloadRequestsLocked(now)
entry, ok := preparedDownloadRequests[itemID]
if !ok {
return fresh, false, false
}
delete(preparedDownloadRequests, itemID)
if entry.key != key {
return fresh, false, false
}
prepared := entry.request
fresh.ISRC = prepared.ISRC
fresh.SpotifyID = prepared.SpotifyID
fresh.TrackName = prepared.TrackName
fresh.ArtistName = prepared.ArtistName
fresh.AlbumName = prepared.AlbumName
fresh.AlbumArtist = prepared.AlbumArtist
fresh.CoverURL = prepared.CoverURL
fresh.TrackNumber = prepared.TrackNumber
fresh.DiscNumber = prepared.DiscNumber
fresh.TotalTracks = prepared.TotalTracks
fresh.TotalDiscs = prepared.TotalDiscs
fresh.ReleaseDate = prepared.ReleaseDate
fresh.DurationMS = prepared.DurationMS
fresh.Genre = prepared.Genre
fresh.Label = prepared.Label
fresh.Copyright = prepared.Copyright
fresh.Composer = prepared.Composer
fresh.TidalID = prepared.TidalID
fresh.QobuzID = prepared.QobuzID
fresh.DeezerID = prepared.DeezerID
return fresh, entry.metadataPrepared, true
}
@@ -1,104 +0,0 @@
package gobackend
import (
"testing"
"time"
)
func resetPreparedDownloadRequestCacheForTest() {
preparedDownloadRequestsMu.Lock()
preparedDownloadRequests = make(map[string]preparedDownloadRequestEntry)
preparedDownloadRequestsMu.Unlock()
}
func TestPreparedDownloadRequestCache(t *testing.T) {
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
resetPreparedDownloadRequestCacheForTest()
fresh := DownloadRequest{
ItemID: "item-1",
Service: "provider-a",
Source: "source-a",
SpotifyID: "spotify-1",
TrackName: "Track",
ArtistName: "Artist",
OutputDir: "/new/output",
OutputPath: "/new/output/current.flac",
OutputFD: 42,
Quality: "lossless",
EmbedMetadata: true,
}
key := downloadPreparationKey(fresh)
prepared := fresh
prepared.ISRC = "USRC17607839"
prepared.AlbumName = "Resolved Album"
prepared.AlbumArtist = "Resolved Album Artist"
prepared.DeezerID = "deezer-1"
prepared.Genre = "Pop"
prepared.OutputDir = "/stale/output"
prepared.OutputPath = "/stale/output/old.flac"
prepared.OutputFD = 7
prepared.Quality = "stale-quality"
prepared.EmbedMetadata = false
cachePreparedDownloadRequest(key, prepared)
got, metadataPrepared, ok := takePreparedDownloadRequest(key, fresh)
if !ok {
t.Fatal("expected prepared request cache hit")
}
if !metadataPrepared {
t.Fatal("prepared request should be marked as metadata-prepared")
}
if got.ISRC != prepared.ISRC || got.AlbumName != prepared.AlbumName || got.DeezerID != prepared.DeezerID || got.Genre != prepared.Genre {
t.Fatalf("prepared metadata was not restored: %#v", got)
}
if got.OutputDir != fresh.OutputDir || got.OutputPath != fresh.OutputPath || got.OutputFD != fresh.OutputFD || got.Quality != fresh.Quality || got.EmbedMetadata != fresh.EmbedMetadata {
t.Fatalf("fresh output/settings fields were overwritten: %#v", got)
}
if _, _, ok := takePreparedDownloadRequest(key, fresh); ok {
t.Fatal("prepared request should be consumed after one retry")
}
cacheUnpreparedDownloadRequest(key, fresh)
_, metadataPrepared, ok = takePreparedDownloadRequest(key, fresh)
if !ok || metadataPrepared {
t.Fatalf("unprepared verification request = hit:%v metadataPrepared:%v", ok, metadataPrepared)
}
}
func TestPreparedDownloadRequestCacheRejectsChangedTrackAndExpiry(t *testing.T) {
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
resetPreparedDownloadRequestCacheForTest()
req := DownloadRequest{
ItemID: "item-2",
Service: "provider-a",
SpotifyID: "spotify-2",
TrackName: "Track",
ArtistName: "Artist",
}
key := downloadPreparationKey(req)
cachePreparedDownloadRequest(key, req)
changed := req
changed.SpotifyID = "spotify-other"
if _, _, ok := takePreparedDownloadRequest(downloadPreparationKey(changed), changed); ok {
t.Fatal("changed track must not reuse another track's prepared metadata")
}
preparedDownloadRequestsMu.Lock()
_, staleEntryExists := preparedDownloadRequests[req.ItemID]
preparedDownloadRequestsMu.Unlock()
if staleEntryExists {
t.Fatal("mismatched prepared request should be discarded")
}
cachePreparedDownloadRequest(key, req)
preparedDownloadRequestsMu.Lock()
entry := preparedDownloadRequests[req.ItemID]
entry.createdAt = time.Now().Add(-downloadPreparationCacheTTL)
preparedDownloadRequests[req.ItemID] = entry
preparedDownloadRequestsMu.Unlock()
if _, _, ok := takePreparedDownloadRequest(key, req); ok {
t.Fatal("expired prepared request must not be reused")
}
}
+41 -200
View File
@@ -1,31 +1,19 @@
package gobackend
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// isrcFileEntry caches the parse result for one file so index rebuilds only
// re-read files whose size or mtime changed.
type isrcFileEntry struct {
size int64
modTime int64 // UnixNano
isrc string // uppercase; empty when the file carries no ISRC tag
}
type ISRCIndex struct {
index map[string]string // ISRC (uppercase) -> file path
files map[string]isrcFileEntry // file path -> cached parse result
index map[string]string // ISRC (uppercase) -> file path
outputDir string
buildTime atomic.Int64 // UnixNano of the last build or write
buildTime time.Time
mu sync.RWMutex
}
@@ -34,20 +22,14 @@ var (
isrcIndexCacheMu sync.RWMutex
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
isrcIndexTTL = 5 * time.Minute
isrcIndexBuildWorkers = 4
)
func (idx *ISRCIndex) isFresh() bool {
return time.Since(time.Unix(0, idx.buildTime.Load())) < isrcIndexTTL
}
func GetISRCIndex(outputDir string) *ISRCIndex {
isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && idx.isFresh() {
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
@@ -60,7 +42,7 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && idx.isFresh() {
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
@@ -70,37 +52,16 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{
index: make(map[string]string),
files: make(map[string]isrcFileEntry),
outputDir: outputDir,
buildTime: time.Now(),
}
idx.buildTime.Store(time.Now().UnixNano())
if outputDir == "" {
return idx
}
// Reuse the previous build's per-file cache: files whose size and mtime
// are unchanged are adopted without touching their content, so a rebuild
// is normally just a stat walk.
prevFiles := map[string]isrcFileEntry{}
isrcIndexCacheMu.RLock()
if prev, ok := isrcIndexCache[outputDir]; ok {
prev.mu.RLock()
for path, entry := range prev.files {
prevFiles[path] = entry
}
prev.mu.RUnlock()
}
isrcIndexCacheMu.RUnlock()
startTime := time.Now()
type parseTask struct {
path string
size int64
modTime int64
}
var toParse []parseTask
reused := 0
fileCount := 0
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
@@ -112,56 +73,18 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return nil
}
size := info.Size()
modTime := info.ModTime().UnixNano()
if entry, ok := prevFiles[path]; ok && entry.size == size && entry.modTime == modTime {
idx.files[path] = entry
if entry.isrc != "" {
idx.index[entry.isrc] = path
}
reused++
metadata, err := ReadMetadata(path)
if err != nil || metadata.ISRC == "" {
return nil
}
toParse = append(toParse, parseTask{path: path, size: size, modTime: modTime})
idx.index[strings.ToUpper(metadata.ISRC)] = path
fileCount++
return nil
})
if len(toParse) > 0 {
// New/changed files: read only their Vorbis comment block, in
// parallel. Embedded cover art (megabytes per file) is never loaded.
isrcs := make([]string, len(toParse))
workerCount := isrcIndexBuildWorkers
if len(toParse) < workerCount {
workerCount = len(toParse)
}
tasks := make(chan int)
var wg sync.WaitGroup
for w := 0; w < workerCount; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range tasks {
isrcs[i] = strings.ToUpper(readFlacISRC(toParse[i].path))
}
}()
}
for i := range toParse {
tasks <- i
}
close(tasks)
wg.Wait()
for i, task := range toParse {
entry := isrcFileEntry{size: task.size, modTime: task.modTime, isrc: isrcs[i]}
idx.files[task.path] = entry
if entry.isrc != "" {
idx.index[entry.isrc] = task.path
}
}
}
fmt.Printf("[ISRCIndex] Built index for %s: %d files (%d parsed, %d cached) in %v\n",
outputDir, len(idx.files), len(toParse), reused, time.Since(startTime).Round(time.Millisecond))
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
isrcIndexCacheMu.Lock()
isrcIndexCache[outputDir] = idx
@@ -170,78 +93,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return idx
}
// readFlacISRC extracts the ISRC Vorbis comment from a FLAC file by walking
// the metadata block headers and reading only the VORBIS_COMMENT payload;
// picture and padding blocks are seeked past, never loaded. Returns "" when
// the file is not FLAC or carries no ISRC tag.
func readFlacISRC(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
magic := make([]byte, 4)
if _, err := io.ReadFull(f, magic); err != nil || string(magic) != "fLaC" {
return ""
}
header := make([]byte, 4)
for {
if _, err := io.ReadFull(f, header); err != nil {
return ""
}
last := header[0]&0x80 != 0
blockType := header[0] & 0x7F
length := int64(header[1])<<16 | int64(header[2])<<8 | int64(header[3])
if blockType == 4 { // VORBIS_COMMENT
if length > 16<<20 {
return ""
}
payload := make([]byte, length)
if _, err := io.ReadFull(f, payload); err != nil {
return ""
}
return vorbisCommentISRC(payload)
}
if last {
return ""
}
if _, err := f.Seek(length, io.SeekCurrent); err != nil {
return ""
}
}
}
func vorbisCommentISRC(payload []byte) string {
if len(payload) < 8 {
return ""
}
offset := int(binary.LittleEndian.Uint32(payload[0:4])) + 4
if offset < 4 || offset+4 > len(payload) {
return ""
}
count := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
offset += 4
for i := 0; i < count; i++ {
if offset+4 > len(payload) {
return ""
}
commentLen := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
offset += 4
if commentLen < 0 || offset+commentLen > len(payload) {
return ""
}
comment := payload[offset : offset+commentLen]
offset += commentLen
eq := strings.IndexByte(string(comment), '=')
if eq > 0 && strings.EqualFold(string(comment[:eq]), "ISRC") {
return strings.TrimSpace(string(comment[eq+1:]))
}
}
return ""
}
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
if isrc == "" {
return "", false
@@ -275,30 +126,10 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
return
}
upper := strings.ToUpper(isrc)
var entry *isrcFileEntry
if info, err := os.Stat(filePath); err == nil {
entry = &isrcFileEntry{
size: info.Size(),
modTime: info.ModTime().UnixNano(),
isrc: upper,
}
}
idx.mu.Lock()
idx.index[upper] = filePath
if entry != nil {
if idx.files == nil {
idx.files = make(map[string]isrcFileEntry)
}
idx.files[filePath] = *entry
}
idx.mu.Unlock()
defer idx.mu.Unlock()
// The index is write-maintained after every successful download;
// refreshing the timestamp keeps the TTL from forcing a full rebuild in
// the middle of the exact workload the index exists to serve.
idx.buildTime.Store(time.Now().UnixNano())
idx.index[strings.ToUpper(isrc)] = filePath
}
func InvalidateISRCCache(outputDir string) {
@@ -362,25 +193,35 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
isrcIdx := GetISRCIndex(outputDir)
// A lookup is a single map read. Holding one read lock for the batch avoids
// one goroutine and one lock/unlock pair per track, which was slower and
// could create thousands of goroutines for large playlists.
isrcIdx.mu.RLock()
var wg sync.WaitGroup
for i, track := range tracks {
result := FileExistenceResult{
ISRC: track.ISRC,
TrackName: track.TrackName,
ArtistName: track.ArtistName,
}
if track.ISRC != "" {
if filePath, exists := isrcIdx.index[strings.ToUpper(track.ISRC)]; exists {
result.Exists = true
result.FilePath = filePath
wg.Add(1)
go func(resultIdx int, t struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
}) {
defer wg.Done()
result := FileExistenceResult{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
Exists: false,
}
}
results[i] = result
if t.ISRC != "" {
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
result.Exists = true
result.FilePath = filePath
}
}
results[resultIdx] = result
}(i, track)
}
isrcIdx.mu.RUnlock()
wg.Wait()
resultJSON, err := json.Marshal(results)
if err != nil {
+3659 -51
View File
File diff suppressed because it is too large Load Diff
-214
View File
@@ -1,214 +0,0 @@
package gobackend
import (
"context"
"fmt"
"strings"
"time"
)
// GetTrackCacheSize and ClearTrackIDCache back the Settings cache screen. The
// track-ID cache is currently a no-op, so these report an empty cache and clear
// nothing, but the gomobile export contract is kept for the Dart/Kotlin callers.
func GetTrackCacheSize() int {
return 0
}
func ClearTrackIDCache() {
}
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
client := GetDeezerClient()
artists, err := client.GetRelatedArtists(ctx, artistID, limit)
if err != nil {
return "", err
}
resp := map[string]any{
"artists": artists,
}
return marshalJSONString(resp)
}
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := GetDeezerClient()
var data any
var err error
switch resourceType {
case "track":
data, err = client.GetTrack(ctx, resourceID)
case "album":
data, err = client.GetAlbum(ctx, resourceID)
case "artist":
data, err = client.GetArtist(ctx, resourceID)
case "playlist":
data, err = client.GetPlaylist(ctx, resourceID)
default:
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
}
if err != nil {
return "", err
}
return marshalJSONString(data)
}
func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := GetDeezerClient()
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
if err != nil {
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
return "", err
}
result := buildDeezerExtendedMetadataResult(metadata)
return marshalJSONString(result)
}
func SearchDeezerByISRC(isrc string) (string, error) {
return SearchDeezerByISRCForItemID(isrc, "")
}
func SearchDeezerByISRCForItemID(isrc string, itemID string) (string, error) {
parentCtx := context.Background()
if itemID != "" {
parentCtx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
if isDownloadCancelled(itemID) {
return "", ErrDownloadCancelled
}
}
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
client := GetDeezerClient()
track, err := client.SearchByISRC(ctx, isrc)
if err != nil {
if isDownloadCancelled(itemID) {
return "", ErrDownloadCancelled
}
return "", err
}
if isDownloadCancelled(itemID) {
return "", ErrDownloadCancelled
}
result := buildDeezerISRCSearchResult(track)
return marshalJSONString(result)
}
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
if metadata == nil {
return map[string]string{
"genre": "",
"label": "",
"copyright": "",
}
}
return map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
"copyright": metadata.Copyright,
}
}
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]any {
if track == nil {
return map[string]any{}
}
result := map[string]any{
"spotify_id": track.SpotifyID,
"artists": track.Artists,
"name": track.Name,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.Images,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
"disc_number": track.DiscNumber,
"total_discs": track.TotalDiscs,
"external_urls": track.ExternalURL,
"isrc": track.ISRC,
"album_id": track.AlbumID,
"artist_id": track.ArtistID,
"album_type": track.AlbumType,
"composer": track.Composer,
}
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
result["id"] = deezerID
result["track_id"] = deezerID
result["success"] = true
}
return result
}
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
songlink := NewSongLinkClient()
deezerClient := GetDeezerClient()
if resourceType == "track" {
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
}
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
}
return marshalJSONString(trackResp)
}
if resourceType == "album" {
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer album: %w", err)
}
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
}
return marshalJSONString(albumResp)
}
return "", fmt.Errorf("spotify to Deezer conversion only supported for tracks and albums: please search by name for %s", resourceType)
}
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetSpotifyIDFromDeezer(deezerTrackID)
}
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetTidalURLFromDeezer(deezerTrackID)
}
-493
View File
@@ -1,493 +0,0 @@
package gobackend
import (
"context"
"encoding/json"
"strings"
"time"
)
type DownloadRequest struct {
ContractVersion int `json:"contract_version,omitempty"`
ISRC string `json:"isrc"`
Service string `json:"service"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
CoverURL string `json:"cover_url"`
OutputDir string `json:"output_dir"`
OutputPath string `json:"output_path,omitempty"`
OutputFD int `json:"output_fd,omitempty"`
OutputExt string `json:"output_ext,omitempty"`
FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"`
EmbedMetadata bool `json:"embed_metadata"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
EmbedReplayGain bool `json:"embed_replaygain,omitempty"`
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
TidalHighFormat string `json:"tidal_high_format,omitempty"`
TrackNumber int `json:"track_number"`
PlaylistPosition int `json:"playlist_position,omitempty"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"`
TotalDiscs int `json:"total_discs,omitempty"`
ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"`
DurationMS int `json:"duration_ms"`
Source string `json:"source"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
LyricsMode string `json:"lyrics_mode,omitempty"`
UseExtensions bool `json:"use_extensions,omitempty"`
UseFallback bool `json:"use_fallback,omitempty"`
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
AllowQualityVariant bool `json:"allow_quality_variant,omitempty"`
QualityVariant string `json:"quality_variant,omitempty"`
SongLinkRegion string `json:"songlink_region,omitempty"`
}
type DownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ActualExtension string `json:"actual_extension,omitempty"`
ActualContainer string `json:"actual_container,omitempty"`
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
Service string `json:"service,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ISRC string `json:"isrc,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
}
type DownloadResult struct {
FilePath string
BitDepth int
SampleRate int
AudioCodec string
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
TotalTracks int
DiscNumber int
TotalDiscs int
ISRC string
CoverURL string
Genre string
Label string
Copyright string
Composer string
LyricsLRC string
DecryptionKey string
Decryption *DownloadDecryptionInfo
ActualExtension string
ActualContainer string
RequiresContainerConversion bool
}
func buildDownloadSuccessResponse(
req DownloadRequest,
result DownloadResult,
service string,
message string,
filePath string,
alreadyExists bool,
) DownloadResponse {
title := result.Title
if title == "" {
title = req.TrackName
}
artist := result.Artist
if artist == "" {
artist = req.ArtistName
}
// Preserve requested release metadata when available so mixed-provider
// fallback downloads from the same source album do not get split into
// different albums just because Tidal/Qobuz report variant titles/dates.
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
req,
result.Album,
result.ReleaseDate,
result.TrackNumber,
result.DiscNumber,
)
isrc := result.ISRC
if isrc == "" {
isrc = req.ISRC
}
genre := result.Genre
if genre == "" {
genre = req.Genre
}
label := result.Label
if label == "" {
label = req.Label
}
copyright := result.Copyright
if copyright == "" {
copyright = req.Copyright
}
composer := result.Composer
if composer == "" {
composer = req.Composer
}
coverURL := strings.TrimSpace(result.CoverURL)
if coverURL == "" {
coverURL = strings.TrimSpace(req.CoverURL)
}
return DownloadResponse{
Success: true,
Message: message,
FilePath: filePath,
AlreadyExists: alreadyExists,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
AudioCodec: result.AudioCodec,
ActualExtension: result.ActualExtension,
ActualContainer: result.ActualContainer,
RequiresContainerConversion: result.RequiresContainerConversion,
Service: service,
Title: title,
Artist: artist,
Album: album,
AlbumArtist: req.AlbumArtist,
ReleaseDate: releaseDate,
TrackNumber: trackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: discNumber,
TotalDiscs: req.TotalDiscs,
ISRC: isrc,
CoverURL: coverURL,
Genre: genre,
Label: label,
Copyright: copyright,
Composer: composer,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
}
}
func shouldSkipQualityProbe(filePath string) bool {
path := strings.TrimSpace(filePath)
if path == "" {
return true
}
if strings.HasPrefix(path, "/proc/self/fd/") {
return true
}
// Content URI and other non-filesystem schemes cannot be read directly by os.Open.
if strings.Contains(path, "://") {
return true
}
return false
}
func enrichResultQualityFromFile(result *DownloadResult) {
if result == nil {
return
}
path := strings.TrimSpace(result.FilePath)
if shouldSkipQualityProbe(path) {
if strings.HasPrefix(path, "/proc/self/fd/") {
LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path)
}
return
}
quality, qErr := GetAudioQuality(path)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.AudioCodec = quality.Codec
if quality.Codec != "" {
GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
return
}
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
}
func applyExtendedMetadataFields(
genre *string,
label *string,
copyright *string,
extMeta *AlbumExtendedMetadata,
) {
if extMeta == nil {
return
}
if genre != nil && *genre == "" && extMeta.Genre != "" {
*genre = extMeta.Genre
}
if label != nil && *label == "" && extMeta.Label != "" {
*label = extMeta.Label
}
if copyright != nil && *copyright == "" && extMeta.Copyright != "" {
*copyright = extMeta.Copyright
}
}
func enrichExtraMetadataByISRC(
logPrefix string,
isrc string,
genre *string,
label *string,
copyright *string,
) {
normalizedISRC := strings.TrimSpace(isrc)
if normalizedISRC == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
extMeta, err := fetchDeezerExtendedMetadataByISRC(ctx, normalizedISRC)
if err != nil {
GoLog("[%s] Failed to get extended metadata from Deezer: %v\n", logPrefix, err)
}
applyExtendedMetadataFields(genre, label, copyright, extMeta)
if genre != nil && *genre == "" {
musicBrainzGenre, err := fetchMusicBrainzGenreByISRC(normalizedISRC)
if err != nil {
GoLog("[%s] Failed to get genre from MusicBrainz: %v\n", logPrefix, err)
} else if musicBrainzGenre != "" {
*genre = musicBrainzGenre
GoLog("[%s] Genre fallback from MusicBrainz: %s\n", logPrefix, *genre)
}
}
currentGenre := ""
currentLabel := ""
currentCopyright := ""
if genre != nil {
currentGenre = *genre
}
if label != nil {
currentLabel = *label
}
if copyright != nil {
currentCopyright = *copyright
}
if currentGenre != "" || currentLabel != "" || currentCopyright != "" {
GoLog("[%s] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", logPrefix, currentGenre, currentLabel, currentCopyright)
}
}
func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req == nil {
return
}
if req.ISRC == "" {
return
}
if strings.TrimSpace(req.AlbumArtist) == "" {
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
if err != nil {
GoLog("[DownloadWithFallback] Failed to get album artist from MusicBrainz: %v\n", err)
} else if strings.TrimSpace(albumArtist) != "" {
req.AlbumArtist = strings.TrimSpace(albumArtist)
GoLog("[DownloadWithFallback] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
}
}
if req.Genre == "" || req.Label == "" || req.Copyright == "" {
enrichExtraMetadataByISRC(
"DownloadWithFallback",
req.ISRC,
&req.Genre,
&req.Label,
&req.Copyright,
)
}
}
func applySongLinkRegionFromRequest(req *DownloadRequest) {
if req == nil {
return
}
SetSongLinkRegion(req.SongLinkRegion)
}
// DownloadByStrategy routes all download requests through extension providers.
func DownloadByStrategy(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
normalizedBytes, err := json.Marshal(req)
if err != nil {
return errorResponse("Invalid request: " + err.Error())
}
normalizedJSON := string(normalizedBytes)
if req.UseExtensions {
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
if err != nil {
return errorResponse(err.Error())
}
return resp, nil
}
return errorResponse("Extension providers are disabled; built-in download providers have been retired")
}
func GetDownloadProgress() string {
progress := getProgress()
jsonBytes, _ := json.Marshal(progress)
return string(jsonBytes)
}
func GetAllDownloadProgress() string {
return GetMultiProgress()
}
func GetAllDownloadProgressDelta(sinceSeq int64) string {
return GetMultiProgressDelta(sinceSeq)
}
func InitItemProgress(itemID string) {
StartItemProgress(itemID)
}
func FinishItemProgress(itemID string) {
CompleteItemProgress(itemID)
}
func ClearItemProgress(itemID string) {
RemoveItemProgress(itemID)
}
func CancelDownload(itemID string) {
cancelDownload(itemID)
}
// ResetDownloadCancel drops a pre-registered cancellation flag for an item
// with no active download, so a user-initiated retry does not consume a stale
// cancel and abort instantly. Entries with live references are left alone.
func ResetDownloadCancel(itemID string) {
resetDownloadCancel(itemID)
}
func CleanupConnections() {
CloseIdleConnections()
}
func errorResponse(msg string) (string, error) {
errorType := classifyDownloadErrorType(msg)
resp := DownloadResponse{
Success: false,
Error: msg,
ErrorType: errorType,
}
s, _ := marshalJSONString(resp)
return s, nil
}
func classifyDownloadErrorType(msg string) string {
lowerMsg := strings.ToLower(msg)
if strings.Contains(lowerMsg, "isp blocking") ||
strings.Contains(lowerMsg, "try using vpn") ||
strings.Contains(lowerMsg, "change dns") {
return "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
return "cancelled"
} else if strings.Contains(lowerMsg, "verify_required") ||
strings.Contains(lowerMsg, "verification_required") ||
strings.Contains(lowerMsg, "verification required") ||
strings.Contains(lowerMsg, "needs verification") ||
strings.Contains(lowerMsg, "session is not authenticated") ||
strings.Contains(lowerMsg, "signed session is not authenticated") ||
strings.Contains(lowerMsg, "signed session expired") ||
strings.Contains(lowerMsg, "unauthorized") ||
strings.Contains(lowerMsg, "precondition required") ||
messageHasHTTPStatusCode(lowerMsg, "401") ||
messageHasHTTPStatusCode(lowerMsg, "428") {
return "verification_required"
} else if strings.Contains(lowerMsg, "rate limit") ||
messageHasHTTPStatusCode(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") {
return "rate_limit"
} else if strings.Contains(lowerMsg, "permission") ||
strings.Contains(lowerMsg, "operation not permitted") ||
strings.Contains(lowerMsg, "access denied") ||
strings.Contains(lowerMsg, "failed to create file") ||
strings.Contains(lowerMsg, "failed to create directory") {
return "permission"
} else if strings.Contains(lowerMsg, "not found") ||
strings.Contains(lowerMsg, "not available") ||
strings.Contains(lowerMsg, "no results") ||
strings.Contains(lowerMsg, "track not found") ||
strings.Contains(lowerMsg, "all services failed") {
return "not_found"
} else if strings.Contains(lowerMsg, "network") ||
strings.Contains(lowerMsg, "connection") ||
strings.Contains(lowerMsg, "timeout") ||
strings.Contains(lowerMsg, "dial") {
return "network"
}
return "unknown"
}
func messageHasHTTPStatusCode(lowerMsg, code string) bool {
return strings.Contains(lowerMsg, "http "+code) ||
strings.Contains(lowerMsg, "http status "+code) ||
strings.Contains(lowerMsg, "status "+code) ||
strings.Contains(lowerMsg, code+" for ") ||
strings.Contains(lowerMsg, code+":") ||
strings.Contains(lowerMsg, code+";")
}
@@ -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)
}
File diff suppressed because it is too large Load Diff
-33
View File
@@ -1,33 +0,0 @@
package gobackend
func SetLibraryCoverCacheDirJSON(cacheDir string) {
SetLibraryCoverCacheDir(cacheDir)
}
func ScanLibraryFolderJSON(folderPath string) (string, error) {
return ScanLibraryFolder(folderPath)
}
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
}
func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) {
return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath)
}
func GetLibraryScanProgressJSON() string {
return GetLibraryScanProgress()
}
func CancelLibraryScanJSON() {
CancelLibraryScan()
}
func ReadAudioMetadataJSON(filePath string) (string, error) {
return ReadAudioMetadata(filePath)
}
func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) {
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey)
}
-191
View File
@@ -1,191 +0,0 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"strings"
)
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
result := map[string]any{
"success": true,
"source": lyrics.Source,
"sync_type": lyrics.SyncType,
"lines": lyrics.Lines,
"instrumental": lyrics.Instrumental,
}
return marshalJSONString(result)
}
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
return lyrics, nil
}
return "", nil
}
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
if lyricsData.Instrumental {
return "[instrumental:true]", nil
}
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil
}
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
source := extractLyricsSourceFromLRC(lyrics)
if source == "" {
source = "Embedded"
}
result := map[string]any{
"lyrics": lyrics,
"source": source,
"sync_type": "EMBEDDED",
"instrumental": false,
}
return marshalJSONString(result)
}
result := map[string]any{
"lyrics": "",
"source": "",
"sync_type": "",
"instrumental": false,
}
return marshalJSONString(result)
}
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
lrcContent := ""
if lyricsData.Instrumental {
lrcContent = "[instrumental:true]"
} else {
lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName)
}
result := map[string]any{
"lyrics": lrcContent,
"source": lyricsData.Source,
"sync_type": lyricsData.SyncType,
"instrumental": lyricsData.Instrumental,
}
return marshalJSONString(result)
}
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
err := EmbedLyrics(filePath, lyrics)
if err != nil {
return errorResponse("Failed to embed lyrics: " + err.Error())
}
resp := map[string]any{
"success": true,
"message": "Lyrics embedded successfully",
}
s, _ := marshalJSONString(resp)
return s, nil
}
func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string, audioFilePath string) error {
// If the audio file already has embedded lyrics or a sidecar .lrc,
// use those directly instead of making redundant network requests.
if audioFilePath != "" {
existing, err := ExtractLyrics(audioFilePath)
if err == nil && strings.TrimSpace(existing) != "" {
if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil {
return fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC from embedded/sidecar to: %s\n", outputPath)
return nil
}
}
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return fmt.Errorf("lyrics not found: %w", err)
}
if lyrics.Instrumental {
return fmt.Errorf("track is instrumental, no lyrics available")
}
lrcContent := convertToLRCWithMetadata(lyrics, trackName, artistName)
if lrcContent == "" {
return fmt.Errorf("failed to generate LRC content")
}
if err := os.WriteFile(outputPath, []byte(lrcContent), 0644); err != nil {
return fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC to: %s (%d lines)\n", outputPath, len(lyrics.Lines))
return nil
}
func SetLyricsProvidersJSON(providersJSON string) error {
var providers []string
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
return err
}
SetLyricsProviderOrder(providers)
return nil
}
func GetLyricsProvidersJSON() (string, error) {
providers := GetLyricsProviderOrder()
return marshalJSONString(providers)
}
func GetAvailableLyricsProvidersJSON() (string, error) {
providers := GetAvailableLyricsProviders()
return marshalJSONString(providers)
}
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
opts := GetLyricsFetchOptions()
if strings.TrimSpace(optionsJSON) != "" {
if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil {
return err
}
}
SetLyricsFetchOptions(opts)
return nil
}
func GetLyricsFetchOptionsJSON() (string, error) {
opts := GetLyricsFetchOptions()
return marshalJSONString(opts)
}
-572
View File
@@ -1,572 +0,0 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func applyAudioMetadataToResult(result map[string]any, meta *AudioMetadata) {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["total_tracks"] = meta.TotalTracks
result["disc_number"] = meta.DiscNumber
result["total_discs"] = meta.TotalDiscs
result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre
result["label"] = meta.Label
result["copyright"] = meta.Copyright
result["composer"] = meta.Composer
result["comment"] = meta.Comment
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
}
func successMethodJSON(method string) (string, error) {
return marshalJSONString(map[string]any{"success": true, "method": method})
}
func ReadFileMetadata(filePath string) (string, error) {
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac")
isMp3 := strings.HasSuffix(lower, ".mp3")
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
isApe := strings.HasSuffix(lower, ".ape")
isWv := strings.HasSuffix(lower, ".wv")
isMpc := strings.HasSuffix(lower, ".mpc")
isWav := strings.HasSuffix(lower, ".wav")
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
result := map[string]any{
"title": "",
"artist": "",
"album": "",
"album_artist": "",
"date": "",
"track_number": 0,
"total_tracks": 0,
"disc_number": 0,
"total_discs": 0,
"isrc": "",
"lyrics": "",
"genre": "",
"label": "",
"copyright": "",
"composer": "",
"comment": "",
"duration": 0,
"format": "",
"audio_codec": "",
}
if isFlac {
result["format"] = "flac"
result["audio_codec"] = "flac"
metadata, err := ReadMetadata(filePath)
if err != nil {
// File may have wrong extension (e.g. opus saved as .flac).
// Try Ogg/Opus parser as fallback before giving up.
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
oggMeta, oggErr := ReadOggVorbisComments(filePath)
if oggErr == nil && oggMeta != nil {
result["title"] = oggMeta.Title
result["artist"] = oggMeta.Artist
result["album"] = oggMeta.Album
result["album_artist"] = oggMeta.AlbumArtist
result["date"] = oggMeta.Date
if oggMeta.Date == "" {
result["date"] = oggMeta.Year
}
result["track_number"] = oggMeta.TrackNumber
result["total_tracks"] = oggMeta.TotalTracks
result["disc_number"] = oggMeta.DiscNumber
result["total_discs"] = oggMeta.TotalDiscs
result["isrc"] = oggMeta.ISRC
result["lyrics"] = oggMeta.Lyrics
result["genre"] = oggMeta.Genre
result["composer"] = oggMeta.Composer
result["comment"] = oggMeta.Comment
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
if quality.Bitrate > 0 {
result["bitrate"] = quality.Bitrate / 1000
}
}
result["format"] = "opus"
result["audio_codec"] = "opus"
} else {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
} else {
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["total_tracks"] = metadata.TotalTracks
result["disc_number"] = metadata.DiscNumber
result["total_discs"] = metadata.TotalDiscs
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
result["replaygain_track_gain"] = metadata.ReplayGainTrackGain
result["replaygain_track_peak"] = metadata.ReplayGainTrackPeak
result["replaygain_album_gain"] = metadata.ReplayGainAlbumGain
result["replaygain_album_peak"] = metadata.ReplayGainAlbumPeak
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.Codec != "" {
result["audio_codec"] = quality.Codec
}
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
}
} else if isM4A {
result["format"] = "m4a"
meta, err := ReadM4ATags(filePath)
if err == nil && meta != nil {
applyAudioMetadataToResult(result, meta)
}
quality, qualityErr := GetM4AQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
result["audio_codec"] = quality.Codec
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
result["format"] = format
}
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
result["bitrate"] = quality.Bitrate
}
}
} else if isMp3 {
result["format"] = "mp3"
result["audio_codec"] = "mp3"
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
applyAudioMetadataToResult(result, meta)
}
quality, qualityErr := GetMP3Quality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
if quality.Bitrate > 0 {
result["bitrate"] = quality.Bitrate / 1000
}
}
} else if isOgg {
result["format"] = "opus"
result["audio_codec"] = "opus"
meta, err := ReadOggVorbisComments(filePath)
if err == nil && meta != nil {
applyAudioMetadataToResult(result, meta)
}
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
if quality.Bitrate > 0 {
result["bitrate"] = quality.Bitrate / 1000
}
}
} else if isApe || isWv || isMpc {
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
result["audio_codec"] = result["format"]
apeTag, apeErr := ReadAPETags(filePath)
if apeErr == nil && apeTag != nil {
meta := APETagToAudioMetadata(apeTag)
if meta != nil {
applyAudioMetadataToResult(result, meta)
}
}
} else if isWav || isAiff {
var meta *AudioMetadata
var quality *WAVQuality
var qualityErr error
if isAiff {
result["format"] = "aiff"
result["audio_codec"] = "pcm"
meta, _ = ReadAIFFTags(filePath)
quality, qualityErr = GetAIFFQuality(filePath)
} else {
result["format"] = "wav"
result["audio_codec"] = "pcm"
meta, _ = ReadWAVTags(filePath)
quality, qualityErr = GetWAVQuality(filePath)
}
if meta != nil {
applyAudioMetadataToResult(result, meta)
}
if qualityErr == nil && quality != nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("unsupported file format: %s", filePath)
}
return marshalJSONString(result)
}
// ParseCueSheet is called from Dart to get track listing and timing data for CUE splitting.
// audioDir, if non-empty, overrides the directory used for resolving the
// referenced audio file (useful for SAF temp file scenarios).
func ParseCueSheet(cuePath string, audioDir string) (string, error) {
return ParseCueFileJSON(cuePath, audioDir)
}
// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of
// LibraryScanResult entries (one per track). This is the SAF-friendly variant:
// - audioDir overrides where the referenced audio file is resolved
// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI)
// - fileModTime is stamped on every result (pass 0 to stat cuePath instead)
func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
if err != nil {
return "[]", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
}
return string(jsonBytes), nil
}
func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
results, err := ScanCueFileForLibraryExtWithCoverCacheKey(
cuePath,
audioDir,
virtualPathPrefix,
fileModTime,
coverCacheKey,
scanTime,
)
if err != nil {
return "[]", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
}
return string(jsonBytes), nil
}
// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes
// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be
// written natively after the FFmpeg metadata pass for the values to persist.
// Only keys present in the JSON are touched; an empty value clears the tag.
func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) {
var fields map[string]string
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
return "", fmt.Errorf("invalid metadata JSON: %w", err)
}
if err := EditM4AFreeformText(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A freeform tags: %w", err)
}
return successMethodJSON("native_m4a_freeform")
}
// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO
// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when
// the file is not AC-4.
func EnsureAC4Config(filePath, sourcePath string) (string, error) {
if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil {
return "", fmt.Errorf("failed to finalize AC-4 container: %w", err)
}
return `{"success":true}`, nil
}
// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON
// "handled" field reports whether the file was AC-4 (true) so the caller can
// skip the FFmpeg metadata pass that would re-wrap it as QuickTime.
func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) {
handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath)
if err != nil {
return "", fmt.Errorf("failed to write AC-4 metadata: %w", err)
}
resp := map[string]any{"success": true, "handled": handled}
s, _ := marshalJSONString(resp)
return s, nil
}
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
var fields map[string]string
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
return "", fmt.Errorf("invalid metadata JSON: %w", err)
}
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
isWavFile := strings.HasSuffix(lower, ".wav")
isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
coverPath := strings.TrimSpace(fields["cover_path"])
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
if err := EditM4AReplayGain(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
}
return successMethodJSON("native_m4a_replaygain")
}
if isFlac {
// A .flac name does not guarantee FLAC content: providers sometimes
// deliver an MP4/M4A stream that ends up under the requested name.
// The FLAC writer would fail "fLaC head incorrect" on every attempt,
// so detect the mismatch up front and say what is actually wrong.
if isMP4ContainerFile(filePath) {
return "", fmt.Errorf(
"failed to write FLAC metadata: file is an MP4/M4A stream under a .flac name; rename it to .m4a",
)
}
if err := EditFlacFields(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
}
return successMethodJSON("native")
}
// WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively.
if isWavFile {
if err := WriteWAVTags(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write WAV metadata: %w", err)
}
return successMethodJSON("native_wav")
}
if isAiffFile {
if err := WriteAIFFTags(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
}
return successMethodJSON("native_aiff")
}
if isApeFile {
meta := audioMetadataFromEditFields(fields)
newItems := AudioMetadataToAPEItems(meta)
// If a cover image was provided, embed it as a binary APE item.
// APEv2 cover format: "cover.jpg\0<binary image data>", flagged binary.
if coverPath != "" {
coverData, coverErr := os.ReadFile(coverPath)
if coverErr == nil && len(coverData) > 0 {
// The value is "filename\0" + raw bytes. We store the
// description as the Value field, but since the item is
// flagged binary, the writer serializes it verbatim.
desc := "cover.jpg\x00"
binaryValue := desc + string(coverData)
newItems = append(newItems, APETagItem{
Key: "Cover Art (Front)",
Value: binaryValue,
Flags: apeItemFlagBinary,
})
}
}
// Build the set of APE keys that the edit explicitly controls.
// Even if the value is empty (user cleared the field), the old
// value must be removed during merge.
overrideKeys := apeKeysFromFields(fields)
if coverPath != "" {
overrideKeys["COVER ART (FRONT)"] = struct{}{}
}
// Read existing tags so we can merge rather than replace.
// This preserves cover art and custom items not in the edit set.
existingTag, _ := ReadAPETags(filePath)
var finalItems []APETagItem
if existingTag != nil && len(existingTag.Items) > 0 {
finalItems = MergeAPEItems(existingTag.Items, newItems, overrideKeys)
} else {
finalItems = newItems
}
tag := &APETag{
Version: apeTagVersion2,
Items: finalItems,
}
if err := WriteAPETags(filePath, tag); err != nil {
return "", fmt.Errorf("failed to write APE tags: %w", err)
}
return successMethodJSON("native_ape")
}
// MP3, Ogg/Opus, and M4A have native editors that preserve foreign
// tags and skip the ffmpeg remux. Any failure falls back to the ffmpeg
// response so callers keep the old behavior for exotic files.
isMp3 := strings.HasSuffix(lower, ".mp3")
isOggFile := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
if isMp3 {
if err := EditMP3Fields(filePath, fields); err != nil {
GoLog("[Metadata] Native MP3 edit failed, falling back to ffmpeg: %v\n", err)
} else {
return successMethodJSON("native_mp3")
}
}
if isOggFile {
if err := EditOggFields(filePath, fields); err != nil {
GoLog("[Metadata] Native Ogg edit failed, falling back to ffmpeg: %v\n", err)
} else {
return successMethodJSON("native_ogg")
}
}
if isM4AFile || isMP4ContainerFile(filePath) {
if err := EditM4AFields(filePath, fields); err != nil {
GoLog("[Metadata] Native M4A edit failed, falling back to ffmpeg: %v\n", err)
} else {
return successMethodJSON("native_m4a")
}
}
resp := map[string]any{
"success": true,
"method": "ffmpeg",
"fields": fields,
}
s, _ := marshalJSONString(resp)
return s, nil
}
func isMP4ContainerFile(filePath string) bool {
f, err := os.Open(filePath)
if err != nil {
return false
}
defer f.Close()
header := make([]byte, 12)
n, err := f.Read(header)
if err != nil || n < 8 {
return false
}
return string(header[4:8]) == "ftyp"
}
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
allowed := map[string]struct{}{
"replaygain_track_gain": {},
"replaygain_track_peak": {},
"replaygain_album_gain": {},
"replaygain_album_peak": {},
}
hasReplayGain := false
for key, value := range fields {
if strings.TrimSpace(value) == "" {
continue
}
if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok {
hasReplayGain = true
continue
}
return false
}
return hasReplayGain
}
// RewriteSplitArtistTagsExport rewrites ARTIST and ALBUMARTIST Vorbis
// comments in a FLAC file as multiple separate entries (one per artist).
// Call this after FFmpeg metadata embedding to fix split artist tags,
// since FFmpeg deduplicates -metadata keys and only keeps the last value.
func RewriteSplitArtistTagsExport(filePath, artist, albumArtist string) (string, error) {
err := RewriteSplitArtistTags(filePath, artist, albumArtist)
if err != nil {
return errorResponse("Failed to rewrite artist tags: " + err.Error())
}
resp := map[string]any{
"success": true,
"message": "Split artist tags written successfully",
}
s, _ := marshalJSONString(resp)
return s, nil
}
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
data, err := downloadCoverToMemory(coverURL, maxQuality)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Downloaded cover to: %s (%d KB)\n", outputPath, len(data)/1024)
return nil
}
func ExtractCoverToFile(audioPath string, outputPath string) error {
lower := strings.ToLower(audioPath)
var coverData []byte
var err error
if strings.HasSuffix(lower, ".flac") {
coverData, err = ExtractCoverArt(audioPath)
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
coverData, err = extractCoverFromM4A(audioPath)
} else if strings.HasSuffix(lower, ".mp3") {
coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
coverData, _, err = extractOggCoverArt(audioPath)
} else {
return fmt.Errorf("unsupported audio format for cover extraction")
}
if err != nil {
return fmt.Errorf("failed to extract cover: %w", err)
}
if err := os.WriteFile(outputPath, coverData, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Extracted cover art to: %s (%d KB)\n", outputPath, len(coverData)/1024)
return nil
}
-202
View File
@@ -1,202 +0,0 @@
package gobackend
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
type musicBrainzTag struct {
Count int `json:"count"`
Name string `json:"name"`
}
type musicBrainzRecordingResponse struct {
Recordings []struct {
Tags []musicBrainzTag `json:"tags"`
} `json:"recordings"`
}
type musicBrainzArtistCredit struct {
Name string `json:"name"`
JoinPhrase string `json:"joinphrase"`
}
type musicBrainzRelease struct {
Title string `json:"title"`
ArtistCredit []musicBrainzArtistCredit `json:"artist-credit"`
}
type musicBrainzAlbumArtistResponse struct {
Recordings []struct {
Releases []musicBrainzRelease `json:"releases"`
} `json:"recordings"`
}
func formatMusicBrainzGenre(tags []musicBrainzTag) string {
if len(tags) == 0 {
return ""
}
caser := cases.Title(language.English)
seen := make(map[string]struct{}, len(tags))
maxCount := -1
bestTag := ""
for _, tag := range tags {
name := strings.TrimSpace(tag.Name)
if name == "" {
continue
}
key := strings.ToLower(name)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
formatted := caser.String(name)
if tag.Count > maxCount {
maxCount = tag.Count
bestTag = formatted
}
}
return bestTag
}
func formatMusicBrainzArtistCredit(credits []musicBrainzArtistCredit) string {
var builder strings.Builder
for _, credit := range credits {
name := strings.TrimSpace(credit.Name)
if name == "" {
continue
}
builder.WriteString(name)
builder.WriteString(credit.JoinPhrase)
}
return strings.TrimSpace(builder.String())
}
func selectMusicBrainzAlbumArtist(releases []musicBrainzRelease, albumName string) string {
if len(releases) == 0 {
return ""
}
normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName))
if normalizedAlbum != "" {
for _, release := range releases {
if strings.ToLower(strings.TrimSpace(release.Title)) != normalizedAlbum {
continue
}
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
return albumArtist
}
}
}
for _, release := range releases {
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
return albumArtist
}
}
return ""
}
// fetchMusicBrainzRecordingByISRC queries the MusicBrainz recording endpoint
// for the given ISRC with the supplied inc= parameter, retrying up to 3 times,
// and decodes the JSON response into payload. It returns the normalized ISRC.
func fetchMusicBrainzRecordingByISRC(isrc string, inc string, payload any) (string, error) {
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
if normalizedISRC == "" {
return "", fmt.Errorf("no ISRC provided")
}
client := NewMetadataHTTPClient(10 * time.Second)
query := fmt.Sprintf("isrc:%s", normalizedISRC)
reqURL := fmt.Sprintf(
"%s/recording?query=%s&fmt=json&inc=%s",
musicBrainzAPIBase,
url.QueryEscape(query),
inc,
)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", getRandomUserAgent())
var resp *http.Response
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
resp, lastErr = client.Do(req)
if lastErr == nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close()
}
if attempt < 2 {
time.Sleep(2 * time.Second)
}
}
if lastErr != nil {
return "", lastErr
}
if resp == nil {
return "", fmt.Errorf("MusicBrainz request failed without response")
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(payload); err != nil {
return "", err
}
return normalizedISRC, nil
}
func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) {
var payload musicBrainzAlbumArtistResponse
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "releases+artist-credits", &payload)
if err != nil {
return "", err
}
for _, recording := range payload.Recordings {
if albumArtist := selectMusicBrainzAlbumArtist(recording.Releases, albumName); albumArtist != "" {
return albumArtist, nil
}
}
return "", fmt.Errorf("no MusicBrainz album artist found for ISRC: %s", normalizedISRC)
}
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
var payload musicBrainzRecordingResponse
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "tags", &payload)
if err != nil {
return "", err
}
if len(payload.Recordings) == 0 {
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
}
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
if genre == "" {
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
}
return genre, nil
}
-785
View File
@@ -1,785 +0,0 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return GetDeezerClient().GetExtendedMetadataByISRC(ctx, isrc)
}
var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC
var fetchMusicBrainzAlbumArtistByISRC = FetchMusicBrainzAlbumArtistByISRC
type reEnrichRequest struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
LyricsMode string `json:"lyrics_mode,omitempty"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ReleaseDate string `json:"release_date"`
ISRC string `json:"isrc"`
Genre string `json:"genre"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Composer string `json:"composer"`
DurationMs int64 `json:"duration_ms"`
SearchOnline bool `json:"search_online"`
UpdateFields []string `json:"update_fields,omitempty"`
}
// shouldUpdateField returns true if the given field group should be updated.
// When UpdateFields is empty/nil, all fields are updated (backward compatible).
func (r *reEnrichRequest) shouldUpdateField(field string) bool {
if len(r.UpdateFields) == 0 {
return true
}
for _, f := range r.UpdateFields {
if f == field {
return true
}
}
return false
}
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
// callers that do not send lyrics_mode are unaffected.
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
}
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
// next to the audio file. Only 'external' and 'both' request a sidecar.
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
return mode == "external" || mode == "both"
}
// reEnrichSameRelease reports whether the candidate track appears to come
// from the same release as the file's existing album. An ISRC identifies a
// recording, not a release: the same song often also resolves to a
// compilation, whose album name, cover, and track positions must not
// replace the original release's.
func reEnrichSameRelease(currentAlbum, candidateAlbum string) bool {
if isPlaceholderReEnrichValue(currentAlbum) ||
strings.TrimSpace(candidateAlbum) == "" {
return true
}
return titlesMatch(currentAlbum, candidateAlbum)
}
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
if req == nil {
return
}
sameRelease := reEnrichSameRelease(req.AlbumName, track.AlbumName)
if !sameRelease {
GoLog("[ReEnrich] Candidate album %q differs from file album %q; keeping release identity (album, cover, positions, date)\n",
track.AlbumName, req.AlbumName)
}
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else if track.QobuzID != "" {
req.SpotifyID = "qobuz:" + track.QobuzID
} else if track.TidalID != "" {
req.SpotifyID = "tidal:" + track.TidalID
} else if track.ID != "" {
req.SpotifyID = track.ID
}
if req.shouldUpdateField("basic_tags") {
if track.Name != "" {
req.TrackName = track.Name
}
if track.Artists != "" {
req.ArtistName = track.Artists
}
if sameRelease {
if track.AlbumName != "" {
req.AlbumName = track.AlbumName
}
if track.AlbumArtist != "" {
req.AlbumArtist = track.AlbumArtist
}
}
}
if sameRelease && req.shouldUpdateField("track_info") {
if track.TrackNumber > 0 {
req.TrackNumber = track.TrackNumber
}
if track.TotalTracks > 0 {
req.TotalTracks = track.TotalTracks
}
if track.DiscNumber > 0 {
req.DiscNumber = track.DiscNumber
}
if track.TotalDiscs > 0 {
req.TotalDiscs = track.TotalDiscs
}
}
if req.shouldUpdateField("release_info") {
if sameRelease && track.ReleaseDate != "" {
req.ReleaseDate = track.ReleaseDate
}
if track.ISRC != "" {
req.ISRC = track.ISRC
}
}
if sameRelease && req.shouldUpdateField("cover") {
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
req.CoverURL = coverURL
}
}
if track.DurationMS > 0 {
req.DurationMs = int64(track.DurationMS)
}
if req.shouldUpdateField("extra") {
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
if track.Composer != "" {
req.Composer = track.Composer
}
}
}
func isPlaceholderReEnrichValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "unknown", "unknown artist", "unknown title", "unknown album":
return true
default:
return false
}
}
func buildReEnrichSearchQuery(req reEnrichRequest) string {
parts := make([]string, 0, 2)
if !isPlaceholderReEnrichValue(req.TrackName) {
parts = append(parts, strings.TrimSpace(req.TrackName))
}
if !isPlaceholderReEnrichValue(req.ArtistName) {
parts = append(parts, strings.TrimSpace(req.ArtistName))
}
if len(parts) == 0 && !isPlaceholderReEnrichValue(req.AlbumName) {
parts = append(parts, strings.TrimSpace(req.AlbumName))
}
return strings.TrimSpace(strings.Join(parts, " "))
}
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
return DownloadRequest{
TrackName: req.TrackName,
ArtistName: req.ArtistName,
AlbumName: req.AlbumName,
ReleaseDate: req.ReleaseDate,
ISRC: req.ISRC,
DurationMS: int(req.DurationMs),
ArtistTagMode: req.ArtistTagMode,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
TotalDiscs: req.TotalDiscs,
Composer: req.Composer,
}
}
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
metadata := map[string]string{}
if req.shouldUpdateField("basic_tags") {
if req.TrackName != "" {
metadata["TITLE"] = req.TrackName
}
if req.ArtistName != "" {
metadata["ARTIST"] = req.ArtistName
}
if req.AlbumName != "" {
metadata["ALBUM"] = req.AlbumName
}
if req.AlbumArtist != "" {
metadata["ALBUMARTIST"] = req.AlbumArtist
}
}
if req.shouldUpdateField("release_info") {
if req.ReleaseDate != "" {
metadata["DATE"] = req.ReleaseDate
}
if req.ISRC != "" {
metadata["ISRC"] = req.ISRC
}
}
if req.shouldUpdateField("extra") {
if req.Genre != "" {
metadata["GENRE"] = req.Genre
}
if req.Label != "" {
metadata["ORGANIZATION"] = req.Label
}
if req.Copyright != "" {
metadata["COPYRIGHT"] = req.Copyright
}
if req.Composer != "" {
metadata["COMPOSER"] = req.Composer
}
}
if req.shouldUpdateField("track_info") {
if req.TrackNumber > 0 {
metadata["TRACKNUMBER"] = formatIndexValue(req.TrackNumber, req.TotalTracks)
}
if req.DiscNumber > 0 {
metadata["DISCNUMBER"] = formatIndexValue(req.DiscNumber, req.TotalDiscs)
}
}
if req.shouldUpdateField("lyrics") {
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
metadata["LYRICS"] = lyricsLRC
metadata["UNSYNCEDLYRICS"] = lyricsLRC
}
}
return metadata
}
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
if len(tracks) == 0 {
return nil
}
downloadReq := reEnrichDownloadRequest(req)
currentISRC := strings.TrimSpace(req.ISRC)
currentAlbum := strings.TrimSpace(req.AlbumName)
effectiveTrackName := req.TrackName
if isPlaceholderReEnrichValue(effectiveTrackName) {
effectiveTrackName = ""
}
effectiveArtistName := req.ArtistName
if isPlaceholderReEnrichValue(effectiveArtistName) {
effectiveArtistName = ""
}
var best *ExtTrackMetadata
bestScore := -1 << 30
for i := range tracks {
track := &tracks[i]
score := 0
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
if !exactISRCMatch {
if effectiveTrackName != "" && !titleMatches {
continue
}
if effectiveArtistName != "" && !artistMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
continue
}
}
if verified {
score += 2000
}
if exactISRCMatch {
score += 10000
}
if titleMatches {
score += 400
}
if artistMatches {
score += 320
}
if currentAlbum != "" && track.AlbumName != "" {
switch {
case albumMatches:
score += 120
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
score += 50
}
}
if req.DurationMs > 0 && track.DurationMS > 0 {
diff := int(req.DurationMs/1000) - (track.DurationMS / 1000)
if diff < 0 {
diff = -diff
}
if diff <= 10 {
score += 80
}
}
if track.ReleaseDate != "" {
score += 70
}
if track.TrackNumber > 0 {
score += 20
}
if track.DiscNumber > 0 {
score += 10
}
if track.ISRC != "" {
score += 40
}
if best == nil || score > bestScore {
best = track
bestScore = score
}
}
return best
}
func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrackMetadata {
if track == nil {
return nil
}
deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:"))
return &ExtTrackMetadata{
ID: track.SpotifyID,
Name: track.Name,
Artists: track.Artists,
AlbumName: track.AlbumName,
AlbumArtist: track.AlbumArtist,
DurationMS: track.DurationMS,
CoverURL: track.Images,
Images: track.Images,
ReleaseDate: track.ReleaseDate,
TrackNumber: track.TrackNumber,
TotalTracks: track.TotalTracks,
DiscNumber: track.DiscNumber,
TotalDiscs: track.TotalDiscs,
ISRC: track.ISRC,
ProviderID: providerID,
DeezerID: deezerID,
SpotifyID: track.SpotifyID,
Composer: track.Composer,
Explicit: track.Explicit,
}
}
func normalizeReEnrichSpotifyTrackID(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if extracted := extractSpotifyIDFromURL(trimmed); extracted != "" {
return extracted
}
if len(trimmed) == 22 && !strings.Contains(trimmed, ":") && !strings.Contains(trimmed, "/") {
return trimmed
}
return ""
}
func resolveReEnrichTrackFromIdentifiers(req reEnrichRequest) (*ExtTrackMetadata, error) {
deezerClient := GetDeezerClient()
downloadReq := reEnrichDownloadRequest(req)
if isrc := strings.TrimSpace(req.ISRC); isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
track, err := deezerClient.SearchByISRC(ctx, isrc)
cancel()
if err == nil && track != nil {
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
return extTrackFromTrackMetadata(track, "deezer"), nil
}
}
}
sourceTrackID := strings.TrimSpace(req.SpotifyID)
if sourceTrackID == "" {
return nil, nil
}
deezerID := strings.TrimSpace(strings.TrimPrefix(sourceTrackID, "deezer:"))
if deezerID == sourceTrackID {
deezerID = extractDeezerIDFromURL(sourceTrackID)
}
if deezerID == "" {
spotifyID := normalizeReEnrichSpotifyTrackID(sourceTrackID)
if spotifyID != "" {
resolvedDeezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyID)
if err == nil {
deezerID = strings.TrimSpace(resolvedDeezerID)
}
}
}
if deezerID == "" {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil || trackResp == nil {
return nil, err
}
track := &trackResp.Track
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if !trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
return nil, nil
}
return extTrackFromTrackMetadata(track, "deezer"), nil
}
func preferredReleaseMetadata(
req DownloadRequest,
album string,
releaseDate string,
trackNumber int,
discNumber int,
) (string, string, int, int) {
preferredAlbum := strings.TrimSpace(req.AlbumName)
if preferredAlbum == "" {
preferredAlbum = album
}
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
if preferredReleaseDate == "" {
preferredReleaseDate = releaseDate
}
preferredTrackNumber := req.TrackNumber
if preferredTrackNumber == 0 {
preferredTrackNumber = trackNumber
}
preferredDiscNumber := req.DiscNumber
if preferredDiscNumber == 0 {
preferredDiscNumber = discNumber
}
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
}
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
// complete metadata from the internet before embedding.
func ReEnrichFile(requestJSON string) (string, error) {
var req reEnrichRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", fmt.Errorf("failed to parse request: %w", err)
}
if req.FilePath == "" {
return "", fmt.Errorf("file_path is required")
}
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
if req.SearchOnline {
found := false
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
manager := getExtensionManager()
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
applyReEnrichTrackMetadata(&req, *identifierTrack)
found = true
}
searchQuery := buildReEnrichSearchQuery(req)
if searchQuery != "" {
GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery)
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
if searchErr == nil && len(tracks) > 0 {
track := selectBestReEnrichTrack(req, tracks)
if track != nil {
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
applyReEnrichTrackMetadata(&req, *track)
found = true
}
} else if searchErr != nil {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
}
} else {
GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
}
if req.shouldUpdateField("basic_tags") && req.AlbumArtist == "" && req.ISRC != "" {
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
if err != nil {
GoLog("[ReEnrich] Failed to get album artist from MusicBrainz: %v\n", err)
} else if strings.TrimSpace(albumArtist) != "" {
req.AlbumArtist = strings.TrimSpace(albumArtist)
GoLog("[ReEnrich] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
found = true
}
}
// Try to enrich extra metadata from ISRC if not already set.
if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
}
if !found {
GoLog("[ReEnrich] No online match found, using existing metadata\n")
}
}
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
lower := strings.ToLower(req.FilePath)
isFlac := strings.HasSuffix(lower, ".flac")
var coverTempPath string
var coverDataBytes []byte
if req.CoverURL != "" && req.shouldUpdateField("cover") {
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
if err != nil {
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
} else {
coverDataBytes = coverData
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
// MP3/Opus requires a real image file path for Dart FFmpeg.
// FLAC uses in-memory embed and does not require temp files.
if !isFlac {
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
if err != nil {
fallbackDir := filepath.Dir(req.FilePath)
if fallbackDir == "" || fallbackDir == "." {
GoLog("[ReEnrich] Failed to create cover temp file: %v\n", err)
} else {
tmpFile, err = os.CreateTemp(fallbackDir, "reenrich_cover_*.jpg")
if err != nil {
GoLog("[ReEnrich] Failed to create cover temp file (fallback dir %s): %v\n", fallbackDir, err)
}
}
}
if err == nil && tmpFile != nil {
coverTempPath = tmpFile.Name()
if _, writeErr := tmpFile.Write(coverData); writeErr != nil {
GoLog("[ReEnrich] Failed writing cover temp file: %v\n", writeErr)
tmpFile.Close()
os.Remove(coverTempPath)
coverTempPath = ""
} else if closeErr := tmpFile.Close(); closeErr != nil {
GoLog("[ReEnrich] Failed closing cover temp file: %v\n", closeErr)
os.Remove(coverTempPath)
coverTempPath = ""
}
}
}
}
}
// Only cleanup cover temp for FLAC (native embed).
// For MP3/Opus, Dart needs the file for FFmpeg — Dart handles cleanup.
cleanupCover := true
defer func() {
if cleanupCover && coverTempPath != "" {
os.Remove(coverTempPath)
}
}()
// Preserve existing lyrics when online enrichment does not return a replacement.
var lyricsLRC string
if req.shouldUpdateField("lyrics") {
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
lyricsLRC = existingLyrics
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
}
}
if req.EmbedLyrics && req.shouldUpdateField("lyrics") {
client := NewLyricsClient()
durationSec := float64(req.DurationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec)
if err != nil {
GoLog("[ReEnrich] Lyrics not found: %v\n", err)
} else if !lyrics.Instrumental {
lyricsLRC = convertToLRCWithMetadata(lyrics, req.TrackName, req.ArtistName)
GoLog("[ReEnrich] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
GoLog("[ReEnrich] Track is instrumental\n")
}
}
// Build enrichedMeta map: only include fields from selected update groups
// so that the caller (Dart) does not overwrite non-selected metadata in its
// local library database with potentially stale cached values.
enrichedMeta := map[string]any{
"spotify_id": req.SpotifyID,
"duration_ms": req.DurationMs,
}
if req.shouldUpdateField("basic_tags") {
enrichedMeta["track_name"] = req.TrackName
enrichedMeta["artist_name"] = req.ArtistName
enrichedMeta["album_name"] = req.AlbumName
enrichedMeta["album_artist"] = req.AlbumArtist
}
if req.shouldUpdateField("track_info") {
enrichedMeta["track_number"] = req.TrackNumber
enrichedMeta["total_tracks"] = req.TotalTracks
enrichedMeta["disc_number"] = req.DiscNumber
enrichedMeta["total_discs"] = req.TotalDiscs
}
if req.shouldUpdateField("release_info") {
enrichedMeta["release_date"] = req.ReleaseDate
enrichedMeta["isrc"] = req.ISRC
}
if req.shouldUpdateField("cover") {
enrichedMeta["cover_url"] = req.CoverURL
}
if req.shouldUpdateField("extra") {
enrichedMeta["genre"] = req.Genre
enrichedMeta["label"] = req.Label
enrichedMeta["copyright"] = req.Copyright
enrichedMeta["composer"] = req.Composer
}
if isFlac {
// Only populate Metadata fields for selected update groups; empty/zero
// values cause EmbedMetadata's setComment() to skip those tags,
// preserving whatever is already in the file.
metadata := Metadata{
ArtistTagMode: req.ArtistTagMode,
}
if req.shouldUpdateField("basic_tags") {
metadata.Title = req.TrackName
metadata.Artist = req.ArtistName
metadata.Album = req.AlbumName
metadata.AlbumArtist = req.AlbumArtist
}
if req.shouldUpdateField("track_info") {
metadata.TrackNumber = req.TrackNumber
metadata.TotalTracks = req.TotalTracks
metadata.DiscNumber = req.DiscNumber
metadata.TotalDiscs = req.TotalDiscs
}
if req.shouldUpdateField("release_info") {
metadata.Date = req.ReleaseDate
metadata.ISRC = req.ISRC
}
if req.shouldUpdateField("lyrics") {
if req.lyricsEmbedEnabled() {
metadata.Lyrics = lyricsLRC
}
}
if req.shouldUpdateField("extra") {
metadata.Genre = req.Genre
metadata.Label = req.Label
metadata.Copyright = req.Copyright
metadata.Composer = req.Composer
}
if len(coverDataBytes) > 0 {
if err := EmbedMetadataWithCoverData(req.FilePath, metadata, coverDataBytes); err != nil {
return "", fmt.Errorf("failed to embed metadata with cover: %w", err)
}
} else {
if err := EmbedMetadata(req.FilePath, metadata, ""); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
}
if len(coverDataBytes) > 0 {
embeddedCover, err := ExtractCoverArt(req.FilePath)
if err != nil || len(embeddedCover) == 0 {
if err != nil {
return "", fmt.Errorf("metadata embedded but cover verification failed: %w", err)
}
return "", fmt.Errorf("metadata embedded but cover verification failed: empty embedded cover")
}
GoLog("[ReEnrich] Cover verified after embed (%d bytes)\n", len(embeddedCover))
}
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
result := map[string]any{
"method": "native",
"success": true,
"enriched_metadata": enrichedMeta,
"lyrics": lyricsLRC,
"write_external_lrc": req.EmbedLyrics &&
req.shouldUpdateField("lyrics") &&
req.lyricsSidecarEnabled() &&
strings.TrimSpace(lyricsLRC) != "",
}
s, _ := marshalJSONString(result)
return s, nil
}
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false
ffmpegMetadata := buildReEnrichFFmpegMetadata(&req, lyricsLRC)
result := map[string]any{
"method": "ffmpeg",
"cover_path": coverTempPath,
"lyrics": lyricsLRC,
"enriched_metadata": enrichedMeta,
"metadata": ffmpegMetadata,
"write_external_lrc": req.EmbedLyrics &&
req.shouldUpdateField("lyrics") &&
req.lyricsSidecarEnabled() &&
strings.TrimSpace(lyricsLRC) != "",
}
s, _ := marshalJSONString(result)
return s, nil
}
-148
View File
@@ -1,148 +0,0 @@
package gobackend
import (
"fmt"
"net/url"
"path/filepath"
"strings"
)
func InitExtensionRepoJSON(cacheDir string) error {
initExtensionRepo(cacheDir)
return nil
}
func SetRepoRegistryURLJSON(registryURL string) error {
repo := getExtensionRepo()
if repo == nil {
return fmt.Errorf("extension repo not initialized")
}
resolved, err := resolveRegistryURL(registryURL)
if err != nil {
return err
}
if err := requireHTTPSURL(resolved, "registry"); err != nil {
return err
}
repo.setRegistryURL(resolved)
return nil
}
func ClearRepoRegistryURLJSON() error {
repo := getExtensionRepo()
if repo == nil {
return fmt.Errorf("extension repo not initialized")
}
repo.setRegistryURL("")
repo.clearCache()
return nil
}
func GetRepoRegistryURLJSON() (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
return repo.getRegistryURL(), nil
}
func GetRepoExtensionsJSON(forceRefresh bool) (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
extensions, err := repo.getExtensionsWithStatus(forceRefresh)
if err != nil {
return "", err
}
return marshalJSONString(extensions)
}
func SearchRepoExtensionsJSON(query, category string) (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
extensions, err := repo.searchExtensions(query, category)
if err != nil {
return "", err
}
return marshalJSONString(extensions)
}
func GetRepoCategoriesJSON() (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
categories := repo.getCategories()
return marshalJSONString(categories)
}
func repoExtensionPackageSuffix(downloadURL string) string {
rawPath := downloadURL
if parsed, err := url.Parse(downloadURL); err == nil {
rawPath = parsed.Path
}
lowerPath := strings.ToLower(rawPath)
if strings.HasSuffix(lowerPath, ".sflx") {
return ".sflx"
}
if strings.HasSuffix(lowerPath, ".spotiflac-ext") {
return ".spotiflac-ext"
}
return ".spotiflac-ext"
}
func buildRepoExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) {
if strings.TrimSpace(extensionID) == "" {
return "", fmt.Errorf("invalid extension id")
}
safeExtensionID := sanitizeFilename(extensionID)
return filepath.Join(destDir, safeExtensionID+repoExtensionPackageSuffix(downloadURL)), nil
}
func DownloadRepoExtensionJSON(extensionID, destDir string) (string, error) {
repo := getExtensionRepo()
if repo == nil {
return "", fmt.Errorf("extension repo not initialized")
}
ext, err := repo.findExtension(extensionID)
if err != nil {
return "", err
}
destPath, err := buildRepoExtensionDestPath(destDir, extensionID, ext.getDownloadURL())
if err != nil {
return "", err
}
err = repo.downloadExtension(extensionID, destPath)
if err != nil {
return "", err
}
return destPath, nil
}
func ClearRepoCacheJSON() error {
repo := getExtensionRepo()
if repo == nil {
return fmt.Errorf("extension repo not initialized")
}
repo.clearCache()
return nil
}
@@ -1,136 +0,0 @@
package gobackend
import (
"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
defer func() {
globalSongLinkClient = origClient
songLinkRetryConfig = origRetryConfig
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
switch req.URL.Host {
case "api.zarz.moe":
body = `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/spotify-1","Deezer":"https://www.deezer.com/track/101","Tidal":"https://listen.tidal.com/track/202","YouTube":"https://youtu.be/yt1","AmazonMusic":"https://music.amazon.com/tracks/amz1","Qobuz":"https://open.qobuz.com/track/303"}}`
case "api.song.link":
body = `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}`
default:
t.Fatalf("unexpected SongLink request: %s", req.URL.String())
}
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
})}}
songLinkClientOnce.Do(func() {})
SetSongLinkNetworkOptions(true, true)
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)
}
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)
}
}
-510
View File
@@ -1,510 +0,0 @@
package gobackend
import (
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
got := classifyDownloadErrorType("All providers failed. Last error: HTTP status 429: too many requests")
if got != "rate_limit" {
t.Fatalf("expected rate_limit, got %q", got)
}
responseJSON, err := errorResponse("All services failed. Last error: rate limit exceeded")
if err != nil {
t.Fatalf("errorResponse returned error: %v", err)
}
var response DownloadResponse
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
t.Fatalf("invalid response JSON: %v", err)
}
if response.ErrorType != "rate_limit" {
t.Fatalf("expected rate_limit response, got %q", response.ErrorType)
}
}
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
cases := []string{
"HTTP 401 for /tickets",
"HTTP status 428: precondition required",
"Verification required",
}
for _, tc := range cases {
if got := classifyDownloadErrorType(tc); got != "verification_required" {
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
}
}
}
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
dir := t.TempDir()
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
CleanupExtensions()
defer CleanupExtensions()
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
ext.ID = "deezer"
ext.Manifest.Name = "deezer"
manager := getExtensionManager()
manager.mu.Lock()
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
manager.mu.Unlock()
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
if err != nil {
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
}
if !strings.Contains(jsonText, "album-track") {
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
}
}
func TestExtensionTrackExportsPreserveExplicitFlag(t *testing.T) {
dir := t.TempDir()
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
manager := getExtensionManager()
manager.mu.Lock()
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
manager.mu.Unlock()
defer CleanupExtensions()
assertExplicit := func(name, jsonText string, err error) {
t.Helper()
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if !strings.Contains(jsonText, `"explicit":true`) {
t.Fatalf("%s dropped explicit flag: %s", name, jsonText)
}
}
jsonText, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`)
assertExplicit("custom search", jsonText, err)
for _, resourceType := range []string{"track", "album", "playlist", "artist"} {
jsonText, err = GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1")
assertExplicit("provider metadata "+resourceType, jsonText, err)
}
jsonText, err = HandleURLWithExtensionJSON("https://example.test/track/1")
assertExplicit("URL handler", jsonText, err)
}
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
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 := 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)
}
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 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 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 := InitExtensionRepoJSON(storeDir); err != nil {
t.Fatalf("InitExtensionRepoJSON: %v", err)
}
if err := SetRepoRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
t.Fatalf("SetRepoRegistryURLJSON: %v", err)
}
store := getExtensionRepo()
store.cache = &repoRegistry{Extensions: []repoExtension{{
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 := GetRepoRegistryURLJSON(); err != nil || registryURL == "" {
t.Fatalf("GetRepoRegistryURLJSON = %q/%v", registryURL, err)
}
if storeJSON, err := GetRepoExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("GetRepoExtensionsJSON = %q/%v", storeJSON, err)
}
if storeJSON, err := SearchRepoExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("SearchRepoExtensionsJSON = %q/%v", storeJSON, err)
}
if catsJSON, err := GetRepoCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
t.Fatalf("GetRepoCategoriesJSON = %q/%v", catsJSON, err)
}
if dest, err := buildRepoExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.spotiflac-ext",
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
t.Fatalf("buildRepoExtensionDestPath = %q/%v", dest, err)
}
if dest, err := buildRepoExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.sflx",
); err != nil || !strings.HasSuffix(dest, ".sflx") {
t.Fatalf("buildRepoExtensionDestPath sflx = %q/%v", dest, err)
}
if _, err := buildRepoExtensionDestPath(
dir,
" ",
"https://registry.example.com/coverage.sflx",
); err == nil {
t.Fatal("expected invalid extension id")
}
if err := ClearRepoCacheJSON(); err != nil {
t.Fatalf("ClearRepoCacheJSON: %v", err)
}
if err := ClearRepoRegistryURLJSON(); err != nil {
t.Fatalf("ClearRepoRegistryURLJSON: %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 := ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing", "key"); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataWithHintAndCoverCacheKeyJSON = %q/%v", metadataJSON, err)
}
}
+9 -429
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",
@@ -352,7 +123,7 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
AlbumName: "Original Album (Deluxe)",
AlbumName: "Resolved Album",
ReleaseDate: "",
ISRC: "",
})
@@ -360,7 +131,7 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
if req.ReleaseDate != "2024-01-01" {
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
}
if req.AlbumName != "Original Album (Deluxe)" {
if req.AlbumName != "Resolved Album" {
t.Fatalf("album = %q, want updated album", req.AlbumName)
}
if req.ISRC != "REQ123" {
@@ -368,43 +139,6 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
}
}
func TestApplyReEnrichTrackMetadataKeepsReleaseIdentityOnAlbumMismatch(t *testing.T) {
req := reEnrichRequest{
TrackName: "Afsana",
ArtistName: "Artist Name",
AlbumName: "Original Soundtrack",
CoverURL: "https://covers/original.jpg",
TrackNumber: 3,
ReleaseDate: "2005-01-01",
}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
Name: "Afsana",
Artists: "Artist Name",
AlbumName: "The Hit Machine",
CoverURL: "https://covers/compilation.jpg",
TrackNumber: 17,
ReleaseDate: "2010-01-01",
ISRC: "NEW123",
})
if req.AlbumName != "Original Soundtrack" {
t.Fatalf("album = %q, want original release kept", req.AlbumName)
}
if req.CoverURL != "https://covers/original.jpg" {
t.Fatalf("cover = %q, want original release cover kept", req.CoverURL)
}
if req.TrackNumber != 3 {
t.Fatalf("track number = %d, want original position kept", req.TrackNumber)
}
if req.ReleaseDate != "2005-01-01" {
t.Fatalf("release date = %q, want original date kept", req.ReleaseDate)
}
if req.ISRC != "NEW123" {
t.Fatalf("isrc = %q, want recording-level fields still enriched", req.ISRC)
}
}
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
@@ -444,90 +178,6 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
}
}
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
AlbumName: "Album Name",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "wrong-rich-metadata",
Name: "Different Song",
Artists: "Different Artist",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "2024-03-09",
TrackNumber: 4,
DiscNumber: 1,
ISRC: "WRONG1234567",
ProviderID: "deezer",
},
}
if best := selectBestReEnrichTrack(req, tracks); best != nil {
t.Fatalf("selected track = %q, want no match", best.ID)
}
}
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
ISRC: "USRC17607839",
DurationMs: 999999000,
}
tracks := []ExtTrackMetadata{
{
ID: "same-isrc",
Name: "Different Song",
Artists: "Different Artist",
DurationMS: 180000,
ISRC: "USRC17607839",
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected exact ISRC candidate to be selected")
}
if best.ID != "same-isrc" {
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
}
}
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
req := reEnrichRequest{
TrackName: "Unknown Title",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "album-match",
Name: "Sign of the Times",
Artists: "Harry Styles",
AlbumName: "Harry Styles",
DurationMS: 180000,
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
}
if best.ID != "album-match" {
t.Fatalf("selected track = %q, want album-match", best.ID)
}
}
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song",
@@ -545,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"])
@@ -572,75 +224,3 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
}
}
}
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
req := reEnrichRequest{
TrackName: "Sign of the Times",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
}
query := buildReEnrichSearchQuery(req)
if query != "Sign of the Times" {
t.Fatalf("query = %q", query)
}
req = reEnrichRequest{
TrackName: "Unknown Title",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
}
query = buildReEnrichSearchQuery(req)
if query != "Harry Styles" {
t.Fatalf("fallback album query = %q", query)
}
}
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
req := reEnrichRequest{}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
Name: "Resolved Song",
Artists: "Resolved Artist",
TrackNumber: 7,
TotalTracks: 12,
DiscNumber: 2,
TotalDiscs: 3,
Composer: "Composer",
})
if req.TrackNumber != 7 || req.TotalTracks != 12 {
t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks)
}
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
}
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
}
if req.Composer != "Composer" {
t.Fatalf("composer = %q", req.Composer)
}
}
func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) {
req := reEnrichRequest{
TrackNumber: 7,
TotalTracks: 12,
DiscNumber: 2,
TotalDiscs: 3,
Composer: "Composer",
}
metadata := buildReEnrichFFmpegMetadata(&req, "")
if metadata["TRACKNUMBER"] != "7/12" {
t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"])
}
if metadata["DISCNUMBER"] != "2/3" {
t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"])
}
if metadata["COMPOSER"] != "Composer" {
t.Fatalf("COMPOSER = %q", metadata["COMPOSER"])
}
}
-57
View File
@@ -1,57 +0,0 @@
package gobackend
import (
"net/http"
"net/url"
"testing"
"time"
)
func TestSimpleCookieJarReplacesAndExpiresCookies(t *testing.T) {
jar, err := newSimpleCookieJar()
if err != nil {
t.Fatalf("newSimpleCookieJar: %v", err)
}
u, _ := url.Parse("https://api.example.com/path")
jar.SetCookies(u, []*http.Cookie{{Name: "session", Value: "old", Path: "/"}})
jar.SetCookies(u, []*http.Cookie{{Name: "session", Value: "new", Path: "/"}})
cookies := jar.Cookies(u)
if len(cookies) != 1 || cookies[0].Value != "new" {
t.Fatalf("replacement cookies = %#v", cookies)
}
jar.SetCookies(u, []*http.Cookie{{
Name: "session",
Value: "expired",
Path: "/",
Expires: time.Now().Add(-time.Hour),
MaxAge: -1,
}})
if cookies := jar.Cookies(u); len(cookies) != 0 {
t.Fatalf("expired cookies = %#v", cookies)
}
}
func TestSimpleCookieJarClearAndScope(t *testing.T) {
jar, err := newSimpleCookieJar()
if err != nil {
t.Fatalf("newSimpleCookieJar: %v", err)
}
apiURL, _ := url.Parse("https://api.example.com/private/resource")
otherURL, _ := url.Parse("https://other.example.com/private/resource")
publicURL, _ := url.Parse("https://api.example.com/public")
jar.SetCookies(apiURL, []*http.Cookie{{Name: "session", Value: "value", Path: "/private"}})
if len(jar.Cookies(apiURL)) != 1 {
t.Fatal("expected cookie for matching host and path")
}
if len(jar.Cookies(otherURL)) != 0 || len(jar.Cookies(publicURL)) != 0 {
t.Fatal("cookie escaped its host or path scope")
}
jar.Clear()
if len(jar.Cookies(apiURL)) != 0 {
t.Fatal("Clear retained cookies")
}
}
File diff suppressed because it is too large Load Diff
@@ -1,122 +0,0 @@
package gobackend
import (
"path/filepath"
"testing"
)
func TestVerifiedDownloadResumeTriesSelectedProviderBeforeMetadata(t *testing.T) {
resetPreparedDownloadRequestCacheForTest()
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
metadataExt := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
metadataExt.ID = "resume-metadata"
metadataExt.Manifest.Name = metadataExt.ID
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
downloadExt.ID = "resume-download"
downloadExt.Manifest.Name = downloadExt.ID
manager := getExtensionManager()
manager.mu.Lock()
previousExtensions := manager.extensions
manager.extensions = map[string]*loadedExtension{
metadataExt.ID: metadataExt,
downloadExt.ID: downloadExt,
}
manager.mu.Unlock()
t.Cleanup(func() {
teardownExtension(metadataExt)
teardownExtension(downloadExt)
manager.mu.Lock()
manager.extensions = previousExtensions
manager.mu.Unlock()
})
req := DownloadRequest{
ItemID: "resume-item",
Service: downloadExt.ID,
Source: metadataExt.ID,
SpotifyID: "spotify:track:1",
TrackName: "Original Song",
ArtistName: "Artist",
AlbumName: "Album",
ReleaseDate: "2026-05-04",
OutputDir: t.TempDir(),
OutputExt: ".flac",
FilenameFormat: "{title}",
Quality: "LOSSLESS",
UseFallback: false,
}
key := downloadPreparationKey(req)
cacheUnpreparedDownloadRequest(key, req)
resp, err := DownloadWithExtensionFallback(req)
if err != nil {
t.Fatalf("DownloadWithExtensionFallback: %v", err)
}
if resp == nil || !resp.Success {
t.Fatalf("resume response = %#v", resp)
}
if got := filepath.Base(resp.FilePath); got != "Original Song.flac" {
t.Fatalf("resume ran metadata enrichment before download: file = %q", got)
}
}
func TestVerifiedDownloadResumeReusesPreparedMetadata(t *testing.T) {
resetPreparedDownloadRequestCacheForTest()
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
metadataExt := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
metadataExt.ID = "prepared-metadata"
metadataExt.Manifest.Name = metadataExt.ID
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
downloadExt.ID = "prepared-download"
downloadExt.Manifest.Name = downloadExt.ID
manager := getExtensionManager()
manager.mu.Lock()
previousExtensions := manager.extensions
manager.extensions = map[string]*loadedExtension{
metadataExt.ID: metadataExt,
downloadExt.ID: downloadExt,
}
manager.mu.Unlock()
t.Cleanup(func() {
teardownExtension(metadataExt)
teardownExtension(downloadExt)
manager.mu.Lock()
manager.extensions = previousExtensions
manager.mu.Unlock()
})
req := DownloadRequest{
ItemID: "prepared-item",
Service: downloadExt.ID,
Source: metadataExt.ID,
SpotifyID: "spotify:track:2",
TrackName: "Original Song",
ArtistName: "Artist",
AlbumName: "Album",
ReleaseDate: "2026-05-04",
OutputDir: t.TempDir(),
OutputExt: ".flac",
FilenameFormat: "{title}",
Quality: "LOSSLESS",
UseFallback: false,
}
key := downloadPreparationKey(req)
prepared := req
prepared.TrackName = "Prepared Song"
cachePreparedDownloadRequest(key, prepared)
resp, err := DownloadWithExtensionFallback(req)
if err != nil {
t.Fatalf("DownloadWithExtensionFallback: %v", err)
}
if resp == nil || !resp.Success {
t.Fatalf("prepared response = %#v", resp)
}
if got := filepath.Base(resp.FilePath); got != "Prepared Song.flac" {
t.Fatalf("prepared metadata was not reused: file = %q", got)
}
}
-647
View File
@@ -1,647 +0,0 @@
package gobackend
import (
"fmt"
"strconv"
"strings"
"github.com/dop251/goja"
)
func gojaValueIsEmpty(value goja.Value) bool {
return value == nil || goja.IsUndefined(value) || goja.IsNull(value)
}
func gojaObjectString(obj *goja.Object, keys ...string) string {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
if str, ok := value.Export().(string); ok {
return str
}
}
return ""
}
func gojaObjectValue(obj *goja.Object, keys ...string) goja.Value {
for _, key := range keys {
value := obj.Get(key)
if !gojaValueIsEmpty(value) {
return value
}
}
return nil
}
func gojaObjectInt(obj *goja.Object, keys ...string) int {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
return int(value.ToInteger())
}
return 0
}
func gojaObjectInt64(obj *goja.Object, keys ...string) int64 {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
return value.ToInteger()
}
return 0
}
func gojaObjectBool(obj *goja.Object, keys ...string) bool {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
return value.ToBoolean()
}
return false
}
func gojaObjectInterfaceMap(obj *goja.Object, keys ...string) map[string]any {
value := gojaObjectValue(obj, keys...)
if gojaValueIsEmpty(value) {
return nil
}
exported, ok := value.Export().(map[string]any)
if !ok || len(exported) == 0 {
return nil
}
return exported
}
func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map[string]string {
value := gojaObjectValue(obj, keys...)
if gojaValueIsEmpty(value) {
return nil
}
valueObj := value.ToObject(vm)
objectKeys := valueObj.Keys()
if len(objectKeys) == 0 {
return nil
}
result := make(map[string]string, len(objectKeys))
for _, childKey := range objectKeys {
childValue := valueObj.Get(childKey)
if gojaValueIsEmpty(childValue) {
continue
}
result[childKey] = childValue.String()
}
if len(result) == 0 {
return nil
}
return result
}
func gojaObjectStringSlice(obj *goja.Object, keys ...string) []string {
value := gojaObjectValue(obj, keys...)
if gojaValueIsEmpty(value) {
return nil
}
exported, ok := value.Export().([]any)
if !ok || len(exported) == 0 {
return nil
}
result := make([]string, 0, len(exported))
for _, item := range exported {
str, ok := item.(string)
if !ok {
continue
}
str = strings.TrimSpace(str)
if str != "" {
result = append(result, str)
}
}
if len(result) == 0 {
return nil
}
return result
}
func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) {
if gojaValueIsEmpty(value) {
return 0, nil
}
lengthValue := value.ToObject(vm).Get("length")
if gojaValueIsEmpty(lengthValue) {
return 0, fmt.Errorf("value is not an array")
}
length := lengthValue.ToInteger()
if length <= 0 {
return 0, nil
}
return int(length), nil
}
func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetadata {
obj := value.ToObject(vm)
return ExtTrackMetadata{
ID: gojaObjectString(obj, "id"),
Name: gojaObjectString(obj, "name"),
Artists: gojaObjectString(obj, "artists"),
AlbumName: gojaObjectString(obj, "album_name", "albumName"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
AlbumID: gojaObjectString(obj, "album_id", "albumId"),
AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"),
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"),
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"),
Images: gojaObjectString(obj, "images"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
ISRC: gojaObjectString(obj, "isrc"),
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
ItemType: gojaObjectString(obj, "item_type", "itemType"),
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
Explicit: gojaObjectBool(obj, "explicit", "is_explicit", "isExplicit"),
TidalID: gojaObjectString(obj, "tidal_id", "tidalId"),
QobuzID: gojaObjectString(obj, "qobuz_id", "qobuzId"),
DeezerID: gojaObjectString(obj, "deezer_id", "deezerId"),
SpotifyID: gojaObjectString(obj, "spotify_id", "spotifyId"),
ExternalLinks: gojaObjectStringMap(vm, obj, "external_links", "externalLinks"),
Label: gojaObjectString(obj, "label"),
Copyright: gojaObjectString(obj, "copyright"),
Genre: gojaObjectString(obj, "genre"),
Composer: gojaObjectString(obj, "composer"),
AudioQuality: gojaObjectString(obj, "audio_quality", "audioQuality"),
AudioModes: gojaObjectString(obj, "audio_modes", "audioModes"),
}
}
func parseExtensionTrackArray(vm *goja.Runtime, value goja.Value) ([]ExtTrackMetadata, error) {
length, err := gojaArrayLength(value, vm)
if err != nil {
return nil, err
}
if length == 0 {
return []ExtTrackMetadata{}, nil
}
arrayObj := value.ToObject(vm)
tracks := make([]ExtTrackMetadata, 0, length)
for i := 0; i < length; i++ {
trackValue := arrayObj.Get(strconv.Itoa(i))
if gojaValueIsEmpty(trackValue) {
continue
}
tracks = append(tracks, parseExtensionTrackValue(vm, trackValue))
}
return tracks, nil
}
func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetadata, error) {
if gojaValueIsEmpty(value) {
return ExtAlbumMetadata{}, nil
}
obj := value.ToObject(vm)
tracks := []ExtTrackMetadata{}
if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) {
parsedTracks, err := parseExtensionTrackArray(vm, tracksValue)
if err != nil {
return ExtAlbumMetadata{}, err
}
tracks = parsedTracks
}
return ExtAlbumMetadata{
ID: gojaObjectString(obj, "id"),
Name: gojaObjectString(obj, "name"),
Artists: gojaObjectString(obj, "artists"),
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"),
Tracks: tracks,
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
}.withTrackFallbacks(), nil
}
// withTrackFallbacks fills the album-level artist and release date from the
// album's tracks when the extension did not provide them at the album level.
// This is a generic mechanism so any extension benefits, without per-extension
// special-casing in the app.
func (a ExtAlbumMetadata) withTrackFallbacks() ExtAlbumMetadata {
if strings.TrimSpace(a.Artists) == "" {
a.Artists = albumArtistFromTracks(a.Tracks)
}
if strings.TrimSpace(a.ReleaseDate) == "" {
a.ReleaseDate = albumReleaseDateFromTracks(a.Tracks)
}
if len(a.AudioTraits) == 0 {
a.AudioTraits = albumAudioTraitsFromTracks(a.Tracks)
}
return a
}
// albumArtistFromTracks prefers an explicit per-track album artist, then falls
// back to the most common track artist across the album.
func albumArtistFromTracks(tracks []ExtTrackMetadata) string {
for _, t := range tracks {
if s := strings.TrimSpace(t.AlbumArtist); s != "" {
return s
}
}
counts := map[string]int{}
order := []string{}
for _, t := range tracks {
artist := strings.TrimSpace(t.Artists)
if artist == "" {
continue
}
if _, ok := counts[artist]; !ok {
order = append(order, artist)
}
counts[artist]++
}
best := ""
bestCount := 0
for _, artist := range order {
if counts[artist] > bestCount {
best = artist
bestCount = counts[artist]
}
}
return best
}
// albumReleaseDateFromTracks returns the first non-empty track release date.
func albumReleaseDateFromTracks(tracks []ExtTrackMetadata) string {
for _, t := range tracks {
if s := strings.TrimSpace(t.ReleaseDate); s != "" {
return s
}
}
return ""
}
// albumAudioTraitsFromTracks derives album-level audio badges (Dolby Atmos,
// Hi-Res Lossless, Lossless) from the per-track audio quality/mode fields that
// extensions like Tidal and Qobuz already provide. Tokens match what the album
// header understands ("dolby_atmos", "hi_res_lossless", "lossless").
func albumAudioTraitsFromTracks(tracks []ExtTrackMetadata) []string {
atmos := false
hiRes := false
lossless := false
for _, t := range tracks {
modes := strings.ToUpper(t.AudioModes)
quality := strings.ToUpper(t.AudioQuality)
if strings.Contains(modes, "ATMOS") || strings.Contains(quality, "ATMOS") {
atmos = true
}
if strings.Contains(quality, "HI_RES") ||
strings.Contains(quality, "HIRES") ||
strings.Contains(quality, "MASTER") ||
strings.Contains(quality, "MQA") {
hiRes = true
}
if strings.Contains(quality, "LOSSLESS") ||
strings.Contains(quality, "FLAC") {
lossless = true
}
if bd, sr := parseBitDepthSampleRate(quality); bd > 0 {
if bd > 16 || sr > 48 {
hiRes = true
} else {
lossless = true
}
}
}
traits := []string{}
if atmos {
traits = append(traits, "dolby_atmos")
}
if hiRes {
traits = append(traits, "hi_res_lossless")
} else if lossless {
traits = append(traits, "lossless")
}
return traits
}
// parseBitDepthSampleRate extracts a bit depth and sample rate (in kHz) from
// labels such as "24bit/96kHz", "16bit/44.1kHz" or "24bit".
func parseBitDepthSampleRate(quality string) (int, float64) {
lower := strings.ToLower(quality)
bitDepth := 0
sampleRate := 0.0
if idx := strings.Index(lower, "bit"); idx > 0 {
j := idx
for j > 0 && lower[j-1] >= '0' && lower[j-1] <= '9' {
j--
}
if n, err := strconv.Atoi(lower[j:idx]); err == nil {
bitDepth = n
}
}
if idx := strings.Index(lower, "khz"); idx > 0 {
j := idx
for j > 0 && ((lower[j-1] >= '0' && lower[j-1] <= '9') || lower[j-1] == '.') {
j--
}
if f, err := strconv.ParseFloat(lower[j:idx], 64); err == nil {
sampleRate = f
}
}
return bitDepth, sampleRate
}
func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) {
length, err := gojaArrayLength(value, vm)
if err != nil {
return nil, err
}
if length == 0 {
return []ExtAlbumMetadata{}, nil
}
arrayObj := value.ToObject(vm)
albums := make([]ExtAlbumMetadata, 0, length)
for i := 0; i < length; i++ {
albumValue := arrayObj.Get(strconv.Itoa(i))
if gojaValueIsEmpty(albumValue) {
continue
}
album, err := parseExtensionAlbumValue(vm, albumValue)
if err != nil {
return nil, err
}
albums = append(albums, album)
}
return albums, nil
}
func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMetadata, error) {
if gojaValueIsEmpty(value) {
return ExtArtistMetadata{}, nil
}
obj := value.ToObject(vm)
albums := []ExtAlbumMetadata{}
if albumsValue := gojaObjectValue(obj, "albums"); !gojaValueIsEmpty(albumsValue) {
parsedAlbums, err := parseExtensionAlbumArray(vm, albumsValue)
if err != nil {
return ExtArtistMetadata{}, err
}
albums = parsedAlbums
}
releases := []ExtAlbumMetadata{}
if releasesValue := gojaObjectValue(obj, "releases"); !gojaValueIsEmpty(releasesValue) {
parsedReleases, err := parseExtensionAlbumArray(vm, releasesValue)
if err != nil {
return ExtArtistMetadata{}, err
}
releases = parsedReleases
}
topTracks := []ExtTrackMetadata{}
if topTracksValue := gojaObjectValue(obj, "top_tracks", "topTracks"); !gojaValueIsEmpty(topTracksValue) {
parsedTopTracks, err := parseExtensionTrackArray(vm, topTracksValue)
if err != nil {
return ExtArtistMetadata{}, err
}
topTracks = parsedTopTracks
}
return ExtArtistMetadata{
ID: gojaObjectString(obj, "id"),
Name: gojaObjectString(obj, "name"),
ImageURL: gojaObjectString(obj, "image_url", "imageUrl"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
Listeners: gojaObjectInt(obj, "listeners"),
Albums: albums,
Releases: releases,
TopTracks: topTracks,
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
}, nil
}
func parseExtensionAvailabilityValue(vm *goja.Runtime, value goja.Value) ExtAvailabilityResult {
obj := value.ToObject(vm)
return ExtAvailabilityResult{
Available: gojaObjectBool(obj, "available"),
Reason: gojaObjectString(obj, "reason"),
TrackID: gojaObjectString(obj, "track_id", "trackId"),
SkipFallback: gojaObjectBool(obj, "skip_fallback", "skipFallback"),
}
}
func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *DownloadDecryptionInfo {
if gojaValueIsEmpty(value) {
return nil
}
obj := value.ToObject(vm)
info := &DownloadDecryptionInfo{
Strategy: gojaObjectString(obj, "strategy"),
Key: gojaObjectString(obj, "key"),
IV: gojaObjectString(obj, "iv"),
InputFormat: gojaObjectString(obj, "input_format", "inputFormat"),
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
Options: gojaObjectInterfaceMap(obj, "options"),
}
if info.Strategy == "" && info.Key == "" && info.IV == "" && info.InputFormat == "" && info.OutputExtension == "" && len(info.Options) == 0 {
return nil
}
return info
}
func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult {
obj := value.ToObject(vm)
return ExtDownloadResult{
Success: gojaObjectBool(obj, "success"),
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
Title: gojaObjectString(obj, "title"),
Artist: gojaObjectString(obj, "artist"),
Album: gojaObjectString(obj, "album"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
ISRC: gojaObjectString(obj, "isrc"),
Genre: gojaObjectString(obj, "genre"),
Label: gojaObjectString(obj, "label"),
Copyright: gojaObjectString(obj, "copyright"),
Composer: gojaObjectString(obj, "composer"),
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
RequiresContainerConversion: gojaObjectBool(
obj,
"requires_container_conversion",
"requiresContainerConversion",
),
}
}
func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) {
obj := value.ToObject(vm)
handleResult := ExtURLHandleResult{
Type: gojaObjectString(obj, "type"),
Name: gojaObjectString(obj, "name"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
}
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
track := parseExtensionTrackValue(vm, trackValue)
handleResult.Track = &track
}
if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) {
tracks, err := parseExtensionTrackArray(vm, tracksValue)
if err != nil {
return ExtURLHandleResult{}, err
}
handleResult.Tracks = tracks
}
if albumValue := gojaObjectValue(obj, "album"); !gojaValueIsEmpty(albumValue) {
album, err := parseExtensionAlbumValue(vm, albumValue)
if err != nil {
return ExtURLHandleResult{}, err
}
handleResult.Album = &album
}
if artistValue := gojaObjectValue(obj, "artist"); !gojaValueIsEmpty(artistValue) {
artist, err := parseExtensionArtistValue(vm, artistValue)
if err != nil {
return ExtURLHandleResult{}, err
}
handleResult.Artist = &artist
}
return handleResult, nil
}
func parseExtensionPostProcessValue(vm *goja.Runtime, value goja.Value) PostProcessResult {
obj := value.ToObject(vm)
return PostProcessResult{
Success: gojaObjectBool(obj, "success"),
NewFilePath: gojaObjectString(obj, "new_file_path", "newFilePath"),
NewFileURI: gojaObjectString(obj, "new_file_uri", "newFileUri"),
Error: gojaObjectString(obj, "error"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
}
}
func parseExtensionLyricsLineArray(vm *goja.Runtime, value goja.Value) ([]ExtLyricsLine, error) {
length, err := gojaArrayLength(value, vm)
if err != nil {
return nil, err
}
if length == 0 {
return []ExtLyricsLine{}, nil
}
arrayObj := value.ToObject(vm)
lines := make([]ExtLyricsLine, 0, length)
for i := 0; i < length; i++ {
lineValue := arrayObj.Get(strconv.Itoa(i))
if gojaValueIsEmpty(lineValue) {
continue
}
lineObj := lineValue.ToObject(vm)
lines = append(lines, ExtLyricsLine{
StartTimeMs: gojaObjectInt64(lineObj, "startTimeMs", "start_time_ms"),
Words: gojaObjectString(lineObj, "words"),
EndTimeMs: gojaObjectInt64(lineObj, "endTimeMs", "end_time_ms"),
})
}
return lines, nil
}
func parseExtensionLyricsValue(vm *goja.Runtime, value goja.Value) (ExtLyricsResult, error) {
obj := value.ToObject(vm)
lines := []ExtLyricsLine{}
if linesValue := gojaObjectValue(obj, "lines"); !gojaValueIsEmpty(linesValue) {
parsedLines, err := parseExtensionLyricsLineArray(vm, linesValue)
if err != nil {
return ExtLyricsResult{}, err
}
lines = parsedLines
}
return ExtLyricsResult{
Lines: lines,
SyncType: gojaObjectString(obj, "syncType", "sync_type"),
Instrumental: gojaObjectBool(obj, "instrumental"),
PlainLyrics: gojaObjectString(obj, "plainLyrics", "plain_lyrics"),
Provider: gojaObjectString(obj, "provider"),
}, nil
}
func parseExtensionSearchResult(vm *goja.Runtime, value goja.Value) (ExtSearchResult, error) {
if gojaValueIsEmpty(value) {
return ExtSearchResult{}, nil
}
resultObj := value.ToObject(vm)
tracksValue := resultObj.Get("tracks")
if gojaValueIsEmpty(tracksValue) {
tracks, err := parseExtensionTrackArray(vm, value)
if err != nil {
return ExtSearchResult{}, err
}
return ExtSearchResult{
Tracks: tracks,
Total: len(tracks),
}, nil
}
tracks, err := parseExtensionTrackArray(vm, tracksValue)
if err != nil {
return ExtSearchResult{}, err
}
total := gojaObjectInt(resultObj, "total")
if total == 0 {
total = len(tracks)
}
return ExtSearchResult{
Tracks: tracks,
Total: total,
}, nil
}
-465
View File
@@ -1,465 +0,0 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
extensionHealthDefaultCache = 10 * time.Minute
extensionHealthMinCache = 60 * time.Second
extensionHealthUnknownCache = 2 * time.Minute
)
type ExtensionHealthResult struct {
ExtensionID string `json:"extension_id"`
Status string `json:"status"`
CheckedAt string `json:"checked_at"`
Checks []ExtensionHealthCheckResult `json:"checks"`
}
type ExtensionHealthCheckResult struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
URL string `json:"url"`
Method string `json:"method"`
ServiceKey string `json:"service_key,omitempty"`
Required bool `json:"required"`
Status string `json:"status"`
HTTPStatus int `json:"http_status,omitempty"`
LatencyMs int64 `json:"latency_ms"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
CheckedAt string `json:"checked_at"`
}
type cachedExtensionHealthResult struct {
result ExtensionHealthResult
expiresAt time.Time
}
var (
extensionHealthCacheMu sync.Mutex
extensionHealthCache = map[string]cachedExtensionHealthResult{}
)
func clearExtensionHealthCache() {
extensionHealthCacheMu.Lock()
extensionHealthCache = map[string]cachedExtensionHealthResult{}
extensionHealthCacheMu.Unlock()
}
func CheckExtensionHealthJSON(extensionID string) (string, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
bytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(bytes), nil
}
func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return CheckExtensionHealth(ext)
}
cacheKey := strings.TrimSpace(ext.ID)
if cacheKey == "" {
return CheckExtensionHealth(ext)
}
now := time.Now()
extensionHealthCacheMu.Lock()
cached, ok := extensionHealthCache[cacheKey]
if ok && now.Before(cached.expiresAt) {
extensionHealthCacheMu.Unlock()
return cached.result
}
extensionHealthCacheMu.Unlock()
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
return result
}
func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return
}
cacheKey := strings.TrimSpace(ext.ID)
if cacheKey == "" {
return
}
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
ttl = extensionHealthUnknownCache
}
extensionHealthCacheMu.Lock()
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
result: result,
expiresAt: time.Now().Add(ttl),
}
extensionHealthCacheMu.Unlock()
}
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
now := time.Now().UTC().Format(time.RFC3339)
result := ExtensionHealthResult{
ExtensionID: "",
Status: "unsupported",
CheckedAt: now,
Checks: []ExtensionHealthCheckResult{},
}
if ext == nil || ext.Manifest == nil {
result.Status = "offline"
return result
}
result.ExtensionID = ext.ID
checks := ext.Manifest.ServiceHealth
if len(checks) == 0 {
return result
}
result.Status = "online"
for _, check := range checks {
checkResult := runExtensionHealthCheck(ext.Manifest, check)
result.Checks = append(result.Checks, checkResult)
switch checkResult.Status {
case "offline":
if check.Required {
result.Status = "offline"
} else if result.Status == "online" {
result.Status = "degraded"
}
case "degraded":
if result.Status == "online" {
result.Status = "degraded"
}
case "unknown":
if result.Status == "online" {
result.Status = "unknown"
}
}
}
return result
}
func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
ttl := extensionHealthDefaultCache
for _, check := range checks {
if check.CacheTTLSeconds <= 0 {
continue
}
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
if checkTTL < extensionHealthMinCache {
checkTTL = extensionHealthMinCache
}
if checkTTL < ttl {
ttl = checkTTL
}
}
return ttl
}
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
method := strings.ToUpper(strings.TrimSpace(check.Method))
if method == "" {
method = http.MethodGet
}
now := time.Now().UTC().Format(time.RFC3339)
result := ExtensionHealthCheckResult{
ID: check.ID,
Label: check.Label,
URL: check.URL,
Method: method,
ServiceKey: strings.TrimSpace(check.ServiceKey),
Required: check.Required,
Status: "unknown",
CheckedAt: now,
}
parsed, err := url.Parse(check.URL)
if err != nil {
result.Status = "offline"
result.Error = fmt.Sprintf("invalid health URL: %v", err)
return result
}
if parsed.Scheme != "https" {
result.Status = "offline"
result.Error = "health check must use https"
return result
}
host := parsed.Hostname()
if host == "" {
result.Status = "offline"
result.Error = "health check URL hostname is required"
return result
}
if isPrivateIP(host) {
result.Status = "offline"
result.Error = "private/local health check host is not allowed"
return result
}
if manifest == nil || !manifest.IsDomainAllowed(host) {
result.Status = "offline"
result.Error = fmt.Sprintf("health check host '%s' is not in extension network permissions", host)
return result
}
if method != http.MethodGet && method != http.MethodHead {
result.Status = "offline"
result.Error = "health check method must be GET or HEAD"
return result
}
timeout := extensionHealthDefaultTimeout
if check.TimeoutMs > 0 {
timeout = time.Duration(check.TimeoutMs) * time.Millisecond
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, check.URL, nil)
if err != nil {
result.Status = "offline"
result.Error = err.Error()
return result
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgentForURL(parsed))
start := time.Now()
resp, err := NewMetadataHTTPClient(timeout).Do(req)
result.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
if isTransientExtensionHealthError(err) {
result.Status = "unknown"
} else {
result.Status = "offline"
}
result.Error = err.Error()
return result
}
defer resp.Body.Close()
result.HTTPStatus = resp.StatusCode
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
result.Status = "offline"
result.Message = resp.Status
return result
}
if method == http.MethodHead {
result.Status = "online"
result.Message = resp.Status
return result
}
body, err := io.ReadAll(io.LimitReader(resp.Body, extensionHealthMaxBodyBytes))
if err != nil {
result.Status = "degraded"
result.Error = err.Error()
return result
}
status, message := classifyExtensionHealthBody(body, check.ServiceKey)
result.Status = status
if message == "" {
result.Message = resp.Status
} else {
result.Message = message
}
return result
}
func isTransientExtensionHealthError(err error) bool {
return isTransientNetworkError(err) || isConnectivityFailure(err)
}
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
if len(strings.TrimSpace(string(body))) == 0 {
return "online", ""
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return "online", ""
}
serviceKey = strings.TrimSpace(serviceKey)
if serviceKey != "" {
if status, message, ok := classifyExtensionHealthService(payload, serviceKey); ok {
return status, message
}
}
rawStatus, _ := payload["status"].(string)
normalized := strings.ToLower(strings.TrimSpace(rawStatus))
switch normalized {
case "", "ok", "up", "online", "healthy", "operational", "pass", "passing":
return "online", rawStatus
case "degraded", "partial", "warning", "warn":
return "degraded", rawStatus
case "down", "offline", "error", "failed", "fail", "unhealthy":
if isTransientHealthStatusMessage(string(body)) {
return "unknown", rawStatus
}
return "offline", rawStatus
default:
return "online", rawStatus
}
}
func classifyExtensionHealthService(payload map[string]any, serviceKey string) (string, string, bool) {
rawServices, ok := payload["services"]
if !ok {
return "", "", false
}
services, ok := rawServices.(map[string]any)
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]any)
if !ok {
return "unknown", fmt.Sprintf("service '%s' has invalid health payload", serviceKey), true
}
label, _ := service["label"].(string)
detail, _ := service["detail"].(string)
errText, _ := service["error"].(string)
messageParts := []string{}
if strings.TrimSpace(label) != "" {
messageParts = append(messageParts, strings.TrimSpace(label))
}
if strings.TrimSpace(detail) != "" {
messageParts = append(messageParts, strings.TrimSpace(detail))
}
if strings.TrimSpace(errText) != "" {
messageParts = append(messageParts, strings.TrimSpace(errText))
}
rawStatus, hasStatus := service["status"]
okValue, hasOK := service["ok"].(bool)
joinedMessage := strings.Join(messageParts, ": ")
transient := isTransientHealthStatusMessage(detail) ||
isTransientHealthStatusMessage(errText) ||
isTransientHealthStatusMessage(label)
if statusCode, ok := healthNumber(rawStatus); ok {
if statusCode >= 200 && statusCode < 300 {
return "online", joinedMessage, true
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return "degraded", joinedMessage, true
}
if statusCode == http.StatusInternalServerError && hasOK && okValue {
return "online", joinedMessage, true
}
if transient || isTransientHealthStatusCode(statusCode) {
return "unknown", joinedMessage, true
}
return "offline", joinedMessage, true
}
if isExtensionHealthAuthRequired(detail) {
return "degraded", joinedMessage, true
}
if transient {
return "unknown", joinedMessage, true
}
if hasOK {
if okValue {
return "online", joinedMessage, true
}
return "offline", joinedMessage, true
}
if !hasStatus {
return "unknown", joinedMessage, true
}
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
switch statusString {
case "ok", "up", "online", "healthy", "operational":
return "online", joinedMessage, true
case "degraded", "partial", "warning", "warn":
return "degraded", joinedMessage, true
case "down", "offline", "error", "failed", "fail", "unhealthy":
return "offline", joinedMessage, true
default:
return "unknown", joinedMessage, true
}
}
func isExtensionHealthAuthRequired(detail string) bool {
switch strings.ToLower(strings.TrimSpace(detail)) {
case "auth_required", "authorization_required", "login_required", "unauthorized":
return true
default:
return false
}
}
func isTransientHealthStatusMessage(text string) bool {
t := strings.ToLower(strings.TrimSpace(text))
if t == "" {
return false
}
return strings.Contains(t, "context deadline exceeded") ||
strings.Contains(t, "deadline exceeded") ||
strings.Contains(t, "timeout") ||
strings.Contains(t, "timed out") ||
strings.Contains(t, "temporarily unavailable") ||
strings.Contains(t, "try again")
}
func isTransientHealthStatusCode(code int) bool {
switch code {
case http.StatusRequestTimeout,
http.StatusTooManyRequests,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
func healthNumber(value any) (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
}
}

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