Compare commits

...

1027 Commits

Author SHA1 Message Date
zarzet 06f2b9ec97 ci(ios): strip CRLF from ffmpeg plugin scripts before pod install
The ffmpeg_kit_flutter_new_full pub package ships setup_ios.sh with CRLF line endings, so its podspec prepare_command failed with '/bin/bash^M: bad interpreter'. Normalize the plugin's shell scripts in the pub cache before building iOS.
2026-06-14 02:28:57 +07:00
zarzet 7fee4cea4f chore: bump version to 4.6.0 2026-06-14 02:08:52 +07:00
zarzet 526897b23b feat(playlist): blurred backdrop with full cover in playlist header
Replaces the cropped BoxFit.cover header with a blurred cover backdrop plus the full square cover centered, so covers with baked-in text are no longer awkwardly cropped. Title, track count and actions now sit in one centered column that adapts to header height.
2026-06-13 20:47:12 +07:00
zarzet c10c2a290c feat(ui): add bottom inset so scrollable content clears the transparent navbar 2026-06-13 20:31:39 +07:00
zarzet fb5204b0a6 fix(metadata): use high-res cover in track metadata header 2026-06-13 20:31:24 +07:00
zarzet 9db4048bc0 feat(library): show active downloads inside the library grid
Active downloads now render as the first tiles of the library list/grid instead of a separate top section, with a compact Downloading header that animates in/out. Completed items hand off seamlessly via a short-lived bridge tile (with cover precache) so the song never blinks out, and the order is reversed so the soonest-to-finish sits next to where it lands.
2026-06-13 20:31:13 +07:00
zarzet 63c68b4d4d fix(download): honor selected provider when it equals the track source
When the chosen download service matched the track's source extension it was skipped in both the source preflight and the fallback loop, so downloads silently fell back to another provider. It is now attempted in the loop, and an explicitly selected provider bypasses the fallback allow-list.
2026-06-13 20:31:02 +07:00
zarzet 953ef37882 fix(download): request fallback provider's own highest quality 2026-06-13 16:29:30 +07:00
zarzet da85a2dcc2 feat(ui): reduce bottom navbar height 2026-06-13 16:09:54 +07:00
zarzet 49869792cf chore: trim redundant comments 2026-06-13 15:37:00 +07:00
zarzet fb2dda1ed1 feat(ui): frosted translucent bottom navbar 2026-06-13 15:36:59 +07:00
zarzet fad4c4ea36 feat(lyrics): show actual lyrics source in metadata 2026-06-13 15:36:47 +07:00
zarzet 6b5345a6e5 fix(downloads/extensions): iOS background task, serialize extension mutations, safer batch convert sheet
- iOS: begin/end UIBackgroundTask while a download queue is active so in-flight downloads survive backgrounding for the limited window iOS allows

- extensions: serialize install/upgrade/remove in the Go manager (mutationMu) and in the Dart store provider to stop concurrent goja VM teardown/reload from hard-crashing the app

- main: add runZonedGuarded + FlutterError/PlatformDispatcher onError so uncaught Dart errors are logged, not fatal

- batch convert sheet: precompute localized title/label before showModalBottomSheet to avoid Localizations lookup via a deactivated context
2026-06-13 02:42:23 +07:00
zarzet ca413a16fa fix(ui): center modals on large screens, modernize edit-metadata + convert sheets, themed badges, fix artist skeleton and format-editor crash
- app: clear displayFeatures so bottom sheets/dialogs center on large/foldable screens

- edit metadata sheet: card sections, modern label-above inputs, elegant collapsible headers, removed title icon

- convert + batch convert: modern card-based sheets; shared BatchConvertSheet widget

- queue: keep selection toolbar hidden until modal close animation finishes

- 24-bit and In Library badges now use primary dynamic color

- artist skeleton: remove duplicate name/listeners lines, keep cover placeholder

- files settings: own filename-format controller in a StatefulWidget to fix use-after-dispose crash
2026-06-13 02:08:06 +07:00
zarzet b8b670642c feat(audio): add WAV and AIFF support + settings-style metadata menu
WAV/AIFF: library scan, quality probe, native tag read/write via embedded ID3 chunk (RIFF id3 / AIFF ID3), cover art, ReadFileMetadata, ExtractLyrics, and FLAC<->WAV/AIFF conversion (PCM, bit-depth preserved via ffprobe). Treat WAV/AIFF as lossless across all convert sheets (no bitrate picker, Lossless labels) via isLosslessConversionTarget. Native MIME maps for SAF. Redesign the track metadata three-dot menu to a settings-style grouped card with a single divider above Share.
2026-06-12 21:10:37 +07:00
zarzet 2a2e2924eb feat(lyrics,replaygain): add LyricsPlus provider and ReplayGain batch scanning
LyricsPlus (KPOE): word-by-word synced lyrics with multi-server failover, converted to enhanced LRC. ReplayGain: standalone EBU R128 (re)scan writing REPLAYGAIN_TRACK_* tags via native writers or FFmpeg, with batch action in queue/album screens and SAF support.
2026-06-12 01:59:26 +07:00
zarzet adea3de737 chore(deps): update Flutter and Go dependencies
Bump riverpod, go_router, sqflite, permission_handler, ffmpeg_kit, flutter_local_notifications, json_annotation and riverpod_generator/lint to stable; refresh go.mod/go.sum via go get -u.
2026-06-12 01:55:58 +07:00
zarzet 7d300a39c9 refactor: generalize Tidal-specific naming to legacy/DASH terminology
- Rename downloadProviderMatchesBuiltIn -> downloadProviderReplacesLegacyProvider

- Rename Tidal DASH ffmpeg helpers and lossy format pickers to generic names

- Add utils.decryptCTRSegments crypto API + raw/bytes file read path in extension runtime

- Update l10n strings/descriptions to drop hardcoded service names

- Bump version to 4.5.7+134
2026-06-11 01:08:20 +07:00
zarzet 688a5f2add fix(l10n): remove redundant ICU plural categories causing gen-l10n warnings 2026-06-07 05:30:51 +07:00
zarzet d736e5aafe refactor(download): remove concurrent download option
The download API only permits one request at a time, so parallel
downloads are removed to avoid wasted/blocked API calls. Downloads
now always run sequentially (one track at a time).

- Drop concurrentDownloads from AppSettings + JSON serialization
- Remove setConcurrentDownloads and the settings UI (1-5 chips + warning)
- Strip optionsConcurrent* l10n keys from all ARBs and regenerate
- Rework queue worker into _processQueueSequential (single active download)
- Update marketing copy and adjust tests
2026-06-06 21:58:45 +07:00
zarzet 3a536ad348 chore(about): credit Mickael81 as French translator 2026-06-04 22:46:56 +07:00
zarzet 5dedeb4971 fix(android): override predictive-back page transition
Flutter's default Android route transition (PredictiveBackPageTransitionsBuilder) mis-routes the predictive-back gesture to a nested Navigator instead of the topmost route (flutter#152323), popping the page behind a root modal instead of closing the modal first. This regressed after the Flutter upgrade in 4.5.6. Force FadeForwardsPageTransitionsBuilder on Android (the same non-gesture animation that builder delegates to) so back closes modals/sheets/dialogs first, then pops the page - restoring 4.5.5 behavior. Keep Cupertino transitions on iOS/macOS.
2026-06-04 22:46:45 +07:00
zarzet 7624e24ea6 fix(queue): simplify queue header and rate-limit indicator layout 2026-06-04 21:03:12 +07:00
zarzet 7b248d8ab4 feat(l10n): enable French and German locales 2026-06-04 21:03:02 +07:00
zarzet fdb2009856 Merge branch 'l10n_main': Crowdin translation updates (#412)
Resolve ARB conflicts via per-key union: apply latest Crowdin
translations for shared keys while preserving newer app keys added on
main after the branch point. Drop hyphenated ARB duplicates
(app_ar-SA, app_es-ES, app_pt-PT, app_tr-TR, app_uk-UA) that break
Flutter gen-l10n; keep underscore filenames. Add Arabic (app_ar.arb)
and regenerate app_localizations.
2026-06-04 20:50:42 +07:00
Zarz Eleutherius 8419a75b04 New translations app_en.arb (Arabic)
[ci skip]
2026-06-04 20:24:31 +07:00
zarzet 5d474d6fe8 fix(l10n): correct crowdin language mapping
Map placeholders to the project's actual Crowdin language ids and drop the bogus bare keys (es, pt, zh) that aren't real Crowdin codes and broke crowdin-cli config validation. Add Arabic (ar) mapped to app_ar.arb so future syncs use underscore filenames instead of hyphenated ones (e.g. app_ar-SA.arb) that break Flutter gen-l10n.
2026-06-04 20:20:05 +07:00
Zarz Eleutherius e597505a1c New translations app_en.arb (French)
[ci skip]
2026-06-02 03:38:17 +07:00
github-actions[bot] 8675d263e7 chore: update AltStore source to v4.5.6 2026-06-01 18:23:57 +00:00
zarzet 1ce66b9e03 fix: align ios deployment target for file picker 2026-06-02 01:09:53 +07:00
Zarz Eleutherius cfda124995 New translations app_en.arb (Hindi)
[ci skip]
2026-06-02 01:09:09 +07:00
Zarz Eleutherius 212f1cacca New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-06-02 01:09:07 +07:00
Zarz Eleutherius dd89de7cad New translations app_en.arb (Ukrainian)
[ci skip]
2026-06-02 01:09:05 +07:00
Zarz Eleutherius 8b4372dc7f New translations app_en.arb (Turkish)
[ci skip]
2026-06-02 01:09:03 +07:00
Zarz Eleutherius 2a25557632 New translations app_en.arb (Russian)
[ci skip]
2026-06-02 01:09:01 +07:00
Zarz Eleutherius 0cbb339948 New translations app_en.arb (Portuguese)
[ci skip]
2026-06-02 01:08:59 +07:00
Zarz Eleutherius 1496f51e30 New translations app_en.arb (Dutch)
[ci skip]
2026-06-02 01:08:58 +07:00
Zarz Eleutherius d1c5fe0605 New translations app_en.arb (Korean)
[ci skip]
2026-06-02 01:08:56 +07:00
Zarz Eleutherius 56786f60ff New translations app_en.arb (Japanese)
[ci skip]
2026-06-02 01:08:54 +07:00
Zarz Eleutherius af5d36f69f New translations app_en.arb (German)
[ci skip]
2026-06-02 01:08:52 +07:00
Zarz Eleutherius e40da71ef8 New translations app_en.arb (Arabic)
[ci skip]
2026-06-02 01:08:50 +07:00
Zarz Eleutherius 26b8bf422c New translations app_en.arb (Indonesian)
[ci skip]
2026-06-02 01:08:49 +07:00
Zarz Eleutherius 0a545706bd New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-02 01:08:47 +07:00
Zarz Eleutherius 9ebac610c7 New translations app_en.arb (Spanish)
[ci skip]
2026-06-02 01:08:45 +07:00
Zarz Eleutherius 5fc8a6af2a New translations app_en.arb (French)
[ci skip]
2026-06-02 01:08:43 +07:00
zarzet 8e68af79aa fix: prevent queue header action clipping 2026-06-02 00:58:43 +07:00
zarzet 6246e6e821 chore: update flutter and native dependencies 2026-06-02 00:58:42 +07:00
zarzet 421d5ffdc8 feat: polish search empty state and share caching 2026-06-02 00:58:42 +07:00
zarzet b82dabe316 fix: align cross-service sharing and fallback routing 2026-06-02 00:58:42 +07:00
zarzet ffdaf14ba5 feat: rebuild cross-extension sharing and queue controls
Co-authored-by: Amonoman <musaauron87@gmail.com>
2026-06-02 00:58:41 +07:00
zarzet f52527a41b chore: bump version to 4.5.6 (build 133) 2026-06-02 00:58:41 +07:00
zarzet 56a89c5fc6 fix: harden download errors and re-enrich sidecars 2026-06-02 00:58:40 +07:00
zarzet 4f5163be01 fix: resolve album-only autofill and placeholder re-enrich regressions
- Dart: _metadataMatchIsConfident now handles album-only case (title empty)
  by adding albumMatches fallback branch
- Go: selectBestReEnrichTrack treats placeholder values (Unknown Title,
  Unknown Artist) as empty via isPlaceholderReEnrichValue, so album-based
  fallback filtering works correctly
- Add test for placeholder album fallback in selectBestReEnrichTrack
2026-06-02 00:58:40 +07:00
zarzet 822c094c8c fix: stricter metadata matching, respect embedLyrics setting, improve Apple Music lyrics
- Re-enrich: reject candidates that don't match title/artist/album unless exact ISRC match
- Respect settings.embedLyrics instead of hardcoding true in re-enrich flows
- Skip lyrics resolution in NativeDownloadFinalizer when not needed
- Apple Music lyrics: use direct catalog API with token scraping instead of Paxsenix search
- Support ELRC/ELRCMultiPerson/Plain formats in Apple Music lyrics response
- Add confidence check in metadata auto-fill to prevent applying wrong metadata
- Add tests for stricter re-enrich matching logic
2026-06-02 00:58:40 +07:00
Zarz Eleutherius 1623f443bb New translations app_en.arb (Spanish)
[ci skip]
2026-05-31 09:12:29 +07:00
Zarz Eleutherius aa47bc4499 New translations app_en.arb (French)
[ci skip]
2026-05-28 18:50:01 +07:00
Zarz Eleutherius f461322842 New translations app_en.arb (French)
[ci skip]
2026-05-28 17:08:19 +07:00
Zarz Eleutherius cce05a0077 New translations app_en.arb (French)
[ci skip]
2026-05-28 16:08:05 +07:00
Zarz Eleutherius 98dc868f47 New translations app_en.arb (French)
[ci skip]
2026-05-28 14:30:31 +07:00
Zarz Eleutherius 821a41c10e New translations app_en.arb (French)
[ci skip]
2026-05-28 03:12:44 +07:00
Zarz Eleutherius 853ccd657a New translations app_en.arb (French)
[ci skip]
2026-05-28 01:57:58 +07:00
Zarz Eleutherius 680fc81db2 New translations app_en.arb (French)
[ci skip]
2026-05-27 23:40:18 +07:00
Zarz Eleutherius 36470eda24 New translations app_en.arb (French)
[ci skip]
2026-05-27 21:58:35 +07:00
Zarz Eleutherius a37dd6c8cb New translations app_en.arb (French)
[ci skip]
2026-05-27 04:52:32 +07:00
Zarz Eleutherius 588f742871 New translations app_en.arb (French)
[ci skip]
2026-05-27 03:45:18 +07:00
Zarz Eleutherius ff25a10e5b New translations app_en.arb (French)
[ci skip]
2026-05-27 02:33:22 +07:00
Zarz Eleutherius 499457f66a New translations app_en.arb (French)
[ci skip]
2026-05-26 23:49:07 +07:00
Zarz Eleutherius 6d15050009 New translations app_en.arb (French)
[ci skip]
2026-05-26 22:09:18 +07:00
Zarz Eleutherius 5ba30031c3 New translations app_en.arb (French)
[ci skip]
2026-05-26 05:23:50 +07:00
Zarz Eleutherius 82c0eef504 New translations app_en.arb (French)
[ci skip]
2026-05-26 04:27:43 +07:00
Zarz Eleutherius 616267e997 New translations app_en.arb (Arabic)
[ci skip]
2026-05-24 15:17:12 +07:00
Zarz Eleutherius 161b0c8c21 New translations app_en.arb (Arabic)
[ci skip]
2026-05-23 16:58:47 +07:00
Zarz Eleutherius facd185d6c New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-23 00:53:46 +07:00
Zarz Eleutherius 42858bf336 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-22 23:40:00 +07:00
Zarz Eleutherius 716be88caf New translations app_en.arb (Arabic)
[ci skip]
2026-05-22 16:14:08 +07:00
Zarz Eleutherius b296726a9d New translations app_en.arb (Spanish)
[ci skip]
2026-05-21 04:09:42 +07:00
Zarz Eleutherius 092f18d7a5 New translations app_en.arb (Spanish)
[ci skip]
2026-05-21 02:23:55 +07:00
Zarz Eleutherius f1ef33e319 New translations app_en.arb (Spanish)
[ci skip]
2026-05-21 01:12:56 +07:00
Zarz Eleutherius fc9bc95418 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-19 01:44:44 +07:00
Zarz Eleutherius c61e64f332 New translations app_en.arb (Spanish)
[ci skip]
2026-05-18 00:54:25 +07:00
Zarz Eleutherius 70ebb8ef1a New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 23:29:19 +07:00
Zarz Eleutherius a4c6a92478 New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 22:18:42 +07:00
Zarz Eleutherius 76b453e535 New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 20:59:16 +07:00
Zarz Eleutherius 19acdd87f5 New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 19:59:25 +07:00
Zarz Eleutherius 492e1335ef New translations app_en.arb (German)
[ci skip]
2026-05-16 20:27:00 +07:00
Zarz Eleutherius 23cde7add3 New translations app_en.arb (Spanish)
[ci skip]
2026-05-16 20:26:59 +07:00
Zarz Eleutherius a20c28db25 New translations app_en.arb (German)
[ci skip]
2026-05-16 19:31:57 +07:00
Zarz Eleutherius f07d46c49e New translations app_en.arb (German)
[ci skip]
2026-05-16 18:21:28 +07:00
Zarz Eleutherius e9781a24a6 New translations app_en.arb (Turkish)
[ci skip]
2026-05-15 20:32:29 +07:00
Zarz Eleutherius 15be15ba58 New translations app_en.arb (Turkish)
[ci skip]
2026-05-15 18:57:35 +07:00
github-actions[bot] 0952b76e11 chore: update AltStore source to v4.5.5 2026-05-14 23:25:38 +00:00
Zarz Eleutherius 8011d41e53 New translations app_en.arb (Arabic)
[ci skip]
2026-05-15 06:18:25 +07:00
Zarz Eleutherius 5412f23d26 New translations app_en.arb (Hindi)
[ci skip]
2026-05-15 06:18:23 +07:00
Zarz Eleutherius 0c39ff47f2 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-15 06:18:21 +07:00
Zarz Eleutherius 537af905f6 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-15 06:18:20 +07:00
Zarz Eleutherius 6b4f70bde3 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-15 06:18:18 +07:00
Zarz Eleutherius be2b6d2c1f New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-15 06:18:17 +07:00
Zarz Eleutherius 0c1a6d8f19 New translations app_en.arb (Turkish)
[ci skip]
2026-05-15 06:18:15 +07:00
Zarz Eleutherius 2821997260 New translations app_en.arb (Russian)
[ci skip]
2026-05-15 06:18:13 +07:00
Zarz Eleutherius 0546a33b10 New translations app_en.arb (Portuguese)
[ci skip]
2026-05-15 06:18:11 +07:00
Zarz Eleutherius deb98d8dfb New translations app_en.arb (Dutch)
[ci skip]
2026-05-15 06:18:09 +07:00
Zarz Eleutherius 72c658eda7 New translations app_en.arb (Korean)
[ci skip]
2026-05-15 06:18:07 +07:00
Zarz Eleutherius df17f10c8a New translations app_en.arb (Japanese)
[ci skip]
2026-05-15 06:18:05 +07:00
Zarz Eleutherius 9cacf2dc8e New translations app_en.arb (German)
[ci skip]
2026-05-15 06:18:04 +07:00
Zarz Eleutherius c7bc9f5b1c New translations app_en.arb (Spanish)
[ci skip]
2026-05-15 06:18:02 +07:00
Zarz Eleutherius 49ba8ae0d2 New translations app_en.arb (French)
[ci skip]
2026-05-15 06:18:00 +07:00
zarzet 7291dbd9e2 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps.json
2026-05-15 06:11:51 +07:00
zarzet fb4cd75cb2 feat: expose audio codec in download result and skip lossy-to-lossless conversion
Go backend:
- Add AudioCodec field to DownloadResult and DownloadResponse
- Extension download results can now include audio_codec/audioCodec
- ffmpegGetInfo and probeAudioQuality now return codec field
- Add trackItemBytes option to file.download() for custom progress handling

Flutter:
- Check audio_codec before container conversion
- Skip FLAC conversion if source codec is lossy (AAC, MP3, Opus, etc.)
- Prevents fake upscale from lossy to lossless containers
2026-05-15 04:37:25 +07:00
zarzet 8b7cecc1c5 refactor: extract download progress label formatting
- Extract _formatDownloadProgressLabel() for cleaner code
- Show received/total size when bytesTotal is available
- Estimate total size from progress when only bytesReceived is known
- Add text overflow handling with ellipsis
2026-05-15 01:29:02 +07:00
Zarz Eleutherius 3a62442ed0 New translations app_en.arb (Spanish)
[ci skip]
2026-05-15 01:05:23 +07:00
zarzet 012dcdc2dd fix: native FLAC handling and extension API optimizations
Native FLAC handling:
- Properly detect and publish native FLAC payloads inside MP4 containers
- Rename to .flac extension and embed metadata instead of skipping
- Fix all code paths: SAF, non-SAF, and native worker finalizer

Extension API optimizations:
- Enable response compression for API/search calls (faster metadata loads)
- Keep downloads uncompressed for accurate progress/streaming
- Add separate extensionAPITransport with compression enabled

Platform bridge caching:
- Cache handleURLWithExtension results (5 min TTL)
- Cache customSearchWithExtension results (2 min TTL)
- Prevent duplicate in-flight requests for same URL/query

Dependency cleanup:
- Remove unused sqflite_common_ffi and sqlite3 packages
2026-05-15 00:54:58 +07:00
Zarz Eleutherius 3a1b92f9c4 New translations app_en.arb (Spanish)
[ci skip]
2026-05-14 23:24:51 +07:00
zarzet 629eb66595 chore: bump version to 4.5.5 (build 132) 2026-05-14 20:48:29 +07:00
zarzet 36749a40d3 Revert "feat: add library scroll-to-top and scroll-to-bottom quick buttons"
This reverts commit f84a33bbf2.
2026-05-14 20:47:24 +07:00
zarzet 4336e6dc78 feat: add 5 new lyrics providers
New lyrics providers using Paxsenix API:
- Spotify: Synced lyrics from Spotify
- Deezer: Synced lyrics from Deezer
- YouTube: Lyrics from YouTube
- Kugou: Lyrics from Kugou (Chinese service)
- Genius: Plain text lyrics from Genius

Implementation:
- Add lyrics client implementations for all providers
- Smart search result scoring based on track name, artist, and duration
- Support for both synced (LRC) and unsynced lyrics formats
- Fallback search with simplified track names and primary artist

UI updates:
- Add provider entries to lyrics priority settings page
- Add display names for new providers in settings
2026-05-14 20:42:14 +07:00
zarzet 3e3e87e73e fix: MP3 lyrics embedding via ID3v2.3 USLT frame
FFmpeg doesn't always embed lyrics correctly to MP3 files. This adds
manual ID3v2.3 USLT (Unsynchronized Lyrics) frame writing after FFmpeg
metadata embedding to ensure lyrics are properly stored.

Implementation:
- Extract lyrics from metadata (UNSYNCEDLYRICS or LYRICS key)
- Build ID3v2.3 compliant USLT frame with UTF-16LE encoding
- Insert or replace USLT frame in existing ID3v2.3 tag
- Create new ID3v2.3 tag if file has no ID3 header
- Skip gracefully for unsupported ID3 versions or flags

Also includes minor audio analysis improvements:
- Consistent dynamic range calculation (peak - rms)
- Filter out 'unknown' and 'n/a' labels
- Add -vn -sn -dn flags for more robust stream selection
2026-05-14 18:25:03 +07:00
zarzet 1b8d6ce7fa feat: enhanced audio analysis with loudness, clipping, and spectral cutoff
Audio Analysis Enhancements:
- Display codec name and container format
- Show decoded sample format (s16, s32, fltp, etc.)
- Add LUFS integrated loudness measurement (broadcast standard)
- Add true peak measurement (dBTP)
- Detect and count clipping samples per channel
- Estimate spectral cutoff frequency (helps detect fake upscales)
- Show per-channel statistics (Peak, RMS, DR, Clip count)

UI Improvements:
- MetricChip now handles long text with ellipsis
- Constrained max width for better layout

Cache version bumped to 4 to force rescan with new metrics.
2026-05-14 16:28:49 +07:00
zarzet 60f1df1488 refactor: use audio_conversion_utils in downloaded_album_screen
- Replace inline format detection with convertibleAudioSourceFormat()
- Replace inline conversion rules with canConvertAudioFormat()
- Add unit tests for Dolby format detection and conversion rules
2026-05-14 15:49:27 +07:00
zarzet ff86869c33 feat: audio analysis rescan and AAC conversion support
Audio Analysis:
- Add rescan capability by bumping cache version
- Display channel layout (stereo, 5.1, etc.) and bitrate
- Use astats filter for more accurate peak/RMS measurements
- Support more formats: mp4, ac3, eac3, mka, wv, ape, tta, aif
- Only report bit depth for codecs that store it (FLAC, ALAC, WAV)
- Validate cache for SAF content:// URIs

Conversion:
- Add AAC as conversion target format
- Recognize ALAC as lossless source
- Prevent accidental deletion when source and target URI match
- Store format and bitrate in database after conversion

Utilities:
- Add audio_conversion_utils.dart for centralized conversion logic
- Add isSameContentUri() helper for safe URI comparison
2026-05-14 15:46:55 +07:00
Zarz Eleutherius 30f97394ec New translations app_en.arb (French)
[ci skip]
2026-05-12 04:22:24 +07:00
Zarz Eleutherius 592308c1c6 New translations app_en.arb (French)
[ci skip]
2026-05-12 03:19:49 +07:00
zarzet 2a2d817314 feat: add AAC lossy target and toggle for Apple Music eLRC word sync
The HIGH-quality lossy format picker can now produce an AAC/M4A 320 kbps output alongside MP3 and Opus. FFmpegService.convertM4aToLossy/convertAudioFormat, the Dart queue pipeline, the Kotlin finalizer, and the library database format helper all route .m4a through a unified aac codec path and tag the resulting file with the M4A metadata writer. The Lossy Format setting gains a new option, and the track metadata convert dialog lists AAC next to the other targets.

Apple Music lyrics gain a 'eLRC word sync' switch (default off). When disabled the pax-to-LRC formatter strips inline word timestamps, producing line-synced LRC that is safer for players that choke on eLRC; enabling it restores the previous word-by-word behaviour. The change propagates through SetLyricsFetchOptions and invalidates the global lyrics cache on toggle.

Broad l10n migration: roughly 400 previously hardcoded English strings across queue, settings, track metadata, repo, audio analysis, setup and extension screens now live in the ARB catalog, with matching plural/placeholder forms. No behaviour change beyond localisation. Existing and new unit tests (lyrics eLRC toggle and Dart settings round-trip) pass.
2026-05-12 02:23:04 +07:00
Zarz Eleutherius 8bcfc63da0 New translations app_en.arb (French)
[ci skip]
2026-05-12 00:43:24 +07:00
Zarz Eleutherius a9cfff2692 New translations app_en.arb (French)
[ci skip]
2026-05-11 22:40:12 +07:00
Zarz Eleutherius 9e7ff56113 New translations app_en.arb (French)
[ci skip]
2026-05-11 18:52:31 +07:00
Zarz Eleutherius 9071143bbd New translations app_en.arb (French)
[ci skip]
2026-05-11 16:57:43 +07:00
zarzet 7845ac8be5 feat: show remote-config launch announcement on app start
Introduce AppRemoteConfigService which fetches a platform/version/locale-aware JSON payload from api.zarz.moe/v1/spotiflac-mobile/config and caches it in SharedPreferences. main_shell shows a one-shot announcement dialog (respecting dismissible, CTA, time window and version gates) when no update prompt is pending; dismissed IDs are persisted so each announcement surfaces only once.

Tweaks bundled in: the service health dot loses its blur halo in favour of solid Material 3 tones, and AppInfo gains the remote config endpoint constant. The share listener and SAF migration hook stay synchronous inside the post-frame callback so share-intent URLs never race the network-bound checks.

New unit tests cover the announcement CTA/active-window rules.
2026-05-11 01:37:10 +07:00
Zarz Eleutherius 40770aff15 New translations app_en.arb (Turkish)
[ci skip]
2026-05-11 01:05:00 +07:00
zarzet 81547013f9 fix: gate M4A to FLAC conversion on a codec probe in every branch
The SAF and local post-download branches used to rush an ffmpeg 'M4A to FLAC' remux whenever the output extension was .flac, which silently upscaled AAC or EAC3 streams into a lossless container. Each branch now mirrors the native worker by probing the primary audio codec before converting: lossless sources (and true FLAC-in-MP4 files) stay in their native container with the right extension, while genuine ALAC/WAV payloads still get remuxed.

Add an outputExt field to DownloadRequestPayload so the Go backend always knows the user-requested container, and use it together with _shouldRequestContainerConversion to pick the right behaviour for shouldPreserveNativeM4a and the Kotlin finalizer. Decryption descriptors no longer force M4A preservation on their own; the codec probe already makes that call correctly.
2026-05-11 00:52:02 +07:00
zarzet 8e605cbd0f feat: persist codec format and bitrate in download history
Bump the history schema on both the Kotlin finalizer and the Dart database to v9, adding bitrate (kbps) and format (codec label) columns, and let the download flow fill them from backend/probe metadata so lossy downloads keep a 'AAC 256kbps' label instead of falling back to the stored placeholder. Library filtering and the track metadata screen now read format/bitrate directly from those columns, which also fixes mis-tagged quality badges after re-downloading a track at a different format.

Additional fixes bundled in: EditFileMetadata now routes ReplayGain writes through the M4A path whenever the file starts with ftyp (fixing .flac files that actually hold MP4 containers); GetM4AQuality falls back to the first trak/mdia/mdhd duration when mvhd is zero so EAC3 streams no longer report 0s; and both Kotlin and Dart reject bitrate values below 16 kbps to prevent probe noise from surfacing as '0 kbps' labels. New unit tests cover the EAC3 mdhd fallback and the mis-named M4A replaygain path.
2026-05-10 23:18:32 +07:00
zarzet d664d46ca4 feat: detect FLAC/ALAC/EAC3/AC3/AC4 codecs inside MP4 containers
GetM4AQuality now recognizes fLaC, alac, ec-3, ac-3, and ac-4 sample entries and parses the MP4 FLACSpecificBox so library entries carry the real codec rather than the container extension. The AudioQuality struct exposes Codec and Bitrate fields (with an estimator for compressed streams), and ReadFileMetadata publishes format + audio_codec so Flutter and Kotlin can make format decisions based on the actual stream.

Downstream: library_scan labels M4A-family items as flac/alac/eac3/ac3/ac4/m4a, zeroes the bitrate for lossless formats, and the filter UI + quality badges use the codec-derived format instead of only the file extension. Scans and SAF importers also accept .mp4 and .aac file extensions. New unit tests cover codec name mapping and MP4 FLACSpecificBox decoding.
2026-05-10 22:14:47 +07:00
zarzet b4031936a0 feat: allow re-running audio quality analysis after cached result
The audio analysis card used to read from a persistent cache but offered no way to refresh the result when the underlying file had been re-downloaded at a different quality (for example, re-downloading a track as FLAC after capturing it as AAC). Add an explicit rescan control that clears the cached JSON + spectrogram, reruns the FFmpeg probe and analysis pipeline, and swaps in the fresh data while keeping the loading copy distinct from first-run analysis. A retry button is also exposed in the error card so transient failures do not require navigating away.

All audio_analysis strings now have a Re-analyze / Re-analyzing pair in the ARB catalog so every locale can translate them independently.
2026-05-10 21:27:54 +07:00
zarzet f84a33bbf2 feat: add library scroll-to-top and scroll-to-bottom quick buttons
Add a pair of floating quick-scroll buttons on the library tab so long lists become easier to navigate. The buttons sit above the bottom navigation (or the selection toolbar in selection mode), fade in and out based on the active page's scroll metrics, and share their scroll-target keys per filter mode so switching filters does not carry over the previous page's scroll state.
2026-05-10 19:09:38 +07:00
zarzet 8f5c59683a fix: force native FLAC muxer when decrypting to .flac output
Downloads from providers that stream FLAC inside an fMP4 container (e.g. Amazon Music) were being written to disk with a .flac extension while the payload still carried ISO-BMFF atoms. The container-conversion guard then saw codec=flac and skipped the remux, leaving native FLAC tag writers to fail with 'fLaC head incorrect'.

Force '-f flac' on the decryption command whenever the target extension is .flac so FFmpeg emits a real FLAC stream, and add an 'fLaC' magic-byte probe on both the Dart and Kotlin container-conversion guards so a FLAC-in-MP4 source is remuxed rather than silently passed through as a tag-writer hazard.
2026-05-10 18:50:49 +07:00
zarzet 4b7146afe4 fix: report zero bit depth for non-ALAC M4A containers
GetM4AQuality previously defaulted to 16-bit whenever the audio sample entry was not ALAC, which silently labeled lossy AAC downloads as CD quality in the library and in extension APIs. Only fill BitDepth when the atom is ALAC (including the ALACSpecificConfig refinement), and leave it as zero for AAC/mp4a, matching how the MP3 and Opus probes already report lossy sources. Tests cover both the ALAC and AAC branches.
2026-05-10 18:31:19 +07:00
Zarz Eleutherius 2bc5ef34ee New translations app_en.arb (Spanish)
[ci skip]
2026-05-10 06:34:38 +07:00
zarzet 939407675b fix: probe codec to avoid fake FLAC upscale from lossy sources
The native-worker container conversion used to remux any .m4a download to .flac whenever the user requested a FLAC output, which silently upgraded lossy AAC streams to a FLAC container without adding any information. Guard the remux with an FFmpeg/FFprobe codec probe on both the Dart and Kotlin finalization paths so only genuinely lossless sources (ALAC, WavPack, PCM, etc.) are converted, and expose a requires_container_conversion capability so extensions can force conversion when they know the source is lossless.
2026-05-09 20:51:40 +07:00
Zarz Eleutherius 6b9a3d95cd New translations app_en.arb (Spanish)
[ci skip]
2026-05-09 13:06:15 +07:00
zarzet 20ac6b2cd4 fix(native-worker): preserve requested output container in finalizer
When the native worker result advertises a requested non-FLAC output extension (for example '.m4a'), skip the m4a-to-flac container conversion in both the Dart and Kotlin finalizers so the native output container is preserved end-to-end.

- ffmpeg_service: propagate the top-level 'output_extension' hint into the download-result descriptor for both the map-backed and legacy paths; expose a normalized getter for consistent comparisons.

- download_queue_provider: short-circuit the native-worker container-conversion step when the descriptor's requested extension is not '.flac', with a debug log describing the skip.

- NativeDownloadFinalizer: mirror the guard on the Kotlin side so the finalizer does not force a container conversion that would clobber the requested native output.
2026-05-09 01:23:38 +07:00
zarzet 904b45e8f6 chore: housekeeping cleanup and code deduplication
- Remove stray tracked files (root AndroidManifest.xml, build.gradle.bak, temp_project template)
- Move README-only images out of app asset bundle to reduce APK/IPA size (~1.68MB)
- Fix logo filename typo (transparant -> transparent)
- Deduplicate _readPositiveInt into shared int_utils.dart
- Deduplicate _themeModeFromString (reuse from theme_settings.dart)
- Remove deprecated LocalLibraryState.items getter
- Remove unused sqflite_common_ffi dependency
- Update apps.json version to 4.5.1
- Fix Flutter version in CONTRIBUTING.md (3.38.1 -> 3.41.5)
- Improve .gitignore patterns (NUL, *.bak, root AndroidManifest.xml)
2026-05-08 21:37:56 +07:00
zarzet 1bd54c530b fix(saf): use extension-agnostic .partial staged filename
Staged SAF outputs and library-scan partials now share a single naming pattern: '<name>.partial' regardless of the audio extension. The previous '<name>.partial.<ext>' form caused SAF / media-scanner to surface half-written files as valid audio.

- SafDownloadHandler: force 'application/octet-stream' MIME for staged docs and collapse buildStagedSafFileName to '<name>.partial'. Keep the legacy form behind buildLegacyStagedSafFileName and sweep both via deleteStaleStagedFiles so upgrades clean old residue.

- library_scan: add isLibraryStagingFile that skips both the new and legacy partial patterns during collectLibraryAudioFiles so residual staging files never show up in the library.

- library_scan_supplement_test: seed both legacy and new partial files and assert they are ignored by the scanner.
2026-05-08 20:35:41 +07:00
Zarz Eleutherius 4fe51cef96 New translations app_en.arb (Spanish)
[ci skip]
2026-05-08 13:37:22 +07:00
github-actions[bot] d005e2e2e7 chore: update AltStore source to v4.5.1 2026-05-07 18:22:36 +00:00
zarzet fb5d8826a2 fix: avoid native worker binder payload limit 2026-05-08 01:06:48 +07:00
zarzet 4bc28704ff docs: update credits and trendshift badge 2026-05-08 00:40:26 +07:00
zarzet ed7171133f fix: show missing extension state for returning users 2026-05-08 00:40:26 +07:00
zarzet 67885e17ed fix: preserve selected metadata and update credits 2026-05-08 00:40:26 +07:00
zarzet fd4da1b7c4 fix: declare dataSync type when starting foreground download service
Use the 3-arg startForeground overload with FOREGROUND_SERVICE_TYPE_DATA_SYNC on API 29+ so the runtime FGS type matches the manifest declaration. Silences the ForegroundServiceTypeLoggerModule warning on targetSdk 36.
2026-05-08 00:40:25 +07:00
zarzet 242a57b7eb fix: restore default quality settings 2026-05-08 00:40:25 +07:00
zarzet 18467c54d6 fix: stabilize library search and bump version 2026-05-08 00:40:25 +07:00
zarzet 8238e2fe68 fix: prevent settings editor white screens 2026-05-08 00:40:25 +07:00
Zarz Eleutherius 672ce024f8 New translations app_en.arb (French)
[ci skip]
2026-05-07 04:04:43 +07:00
Zarz Eleutherius 8224e93447 New translations app_en.arb (Russian)
[ci skip]
2026-05-07 02:40:48 +07:00
Zarz Eleutherius 1ba810fffb New translations app_en.arb (German)
[ci skip]
2026-05-07 02:40:46 +07:00
Zarz Eleutherius 1a725d0d31 New translations app_en.arb (French)
[ci skip]
2026-05-07 02:40:44 +07:00
Zarz Eleutherius 51c5b42a78 New translations app_en.arb (Arabic)
[ci skip]
2026-05-07 01:24:32 +07:00
Zarz Eleutherius 2908827018 New translations app_en.arb (German)
[ci skip]
2026-05-07 01:24:30 +07:00
Zarz Eleutherius b985cbf694 New translations app_en.arb (German)
[ci skip]
2026-05-06 23:33:52 +07:00
github-actions[bot] 13c2360b7e chore: update AltStore source to v4.5.0 2026-05-06 15:40:37 +00:00
zarzet f1138ec7af fix: guard security scoped bookmark options on iOS 2026-05-06 22:25:55 +07:00
Zarz Eleutherius 1293d92896 New translations app_en.arb (Hindi)
[ci skip]
2026-05-06 22:15:45 +07:00
Zarz Eleutherius 705d41931d New translations app_en.arb (Indonesian)
[ci skip]
2026-05-06 22:15:43 +07:00
Zarz Eleutherius 29de69d323 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-06 22:15:41 +07:00
Zarz Eleutherius 28727d89f6 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-06 22:15:39 +07:00
Zarz Eleutherius 4704bcf52f New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-06 22:15:37 +07:00
Zarz Eleutherius 13c148fb6c New translations app_en.arb (Turkish)
[ci skip]
2026-05-06 22:15:35 +07:00
Zarz Eleutherius e6079452f9 New translations app_en.arb (Russian)
[ci skip]
2026-05-06 22:15:33 +07:00
Zarz Eleutherius b68b7d5c9b New translations app_en.arb (Portuguese)
[ci skip]
2026-05-06 22:15:31 +07:00
Zarz Eleutherius 741fcdb4d9 New translations app_en.arb (Dutch)
[ci skip]
2026-05-06 22:15:30 +07:00
Zarz Eleutherius 642f8c5398 New translations app_en.arb (Korean)
[ci skip]
2026-05-06 22:15:28 +07:00
Zarz Eleutherius 1c15d5e7d3 New translations app_en.arb (Japanese)
[ci skip]
2026-05-06 22:15:26 +07:00
Zarz Eleutherius e71090338c New translations app_en.arb (German)
[ci skip]
2026-05-06 22:15:24 +07:00
Zarz Eleutherius 7c0feaaae0 New translations app_en.arb (Spanish)
[ci skip]
2026-05-06 22:15:22 +07:00
Zarz Eleutherius 5aa3ff4bb5 New translations app_en.arb (French)
[ci skip]
2026-05-06 22:15:20 +07:00
zarzet 0e00660e2e fix: preserve source cover metadata embeds 2026-05-06 21:56:59 +07:00
zarzet aad72226c5 fix: show lossy audio bitrate in quality labels 2026-05-06 21:10:04 +07:00
Zarz Eleutherius d4c83db428 New translations app_en.arb (Russian)
[ci skip]
2026-05-06 17:21:54 +07:00
Zarz Eleutherius 9f2d51fd4d New translations app_en.arb (Russian)
[ci skip]
2026-05-06 14:56:46 +07:00
zarzet 83d7106e35 fix: distinguish preparing from downloading in native worker progress
Prevent premature 'downloading' status before actual byte transfer
starts, and cache async provider values to avoid UI flicker during
queue library reloads.

Progress pipeline:
- StartItemProgress now initializes with 'preparing' status instead
  of 'downloading'
- SetItemProgress ignores synthetic pre-download progress updates
  while status is still 'preparing' (no byte data yet)
- DownloadService reads backend status field and propagates preparing/
  downloading/finalizing to native worker item snapshot
- Dart progress stream maps 'preparing' to DownloadStatus.downloading
  with progress 0.0 (indeterminate spinner)

Queue tab:
- Add _queueLibraryCountsCache and _queueLibraryPageDataCache to
  retain last successful data during FutureProvider refetches
- Prevents empty-state flash when loadedIndexVersion bumps trigger
  provider invalidation
- Caches trimmed to max 24 entries via FIFO eviction
2026-05-06 12:08:53 +07:00
zarzet 30a7cba02a fix: sync NativeDownloadFinalizer history schema to v8
Align the Kotlin-side history database contract with the Dart-side
schema v8 changes from the previous commit.

- Bump HISTORY_SCHEMA_VERSION from 5 to 8
- Add spotify_id_norm, isrc_norm, match_key normalized columns to
  CREATE TABLE and ensureHistoryColumn calls
- Create history_path_keys table with item_id/path_key composite key
- Backfill normalized columns and path keys on first v8 open
- Populate normalized columns in putNormalizedHistoryColumns when
  building history rows
- Update deleteDuplicateHistoryRows to also clean history_path_keys
- Call replaceHistoryPathKeys after history row insert
- Implement buildPathMatchKeys in Kotlin mirroring the Dart version:
  URI parsing, backslash normalization, percent decoding, Android
  storage path aliases, audio extension stripping
2026-05-06 04:51:41 +07:00
zarzet 01a5b43613 perf: unify queue tab with DB-backed pagination and cross-database queries
Replace in-memory list merging in the queue tab with fully database-
backed pagination using ATTACH DATABASE to join library and history
tables in a single UNION ALL query.

Queue tab:
- Remove localLibraryAllItemsProvider and _queueHistoryStatsProvider
- Add _queueLibraryPageProvider and _queueLibraryCountsProvider backed
  by LibraryDatabase.getQueueTrackPage/getQueueCounts/getQueueAlbumPage
- Implement infinite scroll via _handleLibraryScrollNotification with
  _libraryPageLimit growing by 300 per batch
- Album/single/total counts computed via SQL GROUP BY aggregates

History database (v5 -> v8):
- v6: add idx_history_track_artist index
- v7: add history_path_keys table for cross-DB dedup, backfill from
  existing rows
- v8: add spotify_id_norm, isrc_norm, match_key normalized columns
  with indexes, backfill from existing data
- Add getAlbumTracks, findByTrackAndArtist, getGroupedCounts,
  existsTrack, findExistingTrack, existingTrackKeys batch lookup
- deleteBySpotifyId now returns deleted count for accurate totalCount
- All write paths maintain history_path_keys consistency

Library database (v7 -> v8):
- v8: add library_path_keys table for cross-DB dedup
- Add getQueueTrackPage, getQueueCounts, getQueueAlbumPage with
  ATTACH DATABASE for cross-DB UNION ALL queries
- Dedup local items against history via path_keys JOIN
- All write/delete paths maintain library_path_keys consistency

Download history provider:
- Load only 100 recent items into state.items at startup
- Store lookupItems as immutable List field instead of recomputing
  from maps on every access
- Add async fallback to DB in _putInMemoryHistory for items outside
  the 100-item window
- Add downloadHistoryPageProvider, downloadHistoryGroupedCountsProvider,
  downloadedAlbumTracksProvider, downloadHistoryBatchExistsProvider
- Add catchError to adoptNativeHistoryItem async block
- Fix removeBySpotifyId to query actual DB count instead of decrement

Screen migrations:
- album/artist/playlist/home screens use async DB lookups instead of
  sync in-memory state for track existence and playback resolution
- downloaded_album_screen uses downloadedAlbumTracksProvider
- library_tracks_folder_screen uses downloadHistoryBatchExistsProvider
  for skip-downloaded checks and cover resolution
2026-05-06 04:38:51 +07:00
zarzet 149cdc782d refactor: migrate local library from in-memory list to database-backed pagination
Replace the full in-memory List<LocalLibraryItem> in LocalLibraryState
with a lightweight lookup index (ISRCs, matchKeys, filePathById) and
database-backed FutureProvider.family pagination providers.

Database changes:
- Add library schema v7 with normalized lookup columns (track_name_norm,
  artist_name_norm, album_name_norm, album_artist_norm, match_key,
  album_key) and corresponding indexes
- Backfill normalized columns on migration from v6
- Add getPage, getPageCount, getAlbumPage, getAlbumCount, getLookupIndex,
  getCoverPaths, getByFilePath, findFirstByTrackAndArtist DB methods

Provider changes:
- LocalLibraryState no longer holds items list; uses totalCount and
  loadedIndexVersion for change tracking
- Deprecate synchronous getByIsrc/findByTrackAndArtist (return null);
  add async findExistingAsync, getByIsrcAsync, getById on notifier
- Add localLibraryPageProvider, localLibraryAlbumPageProvider,
  localLibraryAllItemsProvider family providers for paginated access
- Add localLibraryCoverProvider and localLibraryFirstCoverProvider
  for async cover path resolution from DB

Screen migrations:
- album/artist/playlist screens use findExistingAsync for playback
- library_tracks_folder_screen uses async cover providers and
  existsInLibrary for local library indicator
- queue_tab watches localLibraryAllItemsProvider instead of state.items
- library_settings_page uses state.totalCount
- playback_provider uses findExistingAsync

Track metadata screen:
- Replace pushReplacement navigation with in-place state swap using
  AnimatedSwitcher for smooth cross-fade transitions on track swipe
- Add metadataLoadGeneration counter to prevent stale async callbacks
- Reset all transient state (lyrics, cover, file check) on track change
2026-05-06 03:15:30 +07:00
Zarz Eleutherius 36137e8970 New translations app_en.arb (Russian)
[ci skip]
2026-05-06 01:29:54 +07:00
zarzet d24435dbc2 fix: truncate SAF filenames and directory segments safely at UTF-8 boundaries
Long track names (especially CJK/emoji) could exceed filesystem limits
when used as SAF document display names, causing write failures.

- Add truncateUtf8Bytes in Go, Kotlin (MainActivity + SafDownloadHandler),
  and Dart to truncate strings at valid UTF-8 codepoint boundaries
- Limit SAF filenames to 180 UTF-8 bytes (preserving file extension)
- Limit SAF directory segments to 120 UTF-8 bytes
- Fix Go sanitizeFilename to use UTF-8 aware truncation instead of
  byte slice which could split multi-byte characters
- Add Go test for multi-byte truncation correctness
- Sanitize SAF relative directory in Dart native worker and regular
  download paths via _sanitizeSafRelativeDir
2026-05-06 01:18:49 +07:00
Zarz Eleutherius 823e56926f New translations app_en.arb (German)
[ci skip]
2026-05-06 00:16:56 +07:00
zarzet bb06ab7e12 chore: remove dead code, fix error casing, and add lint rules
- Remove unused Go functions: buildRawAPEItem, loadCredentials,
  scanAudioFile, scanAudioFileWithKnownModTimeAndDisplayName,
  readM4AIndexValue, musixmatchSearchResponse/LyricsResponse structs
- Remove unused Go fields: downloadDir, utlsTransport.mu/h2Transports
- Lowercase Go error messages per convention (golint/ST1005)
- Simplify LyricsLine conversion and artistsMatch return
- Add Dart analysis rules: always_declare_return_types,
  avoid_types_as_parameter_names, strict_top_level_inference,
  type_annotate_public_apis
- Suppress SA1019 lint for required blowfish import
2026-05-06 00:04:49 +07:00
zarzet 2143de3aa7 chore: remove redundant comments and boilerplate across codebase
Strip doc comments, section dividers, HTML comments, and Flutter
template boilerplate that add no informational value. No logic or
behavior changes.
2026-05-05 21:35:18 +07:00
Zarz Eleutherius dd8a54dd43 New translations app_en.arb (German)
[ci skip]
2026-05-05 15:20:56 +07:00
Zarz Eleutherius 1ff33b96fa New translations app_en.arb (German)
[ci skip]
2026-05-05 13:21:43 +07:00
zarzet b5973c45a2 fix: improve native worker metadata embedding and notification progress
Enhance the NativeDownloadFinalizer metadata pipeline and download
service notification accuracy.

Metadata embedding:
- Adopt result > track > request priority chain consistently across
  all metadata fields via resultString/trackString/requestString helpers
- Add cover art embedding for FLAC (via cover_path in editFileMetadata)
  and M4A (via FFmpeg attached_pic) during native finalization
- Use separate track_number/track_total/disc_number/disc_total fields
  for FLAC instead of combined N/M format strings
- Use 'organization' key instead of 'label' for M4A metadata (MP4 std)
- Sanitize literal "null" strings in metadata via cleanMetadataString
- Add -map_metadata 0 to FFmpeg tag commands to preserve existing tags

Notification progress:
- Fall back to percentage-based notification when extension reports
  progress ratio without byte counts (bytesTotal == 0)
- Show indeterminate progress spinner during downloading state with
  no byte data instead of a stale bar
2026-05-05 04:49:28 +07:00
zarzet 9a78798854 feat: improve library grid, image loading, and metadata filters
UI and UX improvements across the library and queue screens.

- Add pinch-to-zoom for library grid views with animated extent
  transitions via _AnimatedLibrarySliverGrid widget
- Replace fixed grid column count with responsive maxCrossAxisExtent
- Add smooth fade-in for cover images (local files via frameBuilder,
  network via CachedCoverImage fadeInDuration/fadeOutDuration params)
- Refactor track metadata swipe navigation from push+pop to
  pushReplacement to prevent route stack accumulation
- Convert adjacentHorizontalPageRoute to MaterialPageRoute subclass
  to support pushReplacement with proper transition semantics
- Add five new metadata completeness filters: missing track number,
  missing disc number, missing artist, incorrect ISRC format, and
  missing label
- Expose trackNumber, discNumber, isrc, and label on
  UnifiedLibraryItem for filter support
- Tighten metadata completeness definition to include all new fields
2026-05-05 04:22:24 +07:00
zarzet 101ab3f521 refactor: remove built-in provider registry in favor of extensions
All search, metadata, and download providers are now exclusively
supplied by extensions. The built-in provider registry that previously
exposed Deezer/Tidal/Qobuz as hardcoded providers is fully removed.

Removed across Go, Dart, Kotlin, and Swift:
- BuiltInProviderSpec class, registry, and all accessor helpers
- SearchProviderAllJSON, GetBuiltInProvidersJSON, ParseProviderURLJSON,
  ParseDeezerURLExport Go exports and their platform channel bindings
- Built-in provider items in search dropdown, service picker, and
  provider priority UI lists
- provider_ui_utils.dart helper file

Deezer metadata enrichment (ISRC lookup, extended metadata, cover
upgrade) remains fully functional through direct DeezerClient calls
in the download pipeline — these are not part of the provider
registry and are unaffected.

Mark deezer as a retired built-in metadata provider so stale user
priority lists are cleaned up on next launch.
2026-05-05 03:55:24 +07:00
zarzet cfc8e699f3 perf: optimize native worker snapshot writes with delta mode
Replace full-queue snapshot writes on every progress tick with a
lightweight delta mode that only includes the active item.

- Progress tick (1s loop) now writes item_delta with only the
  active item instead of serializing the entire queue
- Full compact_items snapshots are reserved for important events:
  start, pause/resume/cancel, item boundaries, finish, and
  service stop/destroy
- Compact items omit large static fields (item_json, track_name,
  artist_name) that Dart already has from queue restore
- Snapshot always carries item_ids for adoption correlation
- Dart-side _applyAndroidNativeWorkerSnapshot handles item_delta
  as a single-item fallback when items array is absent
- Dart-side _tryAdoptAndroidNativeWorkerSnapshot reads item_ids
  as fallback when items is not present
- Add deferred SAF publish: native worker writes to cache, runs
  all finalization locally, then publishes once to SAF at the end
- Forward defer_saf_publish through DownloadRequestPayload
2026-05-05 03:17:38 +07:00
zarzet 6b342aeac6 feat: add experimental Android native download worker
Introduce a service-owned download worker that offloads the full
download-and-finalize pipeline to DownloadService on Android, keeping
downloads alive independently of the Flutter UI process.

Key changes:
- Extract SAF download logic from MainActivity into SafDownloadHandler
- Add NativeDownloadFinalizer for Kotlin-side decryption, format
  conversion, metadata embedding, ReplayGain, post-processing, and
  history persistence
- Extend DownloadService with native queue management (start, pause,
  resume, cancel) using coroutine-based worker with AtomicFile snapshots
- Add Dart-side orchestration: snapshot polling, run-id correlation,
  adoption on app restart, and fallback to Dart queue
- Forward embedReplayGain, tidalHighFormat, and postProcessingEnabled
  through Go backend DownloadRequest struct
- Add nativeDownloadWorkerEnabled setting with UI toggle
- Make DownloadQueueLookup collections unmodifiable
2026-05-05 02:41:00 +07:00
zarzet b306056995 perf: reduce library and queue update overhead 2026-05-04 20:07:32 +07:00
zarzet 82e317c4a8 fix: sync download progress notification states 2026-05-04 17:24:57 +07:00
zarzet a4dc776bfb chore: update default lyrics providers and about links 2026-05-04 15:51:57 +07:00
zarzet 5bdaa35ced test: add comprehensive Go backend and Dart model test suites
- Add 16 Go supplement test files covering extension runtime, providers,
  health checks, lyrics, metadata, HTTP utils, library scan, and more
- Add Dart model/utils test suite (test/models_and_utils_test.dart)
- Update settings.g.dart with deduplicateDownloads serialization
2026-05-04 02:21:17 +07:00
zarzet e187ac461d fix provider fallbacks and public branding 2026-05-04 00:51:52 +07:00
zarzet 1b4a6cd042 feat: show extension service health 2026-05-03 20:20:28 +07:00
zarzet dcfb22c3f4 fix: persist probed audio duration 2026-05-03 16:49:43 +07:00
zarzet 501158df03 fix: constrain artist album covers 2026-05-03 16:35:17 +07:00
zarzet e17a4fad4e fix: avoid disk resizing cover cache by default 2026-05-03 15:19:49 +07:00
zarzet 34894faabf perf: reduce bridge and UI churn 2026-05-03 14:12:53 +07:00
zarzet b329acd710 fix: clean up settings merge regressions 2026-05-03 01:54:59 +07:00
Zarz Eleutherius 4be9273768 New translations app_en.arb (Hindi)
[ci skip]
2026-05-03 01:39:32 +07:00
Zarz Eleutherius f458ac2162 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-03 01:39:31 +07:00
Zarz Eleutherius b5ea2bb4c1 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-03 01:39:29 +07:00
Zarz Eleutherius 284d257921 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-03 01:39:28 +07:00
Zarz Eleutherius 30bf6b7f9a New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-03 01:39:27 +07:00
Zarz Eleutherius 4941b6bd23 New translations app_en.arb (Turkish)
[ci skip]
2026-05-03 01:39:25 +07:00
Zarz Eleutherius 33d99817ec New translations app_en.arb (Russian)
[ci skip]
2026-05-03 01:39:24 +07:00
Zarz Eleutherius 37e1af50ad New translations app_en.arb (Portuguese)
[ci skip]
2026-05-03 01:39:22 +07:00
Zarz Eleutherius 8a6efb1303 New translations app_en.arb (Dutch)
[ci skip]
2026-05-03 01:39:21 +07:00
Zarz Eleutherius 7823b19b89 New translations app_en.arb (Korean)
[ci skip]
2026-05-03 01:39:19 +07:00
Zarz Eleutherius 2a9aa544a9 New translations app_en.arb (Japanese)
[ci skip]
2026-05-03 01:39:18 +07:00
Zarz Eleutherius f387c8ff85 New translations app_en.arb (German)
[ci skip]
2026-05-03 01:39:17 +07:00
Zarz Eleutherius 7e537aec0b New translations app_en.arb (Spanish)
[ci skip]
2026-05-03 01:39:15 +07:00
Zarz Eleutherius 66cd465565 New translations app_en.arb (French)
[ci skip]
2026-05-03 01:39:14 +07:00
zarzet 87dc8eb5ea chore: update app dependency versions 2026-05-03 01:25:26 +07:00
zarzet 397669965d Merge remote-tracking branch 'spotiflacapp/main'
# Conflicts:
#	android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
#	android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
#	android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
#	android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
#	android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
#	android/app/src/main/res/mipmap-hdpi/ic_launcher.png
#	android/app/src/main/res/mipmap-mdpi/ic_launcher.png
#	android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
#	android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
#	android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
#	crowdin.yml
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
#	ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
#	lib/l10n/app_localizations_de.dart
#	lib/l10n/app_localizations_fr.dart
#	lib/l10n/app_localizations_id.dart
#	lib/l10n/app_localizations_tr.dart
#	lib/l10n/arb/app_en.arb
#	lib/l10n/arb/app_id.arb
#	lib/l10n/arb/app_ja.arb
#	lib/l10n/arb/app_tr.arb
#	lib/screens/settings/download_settings_page.dart
#	lib/screens/settings/options_settings_page.dart
2026-05-03 01:00:08 +07:00
Zarz Eleutherius 60bd0e619e Merge pull request #352 from Amonoman/main
Add deduplicateDownloads setting and fix build errors
2026-05-03 00:35:19 +07:00
Amonoman 2c7621c1a5 revert: release.yml to normal 2026-05-02 19:28:26 +02:00
Zarz Eleutherius 83afa40423 New translations app_en.arb (Hindi)
[ci skip]
2026-05-03 00:20:59 +07:00
Zarz Eleutherius 486e7eb101 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-03 00:20:58 +07:00
Zarz Eleutherius 05eb9e60d3 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-03 00:20:56 +07:00
Zarz Eleutherius dde7095644 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-03 00:20:55 +07:00
Zarz Eleutherius f1e9a2915d New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-03 00:20:53 +07:00
Zarz Eleutherius ae3495d373 New translations app_en.arb (Turkish)
[ci skip]
2026-05-03 00:20:51 +07:00
Zarz Eleutherius 6fb2c1b688 New translations app_en.arb (Russian)
[ci skip]
2026-05-03 00:20:50 +07:00
Zarz Eleutherius 1526c558e7 New translations app_en.arb (Portuguese)
[ci skip]
2026-05-03 00:20:49 +07:00
Zarz Eleutherius 324e0f053b New translations app_en.arb (Dutch)
[ci skip]
2026-05-03 00:20:47 +07:00
Zarz Eleutherius 25cb33c78e New translations app_en.arb (Korean)
[ci skip]
2026-05-03 00:20:46 +07:00
Zarz Eleutherius 942b6d9569 New translations app_en.arb (Japanese)
[ci skip]
2026-05-03 00:20:44 +07:00
Zarz Eleutherius cd46c79383 New translations app_en.arb (German)
[ci skip]
2026-05-03 00:20:43 +07:00
Zarz Eleutherius 0bdcdcc229 New translations app_en.arb (Spanish)
[ci skip]
2026-05-03 00:20:42 +07:00
Zarz Eleutherius 1a5863a7fb New translations app_en.arb (French)
[ci skip]
2026-05-03 00:20:40 +07:00
Amonoman b55be00fab i18n: add missing optionsDefaultSearchTabAlbums/Tracks keys to all locales 2026-05-02 19:04:39 +02:00
Amonoman f8b7812943 feat: add deduplicateDownloads setting & fix build errors
- Add deduplicateDownloads field to AppSettings (default: true)
- Add setDeduplicateDownloads() to SettingsNotifier
- Fix type mismatch in files_settings_page (Object → String cast)
- Run build_runner to regenerate settings.g.dart
2026-05-02 18:47:09 +02:00
Amonoman 8f14ff169a Aktualisieren von release.yml 2026-05-02 18:24:03 +02:00
Zarz Eleutherius ca3abeb1cf Merge pull request #345 from Amonoman/main
Refactor settings into dedicated pages and update icons
2026-05-02 23:14:41 +07:00
Amonoman bb0cc23461 i18n: sync missing EN strings to all locales & fix DE consistency
- Add 18 missing keys to DE, ES_ES, FR, HI, ID, JA, KO, NL, PT_PT, RU, TR, UK, ZH_CN, ZH_TW
- Add 580 missing keys to ES, PT, ZH (outdated partial files)
- Fix DE: Sie→du throughout, typos (Standart, auwählen), grammar errors in dialogs
2026-05-02 18:13:04 +02:00
zarzet 45fa33e1ec fix(ios): use security-scoped bookmarks for download directory persistence
- Switch iOS bookmark creation from .minimalBookmark to .withSecurityScope
- Add .withSecurityScope option when resolving bookmarks
- Add downloadDirectoryBookmark field to AppSettings for persisting iOS bookmarks
- Resolve bookmark and startAccessingIosBookmark before queue processing
- Guarantee stopAccessingIosBookmark cleanup via try/finally
- Create bookmark on folder pick in both setup screen and download settings
- Clear bookmark when switching to SAF mode or iOS path normalization
- Fix stale bottom sheet context usage (ctx -> context) in download settings
2026-05-02 01:19:39 +07:00
zarzet 64dbf4441c feat: add Favorite Artists collection
- Add CollectionArtistEntry model with toJson/fromJson and artistCollectionKey helper
- Create favorite_artists table in SQLite with DB migration v1→v2
- Implement toggleFavoriteArtist/removeFavoriteArtist in LibraryCollectionsNotifier
- Add FavoriteArtistsScreen with list view, empty state, and artist navigation
- Add heart toggle button on ArtistScreen header (reactive via Riverpod selector)
- Integrate favorite artists folder in queue_tab collection grid/list views
- Add 8 new localization strings across all 13 locale files
2026-05-02 00:50:02 +07:00
zarzet 148e5c1231 fix: fallback unsupported locales to English
Fixes #327
2026-05-02 00:32:41 +07:00
zarzet 3a7419ec9f refactor: split large screen files into part files and DRY platform bridge
- Extract home_tab.dart helpers/widgets into home_tab_helpers.dart and home_tab_widgets.dart using Dart part files
- Extract queue_tab.dart helpers/widgets into queue_tab_helpers.dart and queue_tab_widgets.dart using Dart part files
- Extract track_metadata_edit_sheet.dart from track_metadata_screen.dart using Dart part file
- Refactor _FileExistsListenableCache into a standalone class in queue_tab_helpers.dart
- Fix artist_screen.dart: replace unreliable findAncestorStateOfType with GlobalKey for _FetchingProgressDialog progress updates
- DRY platform_bridge.dart: extract common JSON decode patterns into reusable helper methods (_decodeRequiredMapResult, _decodeNullableMapResult, _decodeMapListResult, _decodeStringListResult)
2026-05-02 00:27:51 +07:00
zarzet 01c7c9cc3a perf: improve download queue resilience 2026-05-01 23:51:24 +07:00
Zarz Eleutherius 701015ad55 New translations app_en.arb (Spanish)
[ci skip]
2026-05-01 04:51:14 +07:00
zarzet 3f56b88fa5 refactor: rename skipBuiltInFallback to stopProviderFallback, unify service/search provider grid layout, and retire useExtensionProviders toggle
- Add stopProviderFallback manifest field with backward compat for skipBuiltInFallback

- Expose stop_provider_fallback in extension JSON API

- Unify service and search provider chips into 4-per-row grid layout

- Enable Ask Before Download for extensions with quality options

- Force useExtensionProviders always-on (built-in providers retired)

- Update localization: Built-in -> Legacy, remove obsolete description text

- Clear hardcoded donor names list
2026-05-01 04:43:06 +07:00
zarzet bdd3f4aef5 feat: retire built-in download providers, add isolated extension runtimes, Google Sans Flex font, and monochrome icon support
- Remove all built-in download provider code paths (DownloadTrack, DownloadWithFallback, tryBuiltInProvider, isBuiltInDownloadProvider, normalizeQualityForBuiltIn)
- Simplify DownloadByStrategy to route exclusively through extension providers
- Add newIsolatedExtensionRuntime() for concurrent per-download Goja VMs
- Extract reusable initializeExtensionRuntimeWithSettings() and runCleanupOnVM()
- Add TestExtensionDownloadUsesIsolatedRuntimeForConcurrentCalls
- Add Google Sans Flex font family to app themes
- Add Android adaptive icon monochrome support
- Regenerate iOS and Android app icons
2026-05-01 02:44:32 +07:00
zarzet 611abdc6ae feat: improve extension metadata UI 2026-04-29 18:33:44 +07:00
Amonoman 6e9fa45915 feat(settings): reorganize settings into focused pages
- split download/options into download, files, metadata, lyrics, app
- add dedicated pages for files & folders, metadata, and lyrics
- move search source and fallback into download page
- replace options_settings_page with app_settings_page
- add missing l10n keys for all new pages
- improve subtitle copy for download, embed lyrics, primary provider
2026-04-28 13:46:44 +02:00
Zarz Eleutherius 63cfac626a New translations app_en.arb (French) 2026-04-28 05:12:53 +07:00
Zarz Eleutherius e6c5a21bfc New translations app_en.arb (French) 2026-04-28 04:13:54 +07:00
Amonoman 7dafbc1063 refactor(settings): split download/options into focused pages
- Extract files, metadata, lyrics into dedicated pages
- Move search source + fallback into download page
- Move app/update/debug settings into new app_settings_page
- Replace options_settings_page with app_settings_page
- Reorganize settings_tab into 3 logical groups
2026-04-27 20:43:12 +02:00
Amonoman ad8ac3bd2b Update every icon except banner 2026-04-27 17:12:43 +02:00
Zarz Eleutherius 2d80739141 New translations app_en.arb (Spanish) 2026-04-26 01:44:55 +07:00
Zarz Eleutherius 6494102e15 New translations app_en.arb (French) 2026-04-24 15:12:52 +07:00
Zarz Eleutherius 0e6aa2efd9 New translations app_en.arb (French) 2026-04-24 05:23:14 +07:00
zarzet cd2c2a9854 feat: expose audio duration in metadata API and fix home empty-state race
- Add Duration field to AudioQuality for FLAC (streaminfo) and M4A (mvhd atom)
- Expose duration via ReadFileMetadata and extension runtime Go-backend API
- Pass duration_ms to extension CheckAvailability for better track matching
- Fix home tab showing empty state before extensions finish initializing by
  keeping the search bar visible with a loading indicator until ready
- Refactor hasSearchProvider helper to account for built-in providers
- Refine homeEmptyTitle/Subtitle copy (EN + ID translations)
- Bump version to 4.5.0+127
2026-04-24 04:38:41 +07:00
Zarz Eleutherius f412c216c5 New translations app_en.arb (French) 2026-04-24 00:51:45 +07:00
Zarz Eleutherius af15e3d914 New translations app_en.arb (French) 2026-04-23 23:54:53 +07:00
Zarz Eleutherius b00ff3f3f0 New translations app_en.arb (German) 2026-04-23 21:06:22 +07:00
Zarz Eleutherius 1607e6830e New translations app_en.arb (French) 2026-04-23 19:03:21 +07:00
Zarz Eleutherius 817e0bf2bd New translations app_en.arb (French) 2026-04-23 16:51:44 +07:00
Zarz Eleutherius 0f12fbce6a New translations app_en.arb (French) 2026-04-23 14:54:33 +07:00
Zarz Eleutherius 953a09d75f New translations app_en.arb (Ukrainian) 2026-04-22 01:52:19 +07:00
Zarz Eleutherius 5098989614 New translations app_en.arb (Russian) 2026-04-20 18:24:22 +07:00
Zarz Eleutherius 5828bcffdd New translations app_en.arb (Korean) 2026-04-20 18:24:21 +07:00
Zarz Eleutherius ae87a7d58f New translations app_en.arb (Korean) 2026-04-20 16:22:00 +07:00
zarzet bb7c86c29e feat: add generic extension provider resolution, progress phases, and instrumental lyrics heuristic
- Replace hardcoded provider prefix checks with resolveEffectiveMetadataProvider using replacesBuiltInProviders manifest capability
- Add preparing/downloading/finalizing progress status constants and SetItemPreparing/SetItemDownloading APIs
- Expose setDownloadStatus to extension JS runtime for fine-grained progress control
- Skip lyrics search for instrumental tracks detected by title heuristic
- Pass tidal/qobuz IDs to extension checkAvailability for richer matching
- Add shouldAbortCancelledFallback helper for robust cancellation propagation
- Add resolvePreferredTrackIDForExtension for intelligent track ID selection per extension
- Remove ambiguous Auto/Default search provider option, always resolve to concrete provider
- Add tests for shouldAbortCancelledFallback and progress status transitions
2026-04-20 13:46:02 +07:00
Zarz Eleutherius 32ab78a213 New translations app_en.arb (Russian) 2026-04-19 21:20:37 +07:00
Zarz Eleutherius 69583d172c New translations app_en.arb (Russian) 2026-04-19 19:52:56 +07:00
Zarz Eleutherius 38367c1c77 New translations app_en.arb (Russian) 2026-04-19 18:31:58 +07:00
Zarz Eleutherius 2f6bf91a1c New translations app_en.arb (German) 2026-04-19 02:58:18 +07:00
Zarz Eleutherius 60b062bbaf New translations app_en.arb (German) 2026-04-19 02:01:11 +07:00
Zarz Eleutherius 30e8b604a9 New translations app_en.arb (Ukrainian) 2026-04-18 23:47:31 +07:00
Zarz Eleutherius 7c3ab92e17 New translations app_en.arb (Turkish) 2026-04-18 23:47:29 +07:00
Zarz Eleutherius 37b101c70f New translations app_en.arb (Portuguese) 2026-04-18 23:47:28 +07:00
Zarz Eleutherius b7be46e6ae New translations app_en.arb (Spanish) 2026-04-18 23:47:25 +07:00
Zarz Eleutherius df96cc4a1d Merge pull request #333 from spotiflacapp/fix-crowdin-locale-mappings
fix: map missing Crowdin locale variants
2026-04-18 23:44:31 +07:00
zarzet 6c3d92cee4 fix: map missing Crowdin locale variants 2026-04-18 23:39:38 +07:00
Zarz Eleutherius bf1f79866b New translations app_en.arb (Hindi) 2026-04-18 23:35:11 +07:00
Zarz Eleutherius a6460426a2 New translations app_en.arb (Indonesian) 2026-04-18 23:35:10 +07:00
Zarz Eleutherius 304ba14d20 New translations app_en.arb (Chinese Traditional) 2026-04-18 23:35:09 +07:00
Zarz Eleutherius db47233d92 New translations app_en.arb (Chinese Simplified) 2026-04-18 23:35:08 +07:00
Zarz Eleutherius 74eeb98be8 New translations app_en.arb (Russian) 2026-04-18 23:35:06 +07:00
Zarz Eleutherius 331da0f897 New translations app_en.arb (Dutch) 2026-04-18 23:35:04 +07:00
Zarz Eleutherius 73964ee648 New translations app_en.arb (Korean) 2026-04-18 23:35:03 +07:00
Zarz Eleutherius a5e8402141 New translations app_en.arb (Japanese) 2026-04-18 23:35:02 +07:00
Zarz Eleutherius c5e7fcf29b New translations app_en.arb (German) 2026-04-18 23:35:01 +07:00
Zarz Eleutherius d3cf6d30a7 New translations app_en.arb (French) 2026-04-18 23:34:59 +07:00
zarzet 803cd2de96 refactor: remove Qobuz built-in provider and delete qobuz.go
Delete the entire Qobuz downloader implementation (qobuz.go, qobuz_test.go)
including all API clients, search, metadata, download, and track matching
code. Empty the builtInProviderRegistry now that all built-in providers are
retired. Remove Qobuz-specific exports (SearchQobuzAll, GetQobuzMetadata,
ParseQobuzURLExport) and the downloadWithBuiltInQobuz adapter. Stub out
PreWarmTrackCache and cache management since no built-in providers remain.
Move qobuz cover upgrade regex to cover.go. Update Dart screens, providers,
and localization strings for the provider-agnostic UI.
2026-04-18 23:32:16 +07:00
zarzet 8f2ca33e87 refactor: remove Qobuz from built-in provider registry, add retired provider detection
Empty the builtInProviderRegistry now that all built-in providers are
retired. Introduce isRetiredBuiltInDownloadProvider and
isRetiredBuiltInMetadataProvider to classify deezer/qobuz/tidal/spotify
as retired, replacing ad-hoc string checks. Add Dart-side metadata
provider priority reconciliation that replaces retired providers with
extensions declaring replacesBuiltInProviders. Remove getQobuzMetadata
from native bridges and platform_bridge.dart. Update crowdin.yml with
additional locale mappings.
2026-04-18 23:10:00 +07:00
Zarz Eleutherius d87e0d7e01 Merge pull request #331 from spotiflacapp/merge-l10n-dev-into-main-safe
chore: import l10n updates into main and enable Ukrainian locale
2026-04-18 23:06:35 +07:00
zarzet 86b8709ea1 chore: enable Ukrainian locale on main 2026-04-18 23:02:00 +07:00
zarzet 702b917929 chore: import l10n updates from l10n_dev into main 2026-04-18 22:35:57 +07:00
Zarz Eleutherius 74e14f7a43 New translations app_en.arb (Hindi) 2026-04-18 22:24:11 +07:00
Zarz Eleutherius 02e347adb0 New translations app_en.arb (Indonesian) 2026-04-18 22:24:10 +07:00
Zarz Eleutherius 56983cb85b New translations app_en.arb (Chinese Traditional) 2026-04-18 22:24:09 +07:00
Zarz Eleutherius 7917c656b0 New translations app_en.arb (Chinese Simplified) 2026-04-18 22:24:08 +07:00
Zarz Eleutherius fc34c1e548 New translations app_en.arb (Ukrainian) 2026-04-18 22:24:07 +07:00
Zarz Eleutherius f32aeaa0ff New translations app_en.arb (Turkish) 2026-04-18 22:24:06 +07:00
Zarz Eleutherius 86097a932c New translations app_en.arb (Russian) 2026-04-18 22:24:05 +07:00
Zarz Eleutherius f74f24c41f New translations app_en.arb (Portuguese) 2026-04-18 22:24:04 +07:00
Zarz Eleutherius 8e99e7b07e New translations app_en.arb (Dutch) 2026-04-18 22:24:03 +07:00
Zarz Eleutherius e06aab6e87 New translations app_en.arb (Korean) 2026-04-18 22:24:01 +07:00
Zarz Eleutherius a81e56fb26 New translations app_en.arb (Japanese) 2026-04-18 22:24:00 +07:00
Zarz Eleutherius 9a09b119c5 New translations app_en.arb (German) 2026-04-18 22:23:59 +07:00
Zarz Eleutherius 4b28ca1055 New translations app_en.arb (Spanish) 2026-04-18 22:23:58 +07:00
Zarz Eleutherius d684d9f8d1 New translations app_en.arb (French) 2026-04-18 22:23:57 +07:00
zarzet 16ce6089fb feat: remove Tidal built-in provider, add extension download dedup/ISRC/Lyrics APIs, and expand l10n/a11y
Remove Tidal from built-in provider registry (metadata, search, download,
URL parsing) and delete tidal.go. Introduce extension runtime APIs for
lyrics lookup (getLyricsLRC), ISRC existence check (checkISRCExists), and
ISRC index management (addToISRCIndex). Refactor extension download response
construction into normalizeExtensionDownloadResult/overlayExtensionDownloadMetadata
helpers with AlreadyExists support and ISRC indexing. Switch download mirrors
to DoRequestWithUserAgent for ISP blocking detection. Add 50+ new
localization keys and accessibility labels across all supported locales.
2026-04-18 22:12:14 +07:00
zarzet 6895e45f2c refactor: abstract built-in providers into generic registry and unify platform bridge API
Replace hardcoded Tidal/Qobuz switch/case with builtInProviderSpec registry
pattern. Unify searchTidalAll/searchQobuzAll into searchProviderAll,
getDeezerMetadata/getTidalMetadata/getQobuzMetadata into getProviderMetadata,
and parseDeezerUrl/parseQobuzUrl/parseTidalUrl into parseProviderUrl. Remove
extension-specific getAlbum/Playlist/ArtistWithExtension in favor of generic
getProviderMetadata routing. Extract provider UI helpers into
provider_ui_utils.dart. Preserve track_number fallback for zero-value
TrackNumber in album/playlist track lists.
2026-04-17 03:59:02 +07:00
zarzet e87f7a1177 feat: add skip_fallback capability for extension availability results 2026-04-16 20:21:06 +07:00
zarzet bcd8a05352 feat: propagate download cancellation through entire pipeline, add MusicBrainz album artist fallback, and allow disabling home feed
- Add reference-counted cancel entries to prevent premature cleanup when multiple operations share the same itemID
- Propagate cancellation to DownloadTrack, DownloadWithFallback, DownloadWithExtensionsJSON, extension providers, and ISRC search
- Fetch album artist from MusicBrainz when missing during download and re-enrich
- Make ALBUMARTIST tag nullable to avoid writing artistName as album artist
- Add home feed 'Off' option in extension settings
- Skip deezer in download provider priority sanitization
2026-04-16 02:55:40 +07:00
github-actions[bot] 4b219ad18e chore: update AltStore source to v4.3.1 2026-04-14 14:21:29 +00:00
zarzet 57051bd649 fix: handle .mp4 as alias for .m4a throughout download pipeline 2026-04-14 21:12:14 +07:00
zarzet d6fca6ca55 feat: carry extension download metadata through host pipeline and avoid FLAC-only genre/label pre-embed on non-FLAC files 2026-04-14 21:12:14 +07:00
zarzet 153ec2d9e5 chore: bump version to 4.3.1+126 2026-04-14 21:12:14 +07:00
zarzet be90e85d94 fix: show filter button in all/singles modes when tracks are empty but filters are active 2026-04-14 21:12:14 +07:00
zarzet 4af089f56c feat: improve Tidal metadata (copyright, album artist), remove Qobuz metadata search fallback, fix DATE/YAR tag sync 2026-04-14 21:12:14 +07:00
zarzet 62519d2d1c feat: add preserveNativeOutputExtensions capability for extensions 2026-04-14 21:12:14 +07:00
zarzet 27c0880e87 feat: convert M4A to FLAC when extension doesn't prefer native M4A output
When an extension's preferred output isn't .m4a, downloaded M4A streams
are now automatically converted to FLAC via FFmpeg instead of being
preserved. This applies to both SAF and non-SAF download paths.
2026-04-14 21:12:14 +07:00
zarzet f312b74b30 fix: ensure non-null search provider fallback and update default labels to Tidal
- Add monochrome.tf and samidy.com Tidal API mirrors
- Guarantee resolvedProvider is never null by defaulting to 'tidal'
- Replace stale 'Deezer' default label with 'Tidal' (Deezer moved to extension)
- Show dynamic provider target in auto label for search dropdown
2026-04-14 21:12:14 +07:00
zarzet bd49e307ef fix: reset OutputExt on extension→extension fallback too 2026-04-14 21:12:14 +07:00
zarzet e904a836c1 fix: reset OutputExt on extension→built-in fallback 2026-04-14 21:12:14 +07:00
zarzet 763c9478f1 fix: normalize extension codec for built-in fallback, remove dead Tidal ISRC 2026-04-14 21:12:13 +07:00
zarzet 427bdf74dc chore: reduce Gradle memory, add extension network timeout, fix tr locale 2026-04-14 21:12:13 +07:00
zarzet 373a276c54 fix: respect user provider choice over source extension priority 2026-04-14 21:12:13 +07:00
github-actions[bot] dccadf1f87 chore: update AltStore source to v4.3.1 2026-04-14 13:58:11 +00:00
github-actions[bot] d9933fe038 chore: update AltStore source to v4.3.0 2026-04-13 16:39:57 +00:00
zarzet d47ac0934d chore: bump version to 4.3.0 and fix SAF document file race condition
- Bump app version to 4.3.0 (build 125)
- Extract createOrReuseDocumentFile() to handle SAF auto-rename races
  between findFile() and createFile(), preferring the exact-named sibling
  and discarding duplicate documents
2026-04-13 23:35:03 +07:00
zarzet dbba4d6630 feat: propagate download cancel to extension HTTP requests and fix SAF filename extension mismatch
- Bind cancel context to all extension HTTP calls (fetch, httpGet, httpPost,
  httpRequest, fileDownload, authExchangeCodeWithPKCE) so in-flight requests
  are aborted when user cancels a download
- Make initDownloadCancel idempotent: return existing context if entry already
  exists and preserve pre-cancelled state
- Force SAF output filename to match actual file extension when extension
  returns a different format than requested (e.g. FLAC requested but M4A produced)
- Map ALAC/AAC quality to .m4a instead of falling through to default .flac
2026-04-13 23:35:03 +07:00
zarzet 7405855e01 fix: handle extension oauth callback on ios 2026-04-13 23:35:03 +07:00
zarzet ed020c9303 feat: native M4A ReplayGain tag writing and SAF picker error handling 2026-04-13 23:35:03 +07:00
zarzet 378742e37a refactor: remove author field from extension manifest and UI 2026-04-13 23:35:03 +07:00
zarzet c79bee534e fix: align default search tab layout with primary provider selector using Row+Expanded 2026-04-13 23:35:03 +07:00
zarzet 1d6df75829 fix: preserve existing M4A metadata during embed and enable BuildConfig generation 2026-04-13 23:35:03 +07:00
zarzet b7f51b5f14 feat: expose extension utils, preserve M4A native container, and bump to v4.2.3+124 2026-04-13 23:35:03 +07:00
zarzet 1c8e9df727 feat: add artist search filter and normalize search filter handling 2026-04-13 23:35:03 +07:00
zarzet 01540fe3fc fix: improve ALAC M4A quality parsing 2026-04-13 23:35:03 +07:00
zarzet 071db2f109 refactor: move deezer search flow to extension 2026-04-13 23:35:02 +07:00
zarzet e097d3f605 fix: stabilize shared extension link handling 2026-04-13 23:35:02 +07:00
zarzet 277f783f62 feat: add default search tab preference 2026-04-13 23:35:02 +07:00
zarzet 7637aaf168 fix: fallback extra metadata genre 2026-04-13 23:35:02 +07:00
zarzet c4878470bf chore: thank Ldav Nico and Feuerstern on donate page 2026-04-13 23:35:02 +07:00
zarzet a3725e8c48 feat: add keep android open link 2026-04-13 23:35:02 +07:00
zarzet 917ba842f5 fix: align metadata sanitization and lyrics editing 2026-04-13 23:32:19 +07:00
zarzet dac17ead33 chore: bump app to v4.2.2 2026-04-13 23:32:19 +07:00
zarzet 6845ebe04c refactor: move deezer to extension 2026-04-13 23:32:18 +07:00
zarzet eff709480d fix: preserve flat singles output for extension releases 2026-04-13 23:32:18 +07:00
zarzet 67833424cc fix: align re-enrich matching with autofill metadata 2026-04-13 23:32:18 +07:00
zarzet 5c48e1b476 fix: persist downloaded metadata and refine metadata navigation 2026-04-13 23:32:18 +07:00
zarzet 5e17c9f238 feat: add configurable extension download fallback 2026-04-13 23:32:18 +07:00
zarzet 7d330fb2ec fix: preserve composer metadata across qobuz and history 2026-04-13 23:32:18 +07:00
zarzet cd6a4594fa chore: bump app version to v4.2.1 2026-04-13 23:32:17 +07:00
zarzet bcf727f4ec fix: remove stale audio service manifest entries causing crashes on some devices 2026-04-13 23:32:17 +07:00
zarzet 4c4553913f fix: harden gomobile extension bindings and m4a cover retention 2026-04-13 23:32:17 +07:00
zarzet f0013fac16 fix: preserve local convert format and library entries 2026-04-13 23:32:17 +07:00
zarzet ce4be0ba97 feat: enrich composer and track totals metadata 2026-04-13 23:32:17 +07:00
zarzet 4bac38ef2a fix: preserve embedded metadata details 2026-04-13 23:32:17 +07:00
zarzet 4b213f47d9 ci: pin iOS release builds to macOS 15 and Xcode 26.1.1 2026-04-13 23:32:16 +07:00
zarzet a1010f72f2 fix: patch device_info_plus iOS build for older Xcode SDKs 2026-04-13 23:32:16 +07:00
zarzet 21077a26d0 feat: add additional search/metadata API with separate rate limiting 2026-04-13 23:32:16 +07:00
zarzet b50eec5a47 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-13 23:32:16 +07:00
zarzet 38a8b715f8 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-13 23:32:16 +07:00
zarzet 2b47537bb5 fix: route Qobuz API calls through authenticated gateway to resolve 401 errors 2026-04-13 23:32:16 +07:00
zarzet a5cf241846 refactor: consolidate FLAC/MP3/Opus metadata embedding into unified _embedMetadataToFile 2026-04-13 23:32:16 +07:00
zarzet 53a4773480 feat: add skipLyrics manifest field for extensions to opt out of lyrics fetching 2026-04-13 23:32:16 +07:00
zarzet 89603af1f1 chore: remove redundant comments and update donor list 2026-04-13 23:32:15 +07:00
zarzet 2143084d3c 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-13 23:32:15 +07:00
zarzet 0e265193b8 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-13 23:32:15 +07:00
zarzet c7e9749ce4 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-13 23:32:15 +07:00
zarzet e21cffff0b 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-13 23:32:15 +07:00
zarzet d9e20040be 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-13 23:32:15 +07:00
zarzet 6689173525 chore: bump version to 4.2.0 (build 121) 2026-04-13 23:32:14 +07:00
zarzet f37e4704a6 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-13 23:32:14 +07:00
zarzet 65dbd5c8e4 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-13 23:32:14 +07:00
zarzet d034144e9c 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-13 23:32:14 +07:00
zarzet 7c4309955e 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-04-13 23:32:13 +07:00
zarzet 63e90d13d4 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-04-13 23:32:13 +07:00
zarzet bfb0cad603 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-04-13 23:32:13 +07:00
zarzet cc10a917dc 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-04-13 23:32:13 +07:00
zarzet 5e833c1f75 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-04-13 23:32:13 +07:00
zarzet 8c576ac7e4 chore: bump version to 4.1.3 (build 120) 2026-04-13 23:32:13 +07:00
zarzet 92160537c0 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-04-13 23:32:13 +07:00
zarzet 120ecaa0e5 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-04-13 23:32:12 +07:00
zarzet fd3a34303e 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-04-13 23:32:12 +07:00
zarzet d89b70e155 fix: use Tidal quality options as fallback instead of DEFAULT for extensions 2026-04-13 23:32:12 +07:00
zarzet e3b63c1d27 fix: normalize DEFAULT quality to prevent Tidal/Qobuz API failures 2026-04-13 23:32:12 +07:00
zarzet 96301c0dbf 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-04-13 23:32:12 +07:00
zarzet a2458c1292 refactor: extract and improve ReEnrich track selection with scoring-based matching 2026-04-13 23:32:11 +07:00
zarzet 1737e12dd2 fix: add attached_pic disposition to ALAC cover art embedding 2026-04-13 23:32:11 +07:00
zarzet b770d7d9ca 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-04-13 23:32:11 +07:00
zarzet b712b9f509 refactor: route spotify URLs through extensions 2026-04-13 23:32:11 +07:00
zarzet 51496cd34e chore: bump version to 4.1.2+119 2026-04-13 23:32:11 +07:00
zarzet 2b2c2bc90a feat: improve track matching 2026-04-13 23:32:11 +07:00
zarzet e2a489ec92 feat: add haptic feedback when swiping library tabs 2026-04-13 23:32:11 +07:00
zarzet 4f46dd947d 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-04-13 23:32:11 +07:00
zarzet fbb8d30db0 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-04-13 23:32:10 +07:00
Zarz Eleutherius c0637006af Merge pull request #313 from AlexRabbit/main
Extension OAuth + store: flatten action JSON, open auth URLs, spotifl…
2026-04-13 23:30:56 +07:00
Alex 3fc371b8c4 Extension OAuth + store: flatten action JSON, open auth URLs, spotiflac:// callback
Third-party extensions (e.g. Spotify PKCE addons) need three things the current app does not fully provide:

Extension button results – The Go runtime returned { success, result: { message, open_auth_url, … } } while Flutter read message / open_auth_url only on the outer map, so OAuth buttons appeared to do nothing. InvokeAction now merges the extension’s return object onto the top-level JSON (arrays/non-objects still use result).

Flutter – extension_detail_page: unwrap nested result for compatibility, merge setting_updates into saved extension settings (for copyable OAuth URLs), and launchUrl when open_auth_url is set.

Mobile OAuth return – spotiflac://callback?code=…&state=<extension_id> was not handled on Android (manifest + MainActivity) or iOS (AppDelegate open URL + cold-start launchOptions). This wires SetExtensionAuthCodeByID + invokeExtensionAction(..., "completeSpotifyLogin") so PKCE extensions can finish login after the browser redirect.

Extension store HTTP – Add Cache-Control: no-cache on registry and extension package downloads to reduce stale CDN/proxy responses.

Testing: Install a metadata extension that uses PKCE; tap Connect; confirm browser opens, return via spotiflac://callback, and tokens complete without pasting the code manually.

extension InvokeAction JSON was nested under result while the Flutter settings UI only read the top level, so OAuth-related buttons never showed messages or opened the browser. This PR flattens that payload, merges optional setting_updates, launches open_auth_url, adds spotiflac://callback handling on Android and iOS, and sends no-cache on store HTTP fetches. Needed for extensions like SpoitiLists (Spotify Web API + PKCE).
2026-04-12 02:40:31 -06:00
Zarz Eleutherius ee5b3824e9 Merge pull request #297 from mikropsoft/patch-1
Update app_tr.arb
2026-04-09 16:29:33 +07:00
github-actions[bot] be6a856773 chore: update AltStore source to v4.2.2 2026-04-06 07:38:35 +00:00
github-actions[bot] e41c299d49 chore: update AltStore source to v4.2.1 2026-04-04 15:02:40 +00:00
Zarz Eleutherius 981786b4a2 Merge pull request #298 from Amonoman/main
Center images in layout
2026-04-04 19:06:24 +07:00
Amonoman eeb6f11808 Center the images 2026-04-04 13:27:33 +02:00
𝗛𝗼𝗹𝗶 8e361e14b4 Update app_tr.arb 2026-04-04 12:05:33 +03:00
github-actions[bot] d58d46eb1f chore: update AltStore source to v4.2.0 2026-04-04 09:04:47 +00:00
Zarz Eleutherius 562a17f7ae Merge pull request #295 from Amonoman/main
Update logo
2026-04-04 01:24:34 +07:00
Amonoman b035e66540 Update logo 2026-04-03 18:28:16 +02:00
github-actions[bot] 38792a753e chore: update AltStore source to v4.1.3 2026-03-30 11:43:03 +00:00
github-actions[bot] d5b34b4f15 chore: update AltStore source to v4.1.2 2026-03-29 11:31:37 +00:00
github-actions[bot] 2a45c8dcdb chore: update AltStore source to v4.1.1 2026-03-27 15:47:00 +00:00
zarzet e7a2166a4f Merge branch 'dev' 2026-03-27 22:34:15 +07:00
zarzet bd73eb292d chore: bump version to 4.1.1+118 2026-03-27 22:29:16 +07:00
zarzet 8ee2919934 feat: track byte-level download progress for extension downloads
Pass active download item ID through extension download pipeline so
fileDownload can report bytes received/total via ItemProgressWriter.
Add bytesTotal field to DownloadItem model and show X/Y MB progress
in queue tab when total size is known.
2026-03-27 21:58:01 +07:00
zarzet f29177216d refactor: enable strict analysis options and fix type safety across codebase
Enable strict-casts, strict-inference, and strict-raw-types in
analysis_options.yaml. Add custom_lint with riverpod_lint. Fix all
resulting type warnings with explicit type parameters and safer casts.

Also improves APK update checker to detect device ABIs for correct
variant selection and fixes Deezer artist name parsing edge case.
2026-03-27 19:28:42 +07:00
zarzet 18d3612674 fix(ui): skip popular section in artist skeleton for providers without top tracks 2026-03-27 13:27:07 +07:00
zarzet f7c0e417d7 refactor: unexport extension store types and methods (package-internal only) 2026-03-27 04:50:40 +07:00
zarzet 3fd13e9930 fix(ui): match GridSkeleton cover height with actual album cards 2026-03-27 04:39:29 +07:00
zarzet 0b20cb895e fix: conditionally show cover header in artist skeleton and add showCoverHeader param to ArtistScreenSkeleton 2026-03-27 04:35:22 +07:00
zarzet 8979210804 fix: null check crash in SpectrogramView when spectrum loaded from PNG cache 2026-03-27 04:24:19 +07:00
zarzet e9b24712c5 feat: cache spectrogram as PNG for instant loading on subsequent views 2026-03-27 04:21:11 +07:00
zarzet 3d6e5615fa Revert "docs: move badges below screenshots in README"
This reverts commit 198ed5ce6f.
2026-03-27 03:56:57 +07:00
zarzet fc7220b572 docs: update VirusTotal hash for v4.1.0 2026-03-27 03:54:31 +07:00
zarzet 198ed5ce6f docs: move badges below screenshots in README 2026-03-27 03:53:31 +07:00
zarzet b48462a945 fix: add artist_album_flat case to SAF relative output dir builder 2026-03-26 18:31:00 +07:00
zarzet 0f327cd1f6 feat: add flat singles folder option (Artist/song.flac without Singles subfolder) 2026-03-26 18:15:37 +07:00
github-actions[bot] f54597e655 chore: update AltStore source to v4.1.0 2026-03-26 10:47:40 +00:00
zarzet 4f2e677e8b fix: improve skeleton visibility and artist header for light mode 2026-03-26 17:32:54 +07:00
zarzet 79a69f8f70 chore: clean up codebase 2026-03-26 16:43:56 +07:00
zarzet bf0f4bdf3e fix: store URL input flash on startup and FLAC metadata fallback for mismatched files
Load saved registry URL before first state update to prevent brief
flash of the setup screen when the store tab initializes.

Add Ogg/Opus fallback in readFileMetadata when FLAC parsing fails,
handling files saved with .flac extension that contain opus data.
2026-03-26 16:26:14 +07:00
zarzet 5e1cc3ecb5 refactor: extract YouTube download to ytmusic extension and fix UI issues
Remove built-in YouTube/Cobalt download pipeline from Go backend and
Dart frontend. YouTube downloading now requires the ytmusic-spotiflac
extension (with download_provider capability).

Go backend:
- Delete youtube.go (745 lines) and youtube_quality_test.go
- Remove DownloadFromYouTube, IsYouTubeURLExport,
  ExtractYouTubeVideoIDExport from exports.go
- Remove YouTube routing from DownloadTrack and DownloadByStrategy

Dart frontend:
- Remove YouTube from built-in services, bitrate settings, quality UI
- Remove youtubeOpusBitrate/youtubeMp3Bitrate from settings model
- Add migration 7: default service youtube -> tidal
- Remove YouTube l10n keys from all 14 arb files and regenerate
- Update _determineOutputExt to handle opus_/mp3_ quality strings
- Add SAF opus/mp3 metadata embedding in unified branch
- Fix TweenSequence assertion crash (t outside 0.0-1.0)
- Fix store URL TextField styling consistency

Extension changes (gitignored, in extension/YT-Music-SpotiFLAC/):
- Add download_provider type, qualityOptions, network permissions
- Implement checkAvailability and download via SpotubeDL/Cobalt
2026-03-26 16:17:57 +07:00
zarzet d4b37edc2f feat: add animation utilities and fix regressions in UI refactor
- Add animation_utils.dart with skeleton loaders, staggered list animations,
  animated checkboxes, badge bump, download success overlay, and shared
  page route helper
- Replace CircularProgressIndicator with shimmer skeleton loaders across
  album, artist, playlist, search, store, and extension screens
- Unify page transitions via slidePageRoute (MaterialPageRoute) for
  Android predictive back gesture support
- Extract AnimatedSelectionCheckbox with configurable unselectedColor
  to preserve original transparent/opaque backgrounds per context
- Add swipe-to-dismiss on download queue items with confirmDismiss
  dialog for active downloads to prevent accidental cancellation
- Add Hero animations for cover art transitions between list and detail
- Add AnimatedBadge bump on navigation bar badge count changes
- Add DownloadSuccessOverlay green flash on download completion
- Restore fine-grained ref.watch(.select()) in _CollectionTrackTile
  to avoid full list rebuilds on download history changes
- Fix DownloadSuccessOverlay re-flashing on widget recreation by
  initialising _wasSuccess from initial widget state
- Remove orphan Hero tag in search_screen that had no matching pair
- Chip borderRadius updated from 8 to 20 for consistency
2026-03-26 13:38:07 +07:00
zarzet 9483614bc7 feat: cache audio analysis results and fix total samples metric 2026-03-26 02:17:18 +07:00
zarzet a73f2e1a13 feat: auto-select recommended download service based on content source 2026-03-26 01:44:11 +07:00
zarzet 091e3fadd9 feat: add audio quality analysis widget and fix USLT lyrics detection 2026-03-26 01:11:29 +07:00
zarzet 5340ca7b16 chore: bump version to 4.1.0+117 2026-03-25 23:23:14 +07:00
zarzet 85d3e58a26 fix: hi-res cover art for Tidal/Qobuz and album metadata override 2026-03-25 23:17:45 +07:00
zarzet 1125c757fe fix: remove unintended home reset on tab switch 2026-03-25 22:33:04 +07:00
zarzet 66d714d368 fix: unify search bar, filter chips, tab navigation, and clean up comments 2026-03-25 22:27:22 +07:00
zarzet 49c2501fbc refactor: use pointer returns and unified forceRefresh in ExtensionStore 2026-03-25 21:47:31 +07:00
zarzet e487817f21 feat: add sorting options for search results 2026-03-25 21:40:36 +07:00
zarzet d8bbeb1e67 perf: use Tidal/Qobuz metadata for Deezer track resolution 2026-03-25 21:18:47 +07:00
zarzet 9693616645 fix: route tidal/qobuz items from Recent Access to built-in screens instead of extension screens 2026-03-25 20:50:33 +07:00
zarzet 0423e36d34 chore: bump version to 3.9.1+116 2026-03-25 20:08:53 +07:00
zarzet c8d605fdee fix: add ValueListenableBuilder for embedded cover refresh and localize hardcoded queue strings 2026-03-25 20:05:24 +07:00
zarzet 03fd734048 perf: lazy extension VM init, incremental startup maintenance, and UI optimizations
- Defer extension VM initialization until first use with lockReadyVM() pattern to eliminate TOCTOU races and reduce startup overhead
- Add validateExtensionLoad() to catch JS errors at install time without keeping VM alive
- Teardown VM on extension disable to free resources; re-init lazily on re-enable
- Replace full orphan cleanup with incremental cursor-based pagination across launches
- Batch DB writes (upsertBatch, replaceAll) with transactions for atomicity
- Parse JSON natively on Kotlin side to avoid double-serialization over MethodChannel
- Add identity-based memoization caches for unified items and path match keys in queue tab
- Use ValueListenableBuilder for targeted embedded cover refreshes instead of full setState
- Extract shared widgets (_buildAlbumGridItemCore, _buildFilterButton, _navigateWithUnfocus)
- Use libraryCollectionsProvider selector and MediaQuery.paddingOf for fewer rebuilds
- Simplify supporter chip tiers and localize remaining hardcoded strings
2026-03-25 19:55:02 +07:00
zarzet da9d64ccfd chore: update VirusTotal hash in README 2026-03-25 17:15:36 +07:00
zarzet 02e64b7a3c Merge remote-tracking branch 'origin/main' 2026-03-25 17:12:06 +07:00
zarzet a435009d4d fix(qobuz): skip SongLink when ISRC is already available 2026-03-25 17:09:54 +07:00
github-actions[bot] 9ca73a99a6 chore: update AltStore source to v3.9.0 2026-03-25 09:29:51 +00:00
zarzet 4974284760 fix(l10n): consolidate Crowdin locale files and fix ICU plural warnings
- Replace app_es-ES.arb, app_pt-PT.arb, app_tr-TR.arb (hyphen format)
  with properly named app_es_ES.arb, app_pt_PT.arb, app_tr.arb
- Fix @@locale values to match Flutter filename convention (underscore)
- Fix ICU plural syntax: remove redundant 'one {}' before '=1{...}'
  in es_ES, pt_PT, tr translations
- Regenerate l10n output files
2026-03-25 16:12:37 +07:00
Zarz Eleutherius a0306bd345 Merge pull request #258 from zarzet/l10n_dev
New Crowdin updates
2026-03-25 16:08:16 +07:00
zarzet ea7e594c68 Merge remote-tracking branch 'origin/dev' into l10n_dev
# Conflicts:
#	lib/l10n/arb/app_es-ES.arb
#	lib/l10n/arb/app_id.arb
#	lib/l10n/arb/app_pt-PT.arb
#	lib/l10n/arb/app_tr-TR.arb
2026-03-25 16:08:10 +07:00
Zarz Eleutherius d00a84f1b9 New translations app_en.arb (Indonesian) 2026-03-25 16:02:56 +07:00
Zarz Eleutherius 58b6203681 New translations app_en.arb (Chinese Simplified) 2026-03-25 16:02:54 +07:00
Zarz Eleutherius d299144c47 New translations app_en.arb (Russian) 2026-03-25 16:02:53 +07:00
Zarz Eleutherius 40b224e5a1 New translations app_en.arb (Dutch) 2026-03-25 16:02:51 +07:00
Zarz Eleutherius 7021e5493f New translations app_en.arb (Japanese) 2026-03-25 16:02:49 +07:00
Zarz Eleutherius 68bbc8a259 New translations app_en.arb (German) 2026-03-25 16:02:47 +07:00
zarzet be94a59441 chore: bump version to 3.9.0+115, add new translators
- Bump app version from 3.8.8 to 3.9.0 (build 115)
- Add 4 new Crowdin translators: unkn0wn (Indonesian), lunching1272
  (Chinese Simplified), Сергей Ильченко (Russian), Girl-lass (Chinese
  Simplified)
2026-03-25 15:47:08 +07:00
zarzet 3a73aee1b7 feat: add home feed provider setting, fix Qobuz cover URL propagation
- Add homeFeedProvider field to AppSettings with picker UI in extensions page
- Update explore_provider to respect user's home feed provider preference
- Add normalizeCoverReference() and normalizeRemoteHttpUrl() to filter
  invalid cover URLs (no scheme, no host, protocol-relative)
- Apply cover URL normalization across all screens and providers to
  prevent 'no host specified in URI' errors from Qobuz
- Propagate CoverURL from QobuzDownloadResult through Go backend so
  cover art is available even when request metadata is incomplete
2026-03-25 15:46:22 +07:00
zarzet c91154ea3e feat: add built-in search provider in settings, fix bottom sheet overflow 2026-03-25 15:46:12 +07:00
zarzet 4f365ca7fe feat: add built-in Tidal/Qobuz search with recommended service picker
- Add SearchAll() for Tidal and Qobuz in Go backend (tracks, artists, albums)
- Add searchTidalAll/searchQobuzAll platform routing for Android and iOS
- Add Tidal/Qobuz options to search provider dropdown in home tab
- Show (Recommended) label and auto-select service in download picker
2026-03-25 13:52:57 +07:00
zarzet 98fdc0ed7c feat: restore Tidal HIGH (AAC 320kbps) lossy quality option (closes #242)
Requested by @okinaau in issue #242 — brings back the ability to
download tracks in lossy format for users on low storage devices.

HIGH quality fetches the AAC M4A stream directly from the Tidal server
(no lossless download + re-encode), then converts to MP3 or Opus via
FFmpeg based on the tidalHighFormat setting (mp3_320, opus_256, or
opus_128).

- go_backend/tidal.go: restore outputExt .m4a, filename logic,
  duplicate-check guard, HIGH M4A lyrics/LRC handling, and
  bitDepth=0/sampleRate=44100 for HIGH quality result
- settings.dart + settings.g.dart: re-add tidalHighFormat field
  (default mp3_320) with JSON serialization
- settings_provider.dart: re-add setTidalHighFormat(), remove
  migration that force-migrated HIGH to LOSSLESS
- download_queue_provider.dart: restore HIGH conversion logic for
  both SAF and non-SAF paths using FFmpegService.convertM4aToLossy
- download_settings_page.dart: restore Lossy 320kbps quality tile,
  format sub-picker tile, _getTidalHighFormatLabel helper, and
  _showTidalHighFormatPicker bottom sheet
- l10n: add 10 keys (downloadLossy320, downloadLossyFormat,
  downloadLossy320Format, downloadLossy320FormatDesc, downloadLossyMp3,
  downloadLossyMp3Subtitle, downloadLossyOpus256/Subtitle,
  downloadLossyOpus128/Subtitle) to ARB and all 13 generated files
2026-03-22 23:33:32 +07:00
zarzet 12be560cb8 feat: add M4A metadata/cover embed support across all Flutter screens
Add FFmpegService.embedMetadataToM4a() for writing tags and cover art
into M4A files via FFmpeg. Fix two bugs in the same function:
- Remove '-disposition:v:0 attached_pic' which is only valid for
  Matroska/WebM containers and causes FFmpeg to error on MP4/M4A
- Apply same fix to _convertToAlac which had the identical issue

Add M4A handling (isM4A branch) to all four embed call-sites:
track_metadata_screen (lyrics embed, re-enrich, edit metadata sheet,
format conversion), queue_tab, local_album_screen, and
downloaded_album_screen.

Add 'LYRICS'/'UNSYNCEDLYRICS' to _mapMetadataForTagEmbed so existing
lyrics survive a re-enrich cycle on M4A/MP3/Opus files.

Preserve existing lyrics before overwriting tags in the edit metadata
sheet (best-effort readFileMetadata before FFmpeg pass).

Extract mergePlatformMetadataForTagEmbed() into lyrics_metadata_helper
to deduplicate the identical metadata-mapping loops that existed in
queue_tab, local_album_screen, downloaded_album_screen, and
track_metadata_screen.

Wire ensureLyricsMetadataForConversion into the format conversion path
in track_metadata_screen so lyrics are carried through conversions.

Add ISRC and LABEL/ORGANIZATION mappings to _convertToM4aTags.
2026-03-22 23:01:32 +07:00
zarzet 4cf885a52e feat: populate M4A metadata in ReadFileMetadata and library scan
ReadFileMetadata now fills all tag fields (title, artist, album, ISRC,
lyrics, genre, label, copyright, composer, comment, track/disc number)
for M4A files using the new ReadM4ATags helper, matching the existing
behavior for FLAC, MP3, and Ogg.

scanM4AFile reads tags via ReadM4ATags instead of falling back to the
filename, and applies applyDefaultLibraryMetadata for missing fields
(consistent with FLAC/MP3 scan path).

Remove the '&& ext != ".m4a"' guard in cover cache so M4A cover art
is extracted and cached during library scans.
2026-03-22 23:00:55 +07:00
zarzet c57c8a4267 feat: implement full M4A tag read engine with atom path fallback and freeform fix
Add ReadM4ATags() that parses all standard iTunes atoms (title, artist,
album, album artist, date, genre, composer, comment, copyright, lyrics,
track/disc number) and freeform '----' atoms (ISRC, label, lyrics).

Fix two pre-existing bugs in the M4A atom traversal:
- findM4AIlstAtom: now tries moov>udta>meta>ilst first, then falls back
  to moov>meta>ilst so files from Tidal/Qobuz/Apple Music are handled
- readM4AFreeformValue: 'name' atom payload is raw UTF-8 after 4-byte
  flags, not a nested 'data' atom; fix reads it directly so ISRC/label
  freeform tags are no longer silently dropped

Refactor extractLyricsFromM4A and extractCoverFromM4A to reuse the new
helpers (findM4AIlstAtom, readM4ADataAtomPayload) instead of duplicating
the atom traversal logic. Add extractAnyCoverArtWithHint M4A case that
previously returned a hardcoded 'not yet supported' error.
2026-03-22 23:00:42 +07:00
Zarz Eleutherius 2ca6c737c0 Update README 2026-03-22 22:46:03 +07:00
Zarz Eleutherius 2a451ec2a3 Merge pull request #252 from ShuShuzinhuu/main
docs: Add SpotiFLAC Python Module to Other Projects section
2026-03-22 22:44:56 +07:00
Zarz Eleutherius 346e79b247 Merge pull request #254 from Amonoman/main
Improve README structure and readability
2026-03-22 22:44:40 +07:00
zarzet 497ba342c0 feat: add createPlaylistFolder setting for playlist source folder prefix
When enabled, playlist downloads are placed inside a subfolder named
after the playlist before the normal folder organization structure
(e.g. Playlist/<artist>/<album>/). The setting is a no-op when folder
organization is already set to 'By Playlist'. Includes model field,
JSON serialization, settings notifier, download queue path logic,
UI toggle in download settings, and localizations for all 13 languages.
2026-03-22 22:43:03 +07:00
zarzet aca0bbb819 chore: remove security_hardening_test.go
Tests for sanitizeSensitiveLogText, validateExtensionAuthURL,
validateDomain, and buildStoreExtensionDestPath are no longer
maintained alongside the main source and have been removed.
2026-03-22 22:42:50 +07:00
zarzet 2df8fd6282 feat: add normalizeLooseArtistName with diacritic folding for resilient artist matching
Use Unicode NFD decomposition to strip combining marks so variants like
"Özkent" and "Ozkent" are treated as equivalent. Apply the new helper
in both tidal.go and qobuz.go artistsMatch functions.
2026-03-22 22:42:33 +07:00
Amonoman 999317eba1 Update README 2026-03-20 16:14:03 +01:00
Shu 16991476ed Add SpotiFLAC Python Module section to README
Added a section for the SpotiFLAC Python Module with a link and maintainer information.
2026-03-20 09:22:45 -04:00
github-actions[bot] ba33639818 chore: update AltStore source to v3.8.8 2026-03-18 11:33:08 +00:00
zarzet 23cab16471 feat: enable Tidal ISRC and metadata search 2026-03-18 18:14:01 +07:00
zarzet 0a892011de refactor: migrate lyrics providers to Paxsenix endpoints 2026-03-18 17:11:17 +07:00
zarzet acb1d957d3 feat: add McNuggets Jimmy as supporter 2026-03-18 17:10:44 +07:00
zarzet 4a492aeefc chore: bump version to 3.8.8+114 2026-03-18 01:23:55 +07:00
zarzet eb143a41fc refactor: remove redundant comments and fix setMetadataSource bug
- Fix setMetadataSource always returning 'deezer' regardless of input parameter
- Remove self-evident doc comments that restate method/class names across
  app_theme, dynamic_color_wrapper, cover_cache_manager, history_database,
  library_database, and download_service_picker
- Remove stale migration inline notes (// 12 -> 16, // 20 -> 16, etc.) from app_theme
- Remove trivial section-label comments in queue_tab batch conversion method
- Remove duplicate 'wait up to 5 seconds' comment in main_shell
2026-03-18 01:12:16 +07:00
zarzet 75db2f162b fix: improve extension download reliability and Qobuz API integration
- Add dedicated long-timeout download client (24h) for extension file downloads,
  preventing timeouts on large lossless audio files
- Skip unnecessary SongLink Deezer prelookup when an extension download provider
  handles the track, reducing latency and avoiding spurious API failures
- Prefer native track ID over Spotify ID when a source/provider is set, ensuring
  extension providers receive their own IDs correctly
- Update Qobuz MusicDL API endpoint and switch payload URL to open.qobuz.com
- Extract buildQobuzMusicDLPayload helper and add test coverage
2026-03-18 01:06:22 +07:00
zarzet 855d0e3ffc feat: add zcc09 as supporter (thank you) 2026-03-18 00:19:36 +07:00
zarzet 5ccd06cc68 fix: stabilize library scan IDs, pause queue behavior, and scan race condition
- Generate stable SHA-1 based IDs for SAF-scanned library items to prevent null ID crashes on the Dart side
- Suppress false queue-complete notification when user pauses instead of finishing the queue, and break out of parallel loop immediately when paused with no active downloads
- Use SQLite as the single source of truth for library scan results to fix a race condition where auto-scan could fire before provider state finished loading, dropping unchanged rows
2026-03-17 23:54:49 +07:00
github-actions[bot] b2873378fc chore: update AltStore source to v3.8.7 2026-03-17 08:41:17 +00:00
zarzet 66a89d9e8e fix: properly stop active downloads when pausing the queue 2026-03-17 15:26:51 +07:00
zarzet 814deca19d fix: hide queue-as-FLAC button when all selected tracks are already FLAC 2026-03-17 15:19:46 +07:00
zarzet 3bb6754d9c Merge branch 'main' into dev
# Conflicts:
#	lib/constants/app_info.dart
#	lib/main.dart
#	lib/screens/local_album_screen.dart
#	lib/screens/queue_tab.dart
#	lib/screens/settings/donate_page.dart
#	lib/services/local_track_redownload_service.dart
#	pubspec.yaml
2026-03-17 15:10:04 +07:00
zarzet 7d11d67cd2 chore: bump version to 3.8.7+113 2026-03-17 15:07:05 +07:00
zarzet c0bd10cfca fix: skip already-downloaded tracks in library folder download-all 2026-03-17 15:04:45 +07:00
zarzet e003b15ffd fix: skip tracks already in FLAC from queue-as-FLAC selection and fix local album track list widget identity 2026-03-17 15:02:19 +07:00
zarzet ac1c7d31c9 fix: improve Spotify track availability resolution 2026-03-17 14:45:24 +07:00
zarzet 6fc9ffeb23 fix: upgrade Deezer and Tidal cover art to max quality on Dart side
The Dart-side _upgradeToMaxQualityCover only handled Spotify CDN
URLs, causing Deezer covers to stay at 1000x1000 and Tidal at
1280x1280. Add regex-based Deezer upgrade (1800x1800) and Tidal
origin resolution upgrade to match the Go backend logic.

Closes #237
2026-03-16 22:46:45 +07:00
zarzet 9bebed506b fix: honor local library auto-scan cooldown 2026-03-16 22:35:17 +07:00
github-actions[bot] bffeb55a7a chore: update AltStore source to v3.8.6 2026-03-16 14:10:04 +00:00
zarzet c66d13c9fd bump version to 3.8.6+112 2026-03-16 21:02:16 +07:00
github-actions[bot] 8529985a0e chore: update AltStore source to v3.8.6 2026-03-16 13:54:09 +00:00
zarzet a8a3973225 fix: prevent re-download of tracks converted to a different format
When a file is converted externally (e.g. FLAC to OPUS), the
orphan cleanup would delete the history entry because the original
path no longer exists. Now it checks for sibling files with other
audio extensions and updates the stored path instead of deleting.

Also add extension-stripped keys to path_match_keys so that
paths differing only by audio extension still match during local
library scan exclusion and queue deduplication.
2026-03-16 20:38:51 +07:00
zarzet 6710f90e1e feat: add auto-scan option for local library
Add a new 'Auto Scan' setting under Local Library with four modes:
off, every app open (10min cooldown), daily, and weekly. The app
uses WidgetsBindingObserver to trigger incremental scans on launch
and when resuming from background, respecting the configured
cooldown based on the last scan timestamp.
2026-03-16 20:35:59 +07:00
zarzet 929c5f3249 fix: remove double horizontal padding in store tab extension list
The extension list was wrapped in an extra Padding(horizontal: 16)
on top of SettingsGroup's default 16px margin, resulting in 32px
total inset. Remove the outer wrapper to match settings tab width.
2026-03-16 20:35:59 +07:00
zarzet f170ead7b9 docs: add contributors section to README
Add auto-generated contributor avatars via contrib.rocks with a
link to the GitHub contributors page. Include acknowledgement for
translators and bug reporters.
2026-03-16 20:35:59 +07:00
zarzet e63e366228 feat: add mc nuggets jimmy, CJBGR and michahRicie as supporters
Add new supporters to the donate page. michahRicie is highlighted
as a gold supporter.
2026-03-16 20:35:59 +07:00
zarzet 95e755e54e fix: delay iOS folder picker after sheet dismiss and update Afkar hosts 2026-03-16 20:35:59 +07:00
zarzet c719406425 docs: update readme 2026-03-16 20:35:59 +07:00
zarzet 9627ef66cf fix: verify resolved Tidal/Deezer tracks match the download request before downloading
SongLink can return incorrect track IDs (e.g. a different track from the
same album). Qobuz already had verification via qobuzTrackMatchesRequest.
This adds equivalent verification for Tidal and Deezer using a shared
trackMatchesRequest() helper in title_match_utils.go that checks artist,
title, and duration. Mismatched SongLink/ISRC results are now rejected
so the wrong audio is never embedded with Spotify metadata.
2026-03-16 20:35:59 +07:00
zarzet 15f977d98d fix: skip already-downloaded tracks in Download All for albums and playlists
Album and playlist Download All buttons now check download history and local
library before enqueuing, matching the existing behavior in artist discography
and CSV import. Tracks already in library are skipped with a summary snackbar.
2026-03-16 20:35:59 +07:00
zarzet 5b5f043624 docs: add extension store URL setup guide to README 2026-03-16 20:35:59 +07:00
zarzet 529a920b24 bump version to 3.8.5+111 2026-03-16 20:35:59 +07:00
zarzet 09eb6cf206 fix: use album-level artist for Various Artists albums instead of first track's artist
- Extension: fix extractSchemaOrg to find album-level schema (with numTracks) instead of per-track schema
- Extension: add secondaryText2 fallback in parseDescriptiveRows for VA album track artists
- Extension: use headerPrimaryText as primary album artist source, overriding schema.org
- App: album_screen now uses widget.artistName (album-level) instead of tracks.first.artistName
- App: home_tab _parseTrack now populates albumArtist from track data or album-level artist
- Bump Amazon extension to v2.0.1
2026-03-16 20:35:58 +07:00
zarzet af6fa6ea53 fix: extract cover art from M4A/ALAC files for conversion
Add extractCoverFromM4A() that reads the covr atom from the MP4
box tree (moov/udta/meta/ilst/covr/data). Wire it into
ExtractCoverToFile so ALAC-to-FLAC conversion preserves cover art.
2026-03-16 20:35:58 +07:00
zarzet 280b921755 fix: detect embedded lyrics in M4A/ALAC files
Add extractLyricsFromM4A() that walks the MP4 box tree
(moov/udta/meta/ilst/©lyr) to read lyrics. Wire it into
ExtractLyrics so the Embed Lyrics button is hidden when
lyrics already exist in the file.
2026-03-16 20:35:58 +07:00
zarzet 6ebe0c51ce fix: filter batch convert target formats based on source formats
Exclude same-format and lossy-to-lossless targets from the batch
convert sheet so users cannot pick pointless conversions like
FLAC→FLAC. Also clean up redundant inline comments.
2026-03-16 20:35:58 +07:00
zarzet 47bd24c1bd fix: preserve metadata and cover art in ALAC/M4A to FLAC conversion
- Use -map_metadata 0 instead of -map_metadata -1 so FFmpeg copies and
  auto-remaps source tags (M4A/ID3 → Vorbis comments) as a base
- Add _normalizeToVorbisComments() to filter technical fields (BIT_DEPTH,
  SAMPLE_RATE, DURATION) and normalize key variations to standard Vorbis
  comment names before applying overrides
- Switch cover art embedding from METADATA_BLOCK_PICTURE base64 (unreliable
  on Android due to command-line length limits) to -i cover -map 1:v
  -disposition attached_pic (same proven approach as embedMetadata and
  _convertToAlac)
- Drop zero-value track/disc numbers from override map to prevent
  clobbering source metadata with '0' from Go readFileMetadata
2026-03-16 20:35:58 +07:00
zarzet 2b23678c0d feat: add FLAC/ALAC bidirectional lossless conversion support
- Add _convertToAlac() and _convertToFlac() in ffmpeg_service with
  single-pass FFmpeg encoding, metadata tags, and cover art embedding
- Wire lossless formats (ALAC, FLAC) into single-track convert sheet
  with dynamic format list based on source format, hidden bitrate for
  lossless targets, and lossless hint text
- Add lossless conversion to batch convert UI in downloaded_album,
  local_album, and queue_tab screens with lossy-source filtering
- Fix M4A quality probe in Go backend: increase audio sample entry
  buffer from 24 to 32 bytes, read sample rate from correct offset
  (bytes 28-29) and bit depth from samplesize field (bytes 22-23)
- Add l10n keys for lossless confirm dialogs and hints (en, id)
2026-03-16 20:35:58 +07:00
zarzet e8327545ad feat: improve auto-fill track resolution in Edit Metadata sheet
- Identifier-first resolution (ISRC/Deezer/Spotify) before falling back to text search
- Score-based match selection via _metadataMatchScore instead of provider order
- Pass sourceTrackId from TrackMetadataScreen into _EditMetadataSheet
- Refactor buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult as testable helpers
- Add unit tests for buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult
- Propagate copyright through Deezer enrichment chain (exports, extension_providers)
2026-03-16 20:35:58 +07:00
zarzet 89a38af538 fix: resolve all flutter analyze warnings and improve auto-fill enrichment chain
- Fix use_build_context_synchronously in _embedLyrics by capturing l10n
  strings before async gaps (snackbarFailedToWriteStorage,
  snackbarFailedToEmbedLyrics, snackbarUnsupportedAudioFormat)
- Improve auto-fill metadata enrichment to use proper API chain:
  search providers -> convertSpotifyToDeezer (SongLink) for Deezer ID
  -> getDeezerMetadata for ISRC -> getDeezerExtendedMetadata for
  genre/label/copyright. Falls back to ISRC-based Deezer lookup when
  SongLink conversion unavailable.
- flutter analyze now reports 0 issues
2026-03-16 20:35:58 +07:00
zarzet b7f34ec47c feat: selective auto-fill from online in Edit Metadata sheet
Add 'Auto-fill from online' expandable section to the metadata editor
that lets users choose exactly which fields to populate from online
metadata search. Users can select individual fields via filter chips,
use 'All' or 'Empty only' quick-select buttons, then tap 'Fetch & Fill'
to search metadata providers and fill only the selected controllers.

The search uses existing searchTracksWithMetadataProviders API with
ISRC-preferring best-match selection. Extended metadata (genre, label,
copyright) is fetched via Deezer extended metadata API when available.
Cover art is downloaded from the match's cover_url. All results are
previewed in the editor before saving — nothing is written to the file
until the user taps Save.

Add 21 new l10n keys (editMetadata* namespace) for all UI strings.
2026-03-16 20:35:58 +07:00
zarzet 967523bfc6 feat: queue FLAC redownloads for local library tracks
Add LocalTrackRedownloadService with confidence-scored metadata matching
(ISRC, title, artist, album, duration, track/disc number, year) to find
reliable online matches for locally-stored tracks.

Wire up 'Queue FLAC' selection action in both local_album_screen and
queue_tab (library tab). Shows progress snackbar during resolution,
skips ambiguous or low-confidence matches, and reports results.

Add Indonesian (id) translations for all queueFlac l10n keys.
2026-03-16 20:35:58 +07:00
zarzet 29d8a185f9 fix: handle nested legacy iOS Documents path in validation
Detect and recover from stale sandbox container paths embedded inside
the current Documents directory. Extracts helper functions for path
suffix normalization and joining to reduce duplication.
2026-03-16 20:35:57 +07:00
zarzet 4495d4bf4e feat: add Opus 320kbps quality, remove Tidal HIGH tier
- Add YouTubeQualityOpus320 constant and opus_320 parser case in Go backend
- Expand opus supported bitrates to [128, 256, 320] across Go, Dart settings, and UI
- Update default YouTube Opus option from 256 to 320kbps
- Remove Tidal HIGH (lossy 320kbps) quality from Go backend, settings model,
  settings provider, download queue provider (both SAF and non-SAF paths),
  settings UI (quality option, format picker, helper methods), and l10n keys
- Add settings migration v6: auto-migrate users with audioQuality=HIGH to LOSSLESS
- Update and add Go test cases for opus_320 and adjusted max bitrate
- Regenerate l10n files, remove 10 unused downloadLossy* l10n keys
2026-03-16 20:35:57 +07:00
zarzet 67737467e0 ci: auto-update AltStore source (apps.json) on release 2026-03-16 20:35:57 +07:00
renovate[bot] 13845eea04 chore(deps): update dependency flutter to v3.41.4 2026-03-16 20:35:57 +07:00
zarzet 12779778d3 fix(i18n): localize hardcoded strings in bulk playlist download and fix trailing newlines 2026-03-16 20:35:57 +07:00
ViscousPot d4178ad036 feat: add option to download multiple selected playlists 2026-03-16 20:35:57 +07:00
ViscousPot 49ea84384d feat: auto fill playlist name during import 2026-03-16 20:35:57 +07:00
ViscousPot a6d9849468 Update CONTRIBUTING.md 2026-03-16 20:35:57 +07:00
ViscousPot 16100aa0fd add fvm 2026-03-16 20:35:57 +07:00
zarzet 387dd47374 feat: add Qobuz Afkar API provider and prefer request metadata for consistent album grouping 2026-03-16 20:35:57 +07:00
zarzet 6ecb69feae fix: prevent re-download of tracks converted to a different format
When a file is converted externally (e.g. FLAC to OPUS), the
orphan cleanup would delete the history entry because the original
path no longer exists. Now it checks for sibling files with other
audio extensions and updates the stored path instead of deleting.

Also add extension-stripped keys to path_match_keys so that
paths differing only by audio extension still match during local
library scan exclusion and queue deduplication.
2026-03-16 20:28:53 +07:00
zarzet feff985439 feat: add auto-scan option for local library
Add a new 'Auto Scan' setting under Local Library with four modes:
off, every app open (10min cooldown), daily, and weekly. The app
uses WidgetsBindingObserver to trigger incremental scans on launch
and when resuming from background, respecting the configured
cooldown based on the last scan timestamp.
2026-03-16 20:28:45 +07:00
zarzet 2e8fe34824 fix: remove double horizontal padding in store tab extension list
The extension list was wrapped in an extra Padding(horizontal: 16)
on top of SettingsGroup's default 16px margin, resulting in 32px
total inset. Remove the outer wrapper to match settings tab width.
2026-03-16 20:28:37 +07:00
zarzet f58005f406 docs: add contributors section to README
Add auto-generated contributor avatars via contrib.rocks with a
link to the GitHub contributors page. Include acknowledgement for
translators and bug reporters.
2026-03-16 20:28:31 +07:00
zarzet 75abc03a4f feat: add mc nuggets jimmy, CJBGR and michahRicie as supporters
Add new supporters to the donate page. michahRicie is highlighted
as a gold supporter.
2026-03-16 20:28:25 +07:00
zarzet 84381d142a fix: delay iOS folder picker after sheet dismiss and update Afkar hosts 2026-03-16 20:17:37 +07:00
github-actions[bot] f67f52eba9 chore: update AltStore source to v3.8.5 2026-03-15 21:35:25 +00:00
zarzet 3747ffff64 docs: update readme 2026-03-16 04:26:35 +07:00
zarzet ed47efed17 fix: verify resolved Tidal/Deezer tracks match the download request before downloading
SongLink can return incorrect track IDs (e.g. a different track from the
same album). Qobuz already had verification via qobuzTrackMatchesRequest.
This adds equivalent verification for Tidal and Deezer using a shared
trackMatchesRequest() helper in title_match_utils.go that checks artist,
title, and duration. Mismatched SongLink/ISRC results are now rejected
so the wrong audio is never embedded with Spotify metadata.
2026-03-16 04:16:44 +07:00
zarzet c0d72e89d7 fix: skip already-downloaded tracks in Download All for albums and playlists
Album and playlist Download All buttons now check download history and local
library before enqueuing, matching the existing behavior in artist discography
and CSV import. Tracks already in library are skipped with a summary snackbar.
2026-03-16 04:16:44 +07:00
zarzet a4313cfe0f docs: add extension store URL setup guide to README 2026-03-16 04:16:44 +07:00
zarzet c7bef03ee3 bump version to 3.8.5+111 2026-03-16 04:16:44 +07:00
zarzet ce5a9e0cff fix: use album-level artist for Various Artists albums instead of first track's artist
- Extension: fix extractSchemaOrg to find album-level schema (with numTracks) instead of per-track schema
- Extension: add secondaryText2 fallback in parseDescriptiveRows for VA album track artists
- Extension: use headerPrimaryText as primary album artist source, overriding schema.org
- App: album_screen now uses widget.artistName (album-level) instead of tracks.first.artistName
- App: home_tab _parseTrack now populates albumArtist from track data or album-level artist
- Bump Amazon extension to v2.0.1
2026-03-16 04:16:39 +07:00
zarzet 859b823e77 fix: extract cover art from M4A/ALAC files for conversion
Add extractCoverFromM4A() that reads the covr atom from the MP4
box tree (moov/udta/meta/ilst/covr/data). Wire it into
ExtractCoverToFile so ALAC-to-FLAC conversion preserves cover art.
2026-03-16 02:49:48 +07:00
zarzet 7d8cf5f7ca fix: detect embedded lyrics in M4A/ALAC files
Add extractLyricsFromM4A() that walks the MP4 box tree
(moov/udta/meta/ilst/©lyr) to read lyrics. Wire it into
ExtractLyrics so the Embed Lyrics button is hidden when
lyrics already exist in the file.
2026-03-16 02:43:13 +07:00
zarzet 4adaed8da0 fix: filter batch convert target formats based on source formats
Exclude same-format and lossy-to-lossless targets from the batch
convert sheet so users cannot pick pointless conversions like
FLAC→FLAC. Also clean up redundant inline comments.
2026-03-16 02:39:11 +07:00
zarzet 554fe08fcd fix: preserve metadata and cover art in ALAC/M4A to FLAC conversion
- Use -map_metadata 0 instead of -map_metadata -1 so FFmpeg copies and
  auto-remaps source tags (M4A/ID3 → Vorbis comments) as a base
- Add _normalizeToVorbisComments() to filter technical fields (BIT_DEPTH,
  SAMPLE_RATE, DURATION) and normalize key variations to standard Vorbis
  comment names before applying overrides
- Switch cover art embedding from METADATA_BLOCK_PICTURE base64 (unreliable
  on Android due to command-line length limits) to -i cover -map 1:v
  -disposition attached_pic (same proven approach as embedMetadata and
  _convertToAlac)
- Drop zero-value track/disc numbers from override map to prevent
  clobbering source metadata with '0' from Go readFileMetadata
2026-03-16 02:26:53 +07:00
zarzet b8af75bf6e feat: add FLAC/ALAC bidirectional lossless conversion support
- Add _convertToAlac() and _convertToFlac() in ffmpeg_service with
  single-pass FFmpeg encoding, metadata tags, and cover art embedding
- Wire lossless formats (ALAC, FLAC) into single-track convert sheet
  with dynamic format list based on source format, hidden bitrate for
  lossless targets, and lossless hint text
- Add lossless conversion to batch convert UI in downloaded_album,
  local_album, and queue_tab screens with lossy-source filtering
- Fix M4A quality probe in Go backend: increase audio sample entry
  buffer from 24 to 32 bytes, read sample rate from correct offset
  (bytes 28-29) and bit depth from samplesize field (bytes 22-23)
- Add l10n keys for lossless confirm dialogs and hints (en, id)
2026-03-16 02:13:45 +07:00
zarzet 35f2f119db feat: improve auto-fill track resolution in Edit Metadata sheet
- Identifier-first resolution (ISRC/Deezer/Spotify) before falling back to text search
- Score-based match selection via _metadataMatchScore instead of provider order
- Pass sourceTrackId from TrackMetadataScreen into _EditMetadataSheet
- Refactor buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult as testable helpers
- Add unit tests for buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult
- Propagate copyright through Deezer enrichment chain (exports, extension_providers)
2026-03-15 21:12:47 +07:00
zarzet f36096e0ac fix: resolve all flutter analyze warnings and improve auto-fill enrichment chain
- Fix use_build_context_synchronously in _embedLyrics by capturing l10n
  strings before async gaps (snackbarFailedToWriteStorage,
  snackbarFailedToEmbedLyrics, snackbarUnsupportedAudioFormat)
- Improve auto-fill metadata enrichment to use proper API chain:
  search providers -> convertSpotifyToDeezer (SongLink) for Deezer ID
  -> getDeezerMetadata for ISRC -> getDeezerExtendedMetadata for
  genre/label/copyright. Falls back to ISRC-based Deezer lookup when
  SongLink conversion unavailable.
- flutter analyze now reports 0 issues
2026-03-15 20:42:22 +07:00
zarzet 1665e4cd57 feat: selective auto-fill from online in Edit Metadata sheet
Add 'Auto-fill from online' expandable section to the metadata editor
that lets users choose exactly which fields to populate from online
metadata search. Users can select individual fields via filter chips,
use 'All' or 'Empty only' quick-select buttons, then tap 'Fetch & Fill'
to search metadata providers and fill only the selected controllers.

The search uses existing searchTracksWithMetadataProviders API with
ISRC-preferring best-match selection. Extended metadata (genre, label,
copyright) is fetched via Deezer extended metadata API when available.
Cover art is downloaded from the match's cover_url. All results are
previewed in the editor before saving — nothing is written to the file
until the user taps Save.

Add 21 new l10n keys (editMetadata* namespace) for all UI strings.
2026-03-15 20:35:42 +07:00
zarzet 42f0267277 feat: queue FLAC redownloads for local library tracks
Add LocalTrackRedownloadService with confidence-scored metadata matching
(ISRC, title, artist, album, duration, track/disc number, year) to find
reliable online matches for locally-stored tracks.

Wire up 'Queue FLAC' selection action in both local_album_screen and
queue_tab (library tab). Shows progress snackbar during resolution,
skips ambiguous or low-confidence matches, and reports results.

Add Indonesian (id) translations for all queueFlac l10n keys.
2026-03-15 20:18:58 +07:00
zarzet 82f59d32b9 fix: handle nested legacy iOS Documents path in validation
Detect and recover from stale sandbox container paths embedded inside
the current Documents directory. Extracts helper functions for path
suffix normalization and joining to reduce duplication.
2026-03-15 20:18:29 +07:00
zarzet 941347b007 feat: add Opus 320kbps quality, remove Tidal HIGH tier
- Add YouTubeQualityOpus320 constant and opus_320 parser case in Go backend
- Expand opus supported bitrates to [128, 256, 320] across Go, Dart settings, and UI
- Update default YouTube Opus option from 256 to 320kbps
- Remove Tidal HIGH (lossy 320kbps) quality from Go backend, settings model,
  settings provider, download queue provider (both SAF and non-SAF paths),
  settings UI (quality option, format picker, helper methods), and l10n keys
- Add settings migration v6: auto-migrate users with audioQuality=HIGH to LOSSLESS
- Update and add Go test cases for opus_320 and adjusted max bitrate
- Regenerate l10n files, remove 10 unused downloadLossy* l10n keys
2026-03-15 20:16:44 +07:00
zarzet 739c89569f Merge branch 'main' into dev 2026-03-15 19:42:31 +07:00
zarzet 18607597e9 fix: correct AltStore icon URL to assets/images/logo.png 2026-03-15 19:41:25 +07:00
zarzet 7bb808cba5 ci: auto-update AltStore source (apps.json) on release 2026-03-15 19:11:29 +07:00
Zarz Eleutherius 78cd396847 Merge pull request #233 from Amonoman/main
Add AltStore source and update README documentation
2026-03-15 19:07:50 +07:00
Zarz Eleutherius bb342c01e2 Merge pull request #232 from zarzet/renovate/flutter-3.x
chore(deps): update dependency flutter to v3.41.4
2026-03-15 19:02:31 +07:00
renovate[bot] 8a5dc0edfe chore(deps): update dependency flutter to v3.41.4 2026-03-15 12:02:29 +00:00
Amonoman 8540da484f Add AltStore source and update README 2026-03-15 13:02:23 +01:00
zarzet 20f789f8e0 fix(i18n): localize hardcoded strings in bulk playlist download and fix trailing newlines 2026-03-15 19:01:45 +07:00
Zarz Eleutherius 3e89326c95 Merge pull request #229 from ViscousPot/feat/bulk-download-library-playlists
Add bulk download option for selected library playlists
2026-03-15 18:57:06 +07:00
Zarz Eleutherius a7ea4de25a Merge pull request #228 from ViscousPot/feat/auto-fill-playlist-name-for-import
Auto-fill playlist name when importing from Spotify
2026-03-15 18:56:58 +07:00
Zarz Eleutherius aabfbf062e Merge pull request #230 from ViscousPot/feat/improve-dev+build-instructions
Add FVM config and improve dev setup instructions
2026-03-15 18:56:52 +07:00
zarzet 7b9ed3ec8e feat: add Qobuz Afkar API provider and prefer request metadata for consistent album grouping 2026-03-15 18:52:41 +07:00
ViscousPot 6dad66d62d Update CONTRIBUTING.md 2026-03-15 04:37:00 +00:00
ViscousPot 31018230ee add fvm 2026-03-15 04:12:32 +00:00
ViscousPot 54ddc1f59c feat: auto fill playlist name during import 2026-03-15 02:54:02 +00:00
ViscousPot c6856bd1a1 feat: add option to download multiple selected playlists 2026-03-15 02:50:20 +00:00
zarzet 8c18c7b8f1 Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-03-14 23:12:26 +07:00
zarzet 10c5293f64 chore: update VirusTotal hash for v3.8.0 2026-03-14 23:10:19 +07:00
zarzet d5381afcf9 chore: bump app version to 3.8.0+106 2026-03-14 21:49:22 +07:00
zarzet 134bf4375f feat: auto-enrich metadata for extension downloads, fix artist/playlist parsing, and improve metadata screen
- Add metadata provider search (Deezer/Tidal/Qobuz) in download pipeline for extension tracks with missing album/date/ISRC, using the same mechanism as ReEnrichFile
- Always pass enriched metadata (album, release_date, ISRC, cover_url, track/disc number) back in DownloadResponse so Flutter can embed them
- Add Deezer ISRC lookup for genre/label during download enrichment
- Extend _buildTrackForMetadataEmbedding to use ISRC, cover_url, album_artist from backend response
- Add Releases section support in artist page (Go + Flutter)
- Fix Track ID parsing to prefer non-empty native ID over empty spotify_id
- Paginate popular tracks (5 per page with swipe + dot indicators)
- Fix metadata screen: duration getter checks _editedMetadata, read album/duration from file tags
- Make metadata screen ID labels and Open-in buttons source-aware (Amazon/Tidal/Qobuz/Deezer/Spotify)
- Copy enrichment fields (AlbumName, DurationMS, CoverURL, AlbumArtist, ID) back to download request
- Update README badge, add network_requests.txt to gitignore
2026-03-14 21:47:57 +07:00
zarzet aa9854fc0a perf: optimize polling, progress caching, staggered warmup, and snapshot-based library scan
- Reduce polling interval from 800ms to 1200ms across download progress, library scan, and Android native stream
- Add dirty-flag caching to Go GetMultiProgress() to skip redundant JSON marshaling
- Replace eager provider initialization with staggered Timer-based warmup (400/900/1600ms)
- Add snapshot-based incremental library scan to avoid large MethodChannel payloads
- Move history stats and grouped album filtering to Riverpod providers for better cache invalidation
- Cap home tab history preview to 48 items with deep equality wrapper to reduce rebuilds
- Throttle foreground service notification updates to 2% progress buckets
- Migrate PageView to PageView.builder with AutomaticKeepAliveClientMixin
- Add comparison table to README
2026-03-14 16:52:33 +07:00
zarzet 10bc29e347 feat: add Qobuz and Tidal as built-in metadata search providers with priority-based unified search 2026-03-14 16:07:41 +07:00
zarzet 733efce161 fix: fix Tidal track resolution, playlist owner info, and improve track provider state 2026-03-14 15:42:21 +07:00
zarzet ac9141f167 feat: add Qobuz and Tidal metadata API, URL parsers, and full store support 2026-03-14 15:09:48 +07:00
zarzet d89850e8a9 feat: add name and images fields to PlaylistInfoMetadata 2026-03-14 15:07:34 +07:00
zarzet 5948e4f125 chore: remove redundant inline comments 2026-03-14 15:07:15 +07:00
zarzet 34d22f783c feat: add store registry URL management, port iOS handlers, and clean up store UI
Add set/get/clear store registry URL method channel handlers on Android,
iOS, and Go backend so users can configure a custom extension repository.

Store tab now shows a setup screen when no registry URL is configured,
with a cleaner layout (removed redundant description and helper text)
and visible TextField borders for dark theme.

Minor comment and formatting cleanups across several files.
2026-03-14 13:24:30 +07:00
Zarz Eleutherius c347b6999e Merge pull request #218 from ViscousPot/fix/folder-organization-by-playlist-for-library-playlists
Reviewed and approved. The fix correctly passes playlistName to addMultipleToQueue() for library playlists, consistent with playlist_screen.dart pattern.
2026-03-14 12:57:32 +07:00
ViscousPot adc74741ce Update library_tracks_folder_screen.dart 2026-03-14 01:48:34 +00:00
zarzet 48f614359e feat(i18n): replace all hardcoded strings with l10n keys across 13 screens
- Added 80+ new keys to app_en.arb covering lyrics, SAF, download settings,
  snackbars, dialogs, home, cache, and store screens
- Replaced hardcoded strings in main_shell, album_screen, playlist_screen,
  library_tracks_folder_screen, home_tab, settings_tab, download_settings_page,
  lyrics_provider_priority_page, track_metadata_screen, extension_detail_page,
  cache_management_page, local_album_screen, downloaded_album_screen, search_screen
- Fixed structural bug in track_metadata_screen (duplicate closing brace)
- Added missing l10n.dart import to search_screen.dart
- Regenerated all app_localizations*.dart files via flutter gen-l10n
2026-03-13 15:12:12 +07:00
zarzet 16669d8b7a feat: show 'Internal' version in debug builds, optimize download timeouts, and fix navigation safety
- Add displayVersion getter using kDebugMode: debug shows 'Internal', release shows actual version
- Defer Spotify URL resolution in Deezer downloader until fallback is actually needed
- Unify download timeouts to 24h constant (connection-level timeouts still protect hung connections)
- Fix context shadowing in track metadata options menu and delete dialog
- Use addPostFrameCallback + mounted guards for safer sheet/dialog navigation
2026-03-12 04:02:14 +07:00
zarzet f1eef47600 refactor: optimize SAF metadata reading, CUE sibling resolution, and startup initialization
- Add fast-path SAF metadata reading via /proc/self/fd with displayNameHint support, falling back to temp copy
- Replace repeated findFile() CUE audio sibling lookups with cached case-insensitive directory listing
- Cache parsed CUE sheets to avoid redundant parsing during library scans
- Optimize incremental scan CUE modTime lookup from O(N*M) to O(N+M)
- Defer local library provider loading until localLibraryEnabled setting is true
- Replace O(n) track+artist history lookup with O(1) map-based lookup
- Delay startup maintenance tasks by 2s to reduce launch-time contention
2026-03-12 03:36:48 +07:00
zarzet fc1567d2c8 Merge branch 'main' into dev 2026-03-12 02:52:32 +07:00
zarzet fffce6039a feat: add Deezer entry in provider priority UI and improve release changelog
- Add 'deezer' case with icon to _ProviderItem in provider_priority_page.dart
- Fix release.yml: deterministic previous-tag lookup for Full Changelog link
- Strip version header line and author attribution from Telegram changelog
- cliff.toml: hide repo owner username from commit attribution
- cliff.toml: remove PR number stripping preprocessor
2026-03-12 02:51:37 +07:00
Zarz Eleutherius cbfa147a12 New translations app_en.arb (Turkish) 2026-03-11 23:43:01 +07:00
Zarz Eleutherius 5b8c953ae6 New translations app_en.arb (Hindi) 2026-03-11 23:43:00 +07:00
Zarz Eleutherius 37a4dc096b New translations app_en.arb (Indonesian) 2026-03-11 23:42:58 +07:00
Zarz Eleutherius b3808645fb New translations app_en.arb (Chinese Traditional) 2026-03-11 23:42:57 +07:00
Zarz Eleutherius 24aa804bf2 New translations app_en.arb (Chinese Simplified) 2026-03-11 23:42:56 +07:00
Zarz Eleutherius 941ffb2bb7 New translations app_en.arb (Russian) 2026-03-11 23:42:54 +07:00
Zarz Eleutherius 59737d6f2b New translations app_en.arb (Portuguese) 2026-03-11 23:42:53 +07:00
Zarz Eleutherius c8ad93ee9b New translations app_en.arb (Dutch) 2026-03-11 23:42:52 +07:00
Zarz Eleutherius 8cb0c037c2 New translations app_en.arb (Korean) 2026-03-11 23:42:50 +07:00
Zarz Eleutherius e30b69397b New translations app_en.arb (Japanese) 2026-03-11 23:42:49 +07:00
Zarz Eleutherius d6e837fd61 New translations app_en.arb (German) 2026-03-11 23:42:47 +07:00
Zarz Eleutherius 5c97d202b9 New translations app_en.arb (Spanish) 2026-03-11 23:42:46 +07:00
Zarz Eleutherius 0f6cfa75bb New translations app_en.arb (French) 2026-03-11 23:42:44 +07:00
Zarz Eleutherius 91bd6d1572 Update source file app_en.arb 2026-03-11 23:42:42 +07:00
zarzet df77ae3986 fix(ios): remove stale built-in Spotify bridge handlers 2026-03-11 17:16:40 +07:00
zarzet 3cd6d068a2 docs: add centered Trendshift badge below README banner 2026-03-11 17:16:39 +07:00
zarzet 29165da5ac Merge branch 'dev' 2026-03-11 16:42:09 +07:00
zarzet 9343583c69 fix: make README banner more compact for better mobile visibility 2026-03-11 16:27:13 +07:00
zarzet d82d255bae feat: add dark/light theme-aware README banner and reorganize badges 2026-03-11 15:56:42 +07:00
zarzet 93a7042a84 chore: add design/ directory to .gitignore 2026-03-11 15:03:14 +07:00
zarzet 5be5c869da chore: add 'a fan' as donor for this month 2026-03-11 01:23:20 +07:00
zarzet 8d45e023b2 feat: add totalTracks to Track model and refine EP/single classification (#202) 2026-03-11 01:14:12 +07:00
zarzet f2ae1398db feat: add 'By Playlist' folder organization option (#111) 2026-03-11 01:07:14 +07:00
zarzet c2736a61fb refactor: remove built-in Spotify API provider, use Deezer as sole default
- Remove all Spotify credential management (client ID/secret, secure storage)
- Remove Spotify platform channel handlers from MainActivity
- Remove exported Go functions: GetSpotifyMetadata, SearchSpotify,
  SearchSpotifyAll, GetSpotifyRelatedArtists, SetSpotifyAPICredentials
- Simplify GetSpotifyMetadataWithDeezerFallback to SpotFetch-only path
- Remove Spotify built-in fallback in ReEnrichFile search pipeline
- Always return false from HasSpotifyCredentials; getCredentials always errors
- Default metadataProviderPriority is now ['deezer'] only
- Sanitize provider priority list to strip 'spotify' entries on load/save
- Add migration v5 to clear saved Spotify credentials from existing installs
- Remove Spotify source chip and credentials UI from options settings page
- Remove metadataSource param from search() — always uses Deezer
- spotify-web extension remains supported via the extension provider system
2026-03-11 00:58:07 +07:00
zarzet 76fe8dbc69 feat: add CUE sheet support for local library scanning and splitting (#201)
Parse .cue files in library scanner (Go + SAF) to display individual
tracks instead of one large audio file. Add FFmpeg-based CUE splitting
to extract tracks into separate FLAC files with embedded metadata and
cover art.

- Go: CUE parser, two-pass scan (CUE first, skip referenced audio),
  virtual paths (cue#trackNN) for DB UNIQUE constraint, audioDir
  override for SAF temp-file scenarios
- Android: SAF scanner recognizes .cue in both full and incremental
  scan, copies .cue+audio to temp for Go parsing, unchanged-CUE audio
  sibling dedup, parseCueSheet handler resolves SAF audio siblings
- Dart: FFmpegService.splitCueToTracks, CUE split UI in track metadata
  screen, persistent output dir for SAF splits with write-back
- CUE virtual path normalization across fileExists/fileStat/deleteFile/
  openFile; play/share/open blocked for virtual tracks with guidance to
  split first; delete only removes DB entry, not shared .cue file
- iOS: parseCueSheet handler
- Localization: 12 new CUE-related strings

Requested by @Seerafimm
Closes #201
2026-03-11 00:31:20 +07:00
Zarz Eleutherius dd05061829 New translations app_en.arb (Turkish) 2026-03-10 23:26:30 +07:00
Zarz Eleutherius 8f6b99c550 New translations app_en.arb (Hindi) 2026-03-10 23:26:29 +07:00
Zarz Eleutherius f54ee86591 New translations app_en.arb (Indonesian) 2026-03-10 23:26:27 +07:00
Zarz Eleutherius 42e0ec2663 New translations app_en.arb (Chinese Traditional) 2026-03-10 23:26:26 +07:00
Zarz Eleutherius 0456a97b35 New translations app_en.arb (Chinese Simplified) 2026-03-10 23:26:24 +07:00
Zarz Eleutherius 07c609cc3a New translations app_en.arb (Russian) 2026-03-10 23:26:23 +07:00
Zarz Eleutherius de5d26403f New translations app_en.arb (Portuguese) 2026-03-10 23:26:22 +07:00
Zarz Eleutherius 73c2d0efac New translations app_en.arb (Dutch) 2026-03-10 23:26:20 +07:00
Zarz Eleutherius d3c1c440cc New translations app_en.arb (Korean) 2026-03-10 23:26:19 +07:00
Zarz Eleutherius 94195c636f New translations app_en.arb (Japanese) 2026-03-10 23:26:17 +07:00
Zarz Eleutherius 9abf492362 New translations app_en.arb (German) 2026-03-10 23:26:16 +07:00
Zarz Eleutherius defc84c216 New translations app_en.arb (Spanish) 2026-03-10 23:26:15 +07:00
Zarz Eleutherius 3c9ae39145 New translations app_en.arb (French) 2026-03-10 23:26:13 +07:00
zarzet 64408c8d8b feat: add Single and EP badge on artist page and fix EP bucket filtering (#203) 2026-03-10 23:25:45 +07:00
zarzet db55bb4693 fix(deezer): update quality label to FLAC Best Quality since API may deliver above CD quality 2026-03-10 23:12:44 +07:00
zarzet 9c6856b584 refactor: migrate to git-cliff for changelog and deduplicate library items
- Replace manual CHANGELOG.md parsing with git-cliff action in release workflow
- Add cliff.toml config for conventional commit grouping and GitHub integration
- Extract buildPathMatchKeys into shared utility with Android storage alias support
- Deduplicate local library items overlapping with downloaded items in queue tab
2026-03-10 15:26:19 +07:00
Zarz Eleutherius 581f43f4c1 New translations app_en.arb (Turkish) 2026-03-09 22:45:36 +07:00
Zarz Eleutherius 221d7e4829 New translations app_en.arb (Hindi) 2026-03-09 22:45:35 +07:00
Zarz Eleutherius 706528f04b New translations app_en.arb (Indonesian) 2026-03-09 22:45:33 +07:00
Zarz Eleutherius f95a96dd1f New translations app_en.arb (Chinese Traditional) 2026-03-09 22:45:32 +07:00
Zarz Eleutherius d85c16ce0f New translations app_en.arb (Chinese Simplified) 2026-03-09 22:45:31 +07:00
Zarz Eleutherius 35afdf4be4 New translations app_en.arb (Russian) 2026-03-09 22:45:30 +07:00
Zarz Eleutherius eb5ed86019 New translations app_en.arb (Portuguese) 2026-03-09 22:45:28 +07:00
Zarz Eleutherius 0cfa6f56be New translations app_en.arb (Dutch) 2026-03-09 22:45:27 +07:00
Zarz Eleutherius 5af88ead33 New translations app_en.arb (Korean) 2026-03-09 22:45:25 +07:00
Zarz Eleutherius 8ec63ee610 New translations app_en.arb (Japanese) 2026-03-09 22:45:24 +07:00
Zarz Eleutherius c8247bf7a0 New translations app_en.arb (German) 2026-03-09 22:45:22 +07:00
Zarz Eleutherius 2f3270c7ff New translations app_en.arb (Spanish) 2026-03-09 22:45:21 +07:00
Zarz Eleutherius 960d60f0bc New translations app_en.arb (French) 2026-03-09 22:45:19 +07:00
zarzet a4899144c5 fix: show error feedback for unrecognized URLs and fix l10n warnings 2026-03-08 23:12:48 +07:00
zarzet 808083c938 chore(l10n): merge Crowdin translation updates (#153)
Merge branch 'l10n_dev' into dev.

- Update translations for 10 languages (de, fr, hi, id, ja, ko, nl, ru, zh_CN, zh_TW)
- Add new language support: Spanish (es-ES), Portuguese (pt-PT), Turkish (tr-TR)
- Translate previously untranslated English strings to their respective languages
- Add new localization keys: collection/playlist management, batch convert, share, advanced filename tags
- Consolidate and remove deprecated translation keys

Conflicts resolved:
- app_en.arb: kept dev version (reflects Amazon Music moved to extension)
- All other ARB files: kept l10n_dev version (proper Crowdin translations)
2026-03-08 22:52:24 +07:00
zarzet 7e41ab4460 fix: YouTube Music share intent not recognizing album/browse URLs 2026-03-08 22:13:01 +07:00
zarzet 75a2bec8d5 chore: accessibility improvements, Semantics wrappers, and tooltip additions across screens 2026-03-08 15:08:13 +07:00
zarzet c35857bb61 fix(ios): local library scan fails on iOS due to missing security-scoped bookmark access 2026-03-08 14:57:13 +07:00
zarzet 2c897992c5 feat: resolve audio metadata from file, backfill placeholder quality labels with actual bit depth and sample rate 2026-03-08 04:28:16 +07:00
zarzet 7d5cb574c6 feat: move Amazon Music to extension, fix Deezer download timeout 2026-03-08 04:15:28 +07:00
Zarz Eleutherius c582f96cf6 New translations app_en.arb (Turkish) 2026-03-06 23:32:51 +07:00
Zarz Eleutherius 8fab3f60a7 New translations app_en.arb (Hindi) 2026-03-06 23:32:49 +07:00
Zarz Eleutherius c6e981b3a1 New translations app_en.arb (Indonesian) 2026-03-06 23:32:48 +07:00
Zarz Eleutherius f0c5c5660a New translations app_en.arb (Chinese Traditional) 2026-03-06 23:32:47 +07:00
Zarz Eleutherius 9c647bb31b New translations app_en.arb (Chinese Simplified) 2026-03-06 23:32:46 +07:00
Zarz Eleutherius e1e82ac586 New translations app_en.arb (Russian) 2026-03-06 23:32:45 +07:00
Zarz Eleutherius 585d6da98d New translations app_en.arb (Portuguese) 2026-03-06 23:32:43 +07:00
Zarz Eleutherius bc279dd7fd New translations app_en.arb (Dutch) 2026-03-06 23:32:42 +07:00
Zarz Eleutherius f2fdead6d3 New translations app_en.arb (Korean) 2026-03-06 23:32:41 +07:00
Zarz Eleutherius f66ccb4741 New translations app_en.arb (Japanese) 2026-03-06 23:32:40 +07:00
Zarz Eleutherius 32c10c2b23 New translations app_en.arb (German) 2026-03-06 23:32:38 +07:00
Zarz Eleutherius 05674d9586 New translations app_en.arb (Spanish) 2026-03-06 23:32:37 +07:00
Zarz Eleutherius 11bda9aae5 New translations app_en.arb (French) 2026-03-06 23:32:36 +07:00
Zarz Eleutherius 02c803385c Update source file app_en.arb 2026-03-06 23:32:33 +07:00
zarzet 8fe7a1e756 Merge branch 'dev'
# Conflicts:
#	README.md
#	lib/constants/app_info.dart
#	lib/l10n/app_localizations_ru.dart
#	pubspec.yaml
2026-03-06 22:02:12 +07:00
zarzet 4a61ffea8d chore: update VirusTotal hash 2026-03-06 22:00:56 +07:00
zarzet 91548691ad feat(site): add Ruubiiiii as Qobuz & Deezer API provider on partners page 2026-03-06 21:57:39 +07:00
zarzet 36a646e5c0 feat: add Deezer download service, Qobuz squid.wtf fallback, update changelog 2026-03-06 21:18:50 +07:00
zarzet f306599ab2 v3.7.1: YT Music extension priority for YouTube downloads, Qobuz store fallback, queue fixes, server-side search filters 2026-03-06 16:44:53 +07:00
zarzet 3a7b777717 fix(queue): unique queue IDs, nullable currentDownload, local cancel tracking; refactor(l10n): consolidate and clean up localization files
download_queue_provider: generate unique queue item IDs with sequence counter to prevent collisions, fix copyWith to allow setting currentDownload to null via sentinel object pattern, add _locallyCancelledItemIds set for reliable cancel state, normalize restored queue IDs on load. l10n: remove redundant keys, consolidate ARB files, regenerate Dart localization classes.
2026-03-06 16:44:53 +07:00
Zarz Eleutherius 2334e659ad Merge pull request #206 from zarzet/renovate/major-flutter-dependencies
fix(deps): update dependency flutter_local_notifications to v21
2026-03-05 17:32:17 +07:00
renovate[bot] 2a0216c87a fix(deps): update dependency flutter_local_notifications to v21 2026-03-05 10:14:36 +00:00
Zarz Eleutherius ab2d671760 New translations app_en.arb (Turkish) 2026-03-04 23:19:46 +07:00
Zarz Eleutherius 5532d0a7d9 New translations app_en.arb (Hindi) 2026-03-04 23:19:44 +07:00
Zarz Eleutherius 277e7719d3 New translations app_en.arb (Indonesian) 2026-03-04 23:19:43 +07:00
Zarz Eleutherius d6cb9fc261 New translations app_en.arb (Chinese Traditional) 2026-03-04 23:19:42 +07:00
Zarz Eleutherius e6a857335f New translations app_en.arb (Chinese Simplified) 2026-03-04 23:19:40 +07:00
Zarz Eleutherius e82e3a8343 New translations app_en.arb (Russian) 2026-03-04 23:19:39 +07:00
Zarz Eleutherius 6d812c76c2 New translations app_en.arb (Portuguese) 2026-03-04 23:19:38 +07:00
Zarz Eleutherius 5af4bb7ade New translations app_en.arb (Dutch) 2026-03-04 23:19:36 +07:00
Zarz Eleutherius 030d66fd65 New translations app_en.arb (Korean) 2026-03-04 23:19:35 +07:00
Zarz Eleutherius c929f8d0a6 New translations app_en.arb (Japanese) 2026-03-04 23:19:33 +07:00
Zarz Eleutherius 6fb50cfc67 New translations app_en.arb (German) 2026-03-04 23:19:32 +07:00
Zarz Eleutherius ebcdcf40dc New translations app_en.arb (Spanish) 2026-03-04 23:19:31 +07:00
Zarz Eleutherius 76a05e717b New translations app_en.arb (French) 2026-03-04 23:19:30 +07:00
Zarz Eleutherius 062ce31cf7 Update source file app_en.arb 2026-03-04 23:19:27 +07:00
zarzet 98abaf6635 v3.7.0: roll back from v4, remove internal player — v3 is already complete
Version rolled back from v4.x to v3.7.0. After extensive work on v4's
internal streaming engine, smart queue, DASH pipeline, and media controls,
we realized v3 was already feature-complete. Adding more big features
only made maintenance increasingly difficult and the developer's life
miserable. Stripped back to what works: external player only, cleaner
codebase, sustainable long-term.

- Remove just_audio, audio_service, audio_session and entire internal
  playback engine (smart queue, notification, shuffle/repeat, prefetch)
- Remove PlaybackItem model, MiniPlayerBar widget, notification drawables
- Remove playerMode setting (external-only now)
- Migrate MainActivity from AudioServiceFragmentActivity to
  FlutterFragmentActivity
- Migrate Qobuz to MusicDL API
- Update changelog with v3.7.0 rollback explanation
2026-03-04 02:02:25 +07:00
Zarz Eleutherius 8675ab3215 New translations app_en.arb (Russian) 2026-03-02 03:16:44 +07:00
Zarz Eleutherius ad6ef2884a New translations app_en.arb (German) 2026-02-28 21:18:46 +07:00
Zarz Eleutherius 3ebb8a5e79 New translations app_en.arb (Indonesian) 2026-02-27 21:04:14 +07:00
Zarz Eleutherius 652b1b0821 New translations app_en.arb (German) 2026-02-27 21:04:13 +07:00
zarzet 4747119a7f fix(playback): prevent internal mini-player flash in external mode 2026-02-27 15:05:16 +07:00
zarzet bfd769b349 fix(library): exclude downloaded tracks from local scan reliably 2026-02-27 15:05:14 +07:00
zarzet 40c3c73bfd fix: hide internal player UI when external mode is active 2026-02-27 14:42:15 +07:00
zarzet 96d11b1d7d feat: add external player mode for local library playback 2026-02-27 14:38:45 +07:00
zarzet b3771f3488 fix: disable automatic spotify-web install during setup 2026-02-27 14:31:49 +07:00
zarzet a07c125454 feat: update collection actions for offline-first playback 2026-02-27 14:30:10 +07:00
zarzet 54a7b6b568 fix: load lyrics from sidecar lrc before online lookup 2026-02-27 14:27:30 +07:00
zarzet 77d0ac4fce fix: prioritize local embedded lyrics before online fetch 2026-02-27 14:26:11 +07:00
zarzet bddd733466 fix: trigger smart queue for local play actions 2026-02-27 14:21:02 +07:00
zarzet e6ffb08954 feat: make smart queue offline-only 2026-02-27 14:14:44 +07:00
zarzet 2fe8f659bc refactor: simplify setup flow and update collection actions 2026-02-27 13:48:47 +07:00
zarzet ab26d84632 chore: rebuild dev history without streaming-era commits 2026-02-27 13:48:44 +07:00
Zarz Eleutherius a202ca4865 New translations app_en.arb (German) 2026-02-25 20:31:40 +07:00
Zarz Eleutherius a2db5bef25 New translations app_en.arb (Russian) 2026-02-23 19:26:36 +07:00
Zarz Eleutherius a81fa1ead7 New translations app_en.arb (German) 2026-02-23 19:26:35 +07:00
Zarz Eleutherius e7315cbc7e New translations app_en.arb (Turkish) 2026-02-22 18:55:55 +07:00
Zarz Eleutherius cd757f177f New translations app_en.arb (Hindi) 2026-02-22 18:55:54 +07:00
Zarz Eleutherius 103c55c072 New translations app_en.arb (Indonesian) 2026-02-22 18:55:53 +07:00
Zarz Eleutherius 765caab6df New translations app_en.arb (Chinese Traditional) 2026-02-22 18:55:52 +07:00
Zarz Eleutherius 72f4663dd5 New translations app_en.arb (Chinese Simplified) 2026-02-22 18:55:51 +07:00
Zarz Eleutherius deb6d92b55 New translations app_en.arb (Russian) 2026-02-22 18:55:50 +07:00
Zarz Eleutherius 0222ea6ccb New translations app_en.arb (Portuguese) 2026-02-22 18:55:49 +07:00
Zarz Eleutherius 8c047600a0 New translations app_en.arb (Dutch) 2026-02-22 18:55:48 +07:00
Zarz Eleutherius 57b5877fdc New translations app_en.arb (Korean) 2026-02-22 18:55:46 +07:00
Zarz Eleutherius 7ddf67a977 New translations app_en.arb (Japanese) 2026-02-22 18:55:45 +07:00
Zarz Eleutherius 7af2212d11 New translations app_en.arb (German) 2026-02-22 18:55:44 +07:00
Zarz Eleutherius 5e13651ed9 New translations app_en.arb (Spanish) 2026-02-22 18:55:43 +07:00
Zarz Eleutherius 08e9c8d463 New translations app_en.arb (French) 2026-02-22 18:55:42 +07:00
Zarz Eleutherius b3d93880b5 Update source file app_en.arb 2026-02-22 18:55:40 +07:00
Zarz Eleutherius 05e100a492 New translations app_en.arb (Russian) 2026-02-21 18:43:19 +07:00
Zarz Eleutherius a4e22de455 New translations app_en.arb (Korean) 2026-02-20 18:25:11 +07:00
zarzet c89600591c feat: add love and add-to-playlist circular buttons to album screen
Flanks the Download All button with two circular icon buttons:
- Left: heart (favorite_border/favorite) toggles love for all tracks; turns red when all are loved
- Right: plus icon opens the playlist picker sheet for all album tracks
2026-02-20 03:30:34 +07:00
zarzet f1d57d89c7 refactor: extract duplicated code to shared utilities across Dart and Go
- Extract normalizeOptionalString() to lib/utils/string_utils.dart from download_queue_provider and track_metadata_screen
- Extract PrioritySettingsScaffold widget from lyrics and metadata priority pages, reducing ~280 lines of duplication
- Extract _ensureDefaultDocumentsOutputDir/_ensureDefaultAndroidMusicOutputDir in download queue provider
- Extract collectLibraryAudioFiles() and applyDefaultLibraryMetadata() in Go library_scan.go
- Extract plainTextLyricsLines() in Go lyrics.go, used by Apple Music, Musixmatch, and QQ Music clients
2026-02-19 19:49:58 +07:00
zarzet 83124875d3 fix: wrap collection folder list items in Card to match history item style in library tab 2026-02-19 19:31:18 +07:00
zarzet 9460e9faae fix: remove dividers and align content padding in playlist track list to match album screen 2026-02-19 19:26:08 +07:00
zarzet 882afd938b feat: add SongLink region setting and fix track metadata lookup with name+artist fallback
- Add configurable SongLink region (userCountry) setting with picker UI
- Pass songLinkRegion through download request payload to Go backend
- Go backend: thread-safe global SongLink region with per-request override
- Fix downloaded track not recognized in collection tap: add findByTrackAndArtist
  fallback in download history lookup chain (Spotify ID → ISRC → name+artist)
- Apply same name+artist fallback to isDownloaded check in track options sheet
- Add missing library_database.dart import for LocalLibraryItem
2026-02-19 19:16:55 +07:00
zarzet ab72a10578 feat: add multi-select to library folders, batch playlist picker, and Go backend FD safety
- Add multi-select support to library_tracks_folder_screen (wishlist, loved,
  playlist) with long-press to enter selection mode, animated bottom bar with
  batch remove/download/add-to-playlist actions, and PopScope exit handling
- Create batch showAddTracksToPlaylistSheet in playlist_picker_sheet with
  playlist thumbnail widget and cover image support
- Add playlist grid selection tint overlay in queue_tab
- Optimize collection lookups with pre-built _allPlaylistTrackKeys index and
  isTrackInAnyPlaylist/hasPlaylistTracks accessors
- Eagerly initialize localLibraryProvider and libraryCollectionsProvider
- Enable SQLite WAL mode and PRAGMA synchronous=NORMAL across all databases
- Go backend: duplicate SAF output FDs before provider attempts to prevent
  fdsan abort on fallback retries; close detached FDs after download completes
- Go backend: rewrite compatibilityTransport to try HTTPS first and only
  fallback to HTTP on transport-level failures, preventing redirect loops
- Go backend: enforce HTTPS-only for extension sandbox HTTP clients
2026-02-19 18:27:14 +07:00
Zarz Eleutherius d76d020cfe New translations app_en.arb (German) 2026-02-19 18:17:08 +07:00
zarzet e39756fa3f refactor: migrate persistence to SQLite, add strict provider mode, and optimize collection lookups
- Replace SharedPreferences with SQLite (AppStateDatabase, LibraryCollectionsDatabase) for download queue, library collections, and recent access history
- Add Set-based O(1) track containment checks for wishlist, loved, and playlist tracks
- Add batch addTracksToPlaylist with PlaylistAddBatchResult
- Go backend: strict mode locks download to selected provider when auto fallback is off
- Go backend: fix extension progress normalization (percent/100) and lifecycle tracking
- Go backend: case-insensitive provider ID matching throughout fallback chain
- Lyrics embedding now respects lyricsMode setting (embed/both/off)
- Debounced queue persistence to reduce write frequency
- Fix shouldUseFallback logic to not be gated by useExtensions
2026-02-19 16:40:03 +07:00
zarzet 8e794e1ef1 feat: Library tab redesign with playlists, drag-and-drop categorization, and pinned app bars 2026-02-19 15:55:24 +07:00
zarzet caf68c8137 redesign: full-screen cover art with parallax scroll across all detail screens
Replace blurred background + centered cover thumbnail with full-screen
cover art, dark gradient overlay, and parallax collapse mode for a
consistent Apple Music-inspired design across album, playlist, downloaded
album, local album, and track metadata screens. Remove select button UI
(users enter selection via long-press), upgrade cover resolution for
Spotify/Deezer CDN, and move track/album info into the overlay.
2026-02-19 00:28:12 +07:00
zarzet 5161ac8f77 chore: bump version to 3.7.0+83 2026-02-18 19:43:50 +07:00
zarzet 4df96db809 feat: batch re-enrich for local tracks, SAF FD refactor, Ogg quality fix
- Replace batch Share action with batch Re-enrich in local album selection bar
  - Full native/FFmpeg re-enrich flow with SAF write-back support
  - Triggers incremental local library scan after completion to refresh metadata
- Queue tab: switch first selection action to Re-enrich when all selected items are local-only
- Refactor SAF FD handoff in MainActivity: drop detachFd/dup pattern, pass procfs
  path to Go and let Go re-open it to avoid fdsan double-close race conditions
- Handle /proc/self/fd/ path in output_fd.go: re-open via O_WRONLY|O_TRUNC instead
  of taking raw FD ownership
- Fix Ogg duration/bitrate calculation in audio_metadata.go:
  - Use float64 arithmetic and math.Round for accurate duration
  - Compute bitrate from file size / float duration at the source
  - Validate Ogg page header fields (version, headerType, segment table) to avoid
    false positives from payload bytes during backward scan
  - Guard against corrupted granule values (>24h duration, <8kbps bitrate)
- Rename trackReEnrich label from 'Re-enrich Metadata' to 'Re-enrich' across all
  13 locales and ARB files
- Update CHANGELOG.md with 3.7.0 entry
2026-02-18 19:29:59 +07:00
zarzet 5605930aef feat: add multi-select share and batch convert in downloaded/local album screens
- Add shareMultipleContentUris native handler in MainActivity for ACTION_SEND_MULTIPLE
- Add shareMultipleContentUris binding in PlatformBridge
- Add _shareSelected and _performBatchConversion methods to DownloadedAlbumScreen and LocalAlbumScreen
- Add batch convert bottom sheet UI with format/bitrate selection (MP3/Opus, 128k-320k)
- Add share & convert action buttons to selection bottom bar in both screens
- Add batch convert with full SAF support: temp copy, write-back, history update
- Add share/convert selection strings to l10n (all supported locales + app_en.arb)
- Add queue tab selection share/convert feature (queue_tab.dart)
- Update donate page
- Update go.sum with bumped dependency hashes
2026-02-18 18:05:48 +07:00
Zarz Eleutherius 85bf3cfa84 New translations app_en.arb (Turkish) 2026-02-18 17:44:35 +07:00
Zarz Eleutherius 8eec73d88c New translations app_en.arb (Hindi) 2026-02-18 17:44:34 +07:00
Zarz Eleutherius b63dbbbfd5 New translations app_en.arb (Indonesian) 2026-02-18 17:44:33 +07:00
Zarz Eleutherius 8b16157047 New translations app_en.arb (Chinese Traditional) 2026-02-18 17:44:32 +07:00
Zarz Eleutherius 6628682f97 New translations app_en.arb (Chinese Simplified) 2026-02-18 17:44:31 +07:00
Zarz Eleutherius 5971ffc470 New translations app_en.arb (Russian) 2026-02-18 17:44:30 +07:00
Zarz Eleutherius baf95ec328 New translations app_en.arb (Portuguese) 2026-02-18 17:44:28 +07:00
Zarz Eleutherius 0a6590fafd New translations app_en.arb (Dutch) 2026-02-18 17:44:27 +07:00
Zarz Eleutherius 22dd0ee0f6 New translations app_en.arb (Korean) 2026-02-18 17:44:26 +07:00
Zarz Eleutherius f9ad6046e8 New translations app_en.arb (Japanese) 2026-02-18 17:44:25 +07:00
Zarz Eleutherius 8a21902fa1 New translations app_en.arb (German) 2026-02-18 17:44:24 +07:00
Zarz Eleutherius 016564eda7 New translations app_en.arb (Spanish) 2026-02-18 17:44:23 +07:00
Zarz Eleutherius 5a8ff7db37 New translations app_en.arb (French) 2026-02-18 17:44:22 +07:00
Zarz Eleutherius cc08596adf Update source file app_en.arb 2026-02-18 17:44:19 +07:00
zarzet cdc5836785 fix: rollback Go toolchain to 1.25.7 to fix ARM32 SIGSYS crash
Go 1.26.0 runtime uses futex_time64 (syscall 422) which is blocked
by seccomp on Android 10 and older ARM32 devices, causing immediate
SIGSYS (signal 31) crash on app launch.

Downgraded:
- toolchain: go1.26.0 -> go1.25.7
- golang.org/x/sys: v0.41.0 -> v0.40.0
- golang.org/x/crypto: v0.48.0 -> v0.47.0
- golang.org/x/mod: v0.33.0 -> v0.32.0
- golang.org/x/text: v0.34.0 -> v0.33.0
- golang.org/x/tools: v0.42.0 -> v0.41.0
2026-02-18 00:04:32 +07:00
zarzet 813ed79073 refactor: conditionally show lyrics settings only when embed lyrics is enabled 2026-02-17 20:57:50 +07:00
Zarz Eleutherius 537bab69ab Merge pull request #151 from zarzet/renovate/go-dependencies
fix(deps): update go dependencies
2026-02-17 18:28:44 +07:00
Zarz Eleutherius b0871ad94b Merge pull request #158 from zarzet/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2026-02-17 18:28:31 +07:00
Zarz Eleutherius 0bd7574ab2 Merge pull request #159 from zarzet/renovate/actions-upload-pages-artifact-4.x
chore(deps): update actions/upload-pages-artifact action to v4
2026-02-17 18:28:16 +07:00
renovate[bot] c3f8b48bf7 fix(deps): update go dependencies 2026-02-17 11:28:10 +00:00
zarzet 90f731ac1e fix: refine launcher icons and settings page presentation
Polish generated app icon handling and improve the settings page supporter section layout for better scalability and readability.
2026-02-17 18:26:20 +07:00
Zarz Eleutherius e83fd66023 New translations app_en.arb (Russian) 2026-02-17 17:43:02 +07:00
Zarz Eleutherius d49bab403d New translations app_en.arb (German) 2026-02-17 17:43:01 +07:00
zarzet 8e6cbcbc2a feat: YouTube customizable bitrate, improved title matching, SpotubeDL engine fallback
- Add configurable YouTube Opus (96-256kbps) and MP3 (96-320kbps) bitrates
- Improve title matching with loose normalization for symbol-heavy tracks
- Add SpotubeDL engine v2 fallback for MP3 requests
- Improve filename sanitization in track metadata screen
- Bump version to 3.6.9+82
2026-02-17 17:22:24 +07:00
Zarz Eleutherius a6bef63aa7 New translations app_en.arb (Indonesian) 2026-02-16 17:38:32 +07:00
Zarz Eleutherius 898e28c40c New translations app_en.arb (German) 2026-02-15 17:40:51 +07:00
zarzet 9fda7ef596 Merge branch 'dev' 2026-02-14 17:56:33 +07:00
Zarz Eleutherius 17ba1713ad New translations app_en.arb (Turkish) 2026-02-14 17:37:29 +07:00
Zarz Eleutherius f4110204b1 New translations app_en.arb (Hindi) 2026-02-14 17:37:28 +07:00
Zarz Eleutherius d2a183b52d New translations app_en.arb (Indonesian) 2026-02-14 17:37:27 +07:00
Zarz Eleutherius a8dcf3113c New translations app_en.arb (Chinese Traditional) 2026-02-14 17:37:26 +07:00
Zarz Eleutherius 1f52a6c9e0 New translations app_en.arb (Chinese Simplified) 2026-02-14 17:37:25 +07:00
Zarz Eleutherius adbed63196 New translations app_en.arb (Russian) 2026-02-14 17:37:24 +07:00
Zarz Eleutherius 33e20845f1 New translations app_en.arb (Portuguese) 2026-02-14 17:37:22 +07:00
Zarz Eleutherius 9a7096c301 New translations app_en.arb (Dutch) 2026-02-14 17:37:21 +07:00
Zarz Eleutherius 4c365032ff New translations app_en.arb (Korean) 2026-02-14 17:37:20 +07:00
Zarz Eleutherius bbd32d40a6 New translations app_en.arb (Japanese) 2026-02-14 17:37:19 +07:00
Zarz Eleutherius 73f4a91fa1 New translations app_en.arb (German) 2026-02-14 17:37:18 +07:00
Zarz Eleutherius 1e2e383794 New translations app_en.arb (Spanish) 2026-02-14 17:37:17 +07:00
Zarz Eleutherius 3b70b071e3 New translations app_en.arb (French) 2026-02-14 17:37:16 +07:00
Zarz Eleutherius 838c0ea421 Update source file app_en.arb 2026-02-14 17:37:13 +07:00
zarzet 3ac9ff1dd7 docs: update Paxsenix integration to include all supported providers 2026-02-14 17:26:53 +07:00
zarzet 3e90b29d2b fix: improve lyrics error detection and add new donor
- Detect error JSON payloads from Apple Music and QQMusic proxies
- Add lyricsHasUsableText validation for usable lyrics content
- Skip empty word lines in synced lyrics parsing
- Add ClearAll method to lyrics cache
- Handle TimeoutException properly in track metadata screen
- Add laflame to recent donors list
2026-02-14 17:25:17 +07:00
zarzet b74186464b chore: update CHANGELOG for v3.6.8 2026-02-14 02:18:43 +07:00
zarzet f4934dcb28 feat: add lyrics source tracking, Paxsenix partner, and dedicated lyrics provider settings page
- Add getLyricsLRCWithSource to return lyrics with source metadata
- Display lyrics source in track metadata screen
- Improve LRC parsing to preserve background vocal tags
- Add dedicated LyricsProviderPriorityPage for provider configuration
- Add Paxsenix as lyrics proxy partner for Apple Music/QQ Music
- Handle inline timestamps and speaker prefixes in LRC display
2026-02-14 02:15:36 +07:00
zarzet 30973a8e78 feat: lyrics provider extensions, configurable lyrics cascade, and iOS method channel parity
Add lyrics_provider as a new extension type alongside metadata_provider and
download_provider. Extensions implementing fetchLyrics() are called before
built-in providers, giving user-installed extensions highest priority.

Built-in lyrics cascade is now configurable from Download Settings:
  - Reorderable provider list (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music)
  - Per-provider options: Netease translation/romanization, Apple/QQ multi-person
    word-by-word speaker tags, Musixmatch language code
  - Provider order and options synced to Go backend on app start and on change

Go backend changes:
  - New lyrics_provider manifest type with validation (extension_manifest.go)
  - ExtensionProviderWrapper.FetchLyrics() with Goja JS bridge (extension_providers.go)
  - Configurable SetLyricsProviderOrder/GetLyricsProviderOrder cascade (lyrics.go)
  - LyricsFetchOptions struct for per-provider settings (lyrics.go)
  - Extracted tryLRCLIB() helper, randomized LRCLIB User-Agent (lyrics.go)
  - Refactored msToLRCTimestamp to separate msToLRCTimestampInline (lyrics.go)
  - New provider source files: lyrics_apple.go, lyrics_musixmatch.go,
    lyrics_netease.go, lyrics_qqmusic.go
  - JSON export functions for lyrics settings (exports.go)
  - hasLyricsProvider field in extension manager JSON output

Platform channels:
  - Android (MainActivity.kt): setLyricsProviders, getLyricsProviders,
    getAvailableLyricsProviders, setLyricsFetchOptions, getLyricsFetchOptions
  - iOS (AppDelegate.swift): same 5 method channel handlers for iOS parity

Flutter side:
  - Extension model: hasLyricsProvider field + Lyrics Provider capability badge
  - Settings model: lyricsProviders, lyricsIncludeTranslationNetease,
    lyricsIncludeRomanizationNetease, lyricsMultiPersonWordByWord,
    musixmatchLanguage fields with generated serialization
  - Settings provider: setters + _syncLyricsSettingsToBackend()
  - Download settings UI: provider picker, toggle switches, language picker
  - Platform bridge: lyrics provider/options methods

Docs: lyrics provider extension documentation in site/docs.html
CHANGELOG: updated with lyrics provider and search feature entries
2026-02-14 01:42:18 +07:00
zarzet 9b89625660 feat(site): add global search modal, search trigger styling, and remove emoji from docs
- Add search modal with full keyboard navigation (Ctrl+K, arrows, Enter, Esc) to all pages
- Search opens in-page on every page with static docs index; results navigate to docs#section
- Search trigger in desktop nav styled as bordered pill chip with hover states
- Add Search Docs link in mobile hamburger menus
- Fix nav-links vertical alignment with align-items: center
- Remove all colored emoji from docs.html (checkmarks, crosses, music note)
2026-02-14 00:54:46 +07:00
zarzet c70ba5962e feat(site): add copyright and MIT license to all page footers 2026-02-13 22:47:50 +07:00
renovate[bot] 8c722b0a18 chore(deps): update actions/upload-pages-artifact action to v4 2026-02-13 14:42:53 +00:00
renovate[bot] 3ece6770e1 chore(deps): update actions/checkout action to v6 2026-02-13 14:42:49 +00:00
zarzet b39ec41255 Merge dev into main: v3.6.7 release 2026-02-13 21:42:02 +07:00
zarzet 1407018d98 feat: advanced filename templates, low-RAM device profiling, responsive artist UI, and project site
- Add advanced filename template placeholders: {track_raw}, {disc_raw}, {date},
  formatted numbers {track:N}/{disc:N}, and date formatting {date:%Y-%m-%d}
  with strftime-to-Go layout conversion and robust date parser
- Pass date/release_date metadata to filename builder in all providers
  (Amazon, Qobuz, Tidal, YouTube, extensions) and Flutter download queue
- Detect ARM32-only / low-RAM Android devices at startup and reduce image
  cache size and disable overscroll effects for smoother experience
- Make artist screen selection bar responsive: compact stacked layout on
  narrow screens or large text scale; add quality picker before track download
- Add advanced tags toggle in download settings filename format editor
- Fix ICU plural syntax in DE/ES/PT/RU translations (one {}=1{...} -> one {...})
- Add filenameShowAdvancedTags l10n strings (EN, ID) and regenerate dart files
- Fix featured-artist regex: remove '&' from split separators
- Add Go filename template tests (filename_test.go)
- Add GitHub Pages workflow and static project site
2026-02-13 21:39:08 +07:00
Zarz Eleutherius d4d661d6d4 New translations app_en.arb (German) 2026-02-13 17:08:10 +07:00
Zarz Eleutherius 2092f078ec Update badge URL 2026-02-13 04:06:33 +07:00
Zarz Eleutherius 924569aefb New translations app_en.arb (Russian) 2026-02-12 16:53:01 +07:00
Zarz Eleutherius a5864e15f8 New translations app_en.arb (German) 2026-02-12 16:53:00 +07:00
zarzet 0dc89cf569 fix(deps): allow material_color_utilities sdk-pinned range 2026-02-12 02:33:54 +07:00
zarzet 3c1e9d03a0 fix(ios): recover notification permission and path handling 2026-02-12 02:23:54 +07:00
zarzet 28a082f47a fix(l10n): fix arb locale mismatch - rename es-ES/pt-PT to underscore format 2026-02-12 01:28:56 +07:00
zarzet 38994d5900 chore: bump pubspec.yaml version to 3.6.6+80 2026-02-12 01:16:49 +07:00
zarzet 472896328a Merge remote-tracking branch 'origin/dev' into dev 2026-02-12 01:15:50 +07:00
zarzet 92f408035a feat: enable library scan notifications on iOS (remove Android-only guard) 2026-02-12 01:14:10 +07:00
zarzet 979186243c docs: update changelog v3.6.6 with new features and translation update 2026-02-12 01:11:12 +07:00
zarzet ee66247bea Merge remote-tracking branch 'origin/l10n_dev' into dev
# Conflicts:
#	lib/l10n/arb/app_es-ES.arb
#	lib/l10n/arb/app_id.arb
#	lib/l10n/arb/app_pt-PT.arb
2026-02-12 01:10:04 +07:00
zarzet 66a9daf733 chore: minor formatting and whitespace cleanup 2026-02-12 01:06:27 +07:00
zarzet 69a9e0cb40 feat: add library scan notifications - progress, complete, failed, cancelled notifications for local library scan - new notification channel for library scan - Android only 2026-02-12 01:06:17 +07:00
zarzet cd6beaa7d4 feat: add filterContributingArtistsInAlbumArtist setting - new option to strip contributing artists from Album Artist metadata - applies to folder organization and metadata embedding - collapsible Artist Name Filters section in download settings UI 2026-02-12 01:06:08 +07:00
zarzet 5f4ff17630 refactor(ios): remove legacy download handlers, use downloadByStrategy only 2026-02-12 01:02:48 +07:00
zarzet 3c3bbe516e v3.6.6: fix iOS downloads, metadata fallback, lossy quality display, audio duration accuracy 2026-02-12 00:32:40 +07:00
zarzet a1d1ab1f0f fix: preserve extended metadata during fallback, accurate lossy quality display, SAF improvements
- Add Genre/Label/Copyright fields to DownloadResult struct
- buildDownloadSuccessResponse now prefers service result metadata over request
- enrichRequestExtendedMetadata fetches Deezer metadata by ISRC before download
- Flutter sends copyright in download request payload
- History merge preserves existing genre/label/copyright on re-download
- Accurate MP3 duration via Xing/VBRI VBR headers, MPEG2/2.5 bitrate tables
- Accurate Opus/Vorbis duration via last Ogg page granule position
- Bitrate field added to LibraryScanResult, LocalLibraryItem, DB v4 migration
- Lossy formats display format+bitrate instead of fake 16-bit quality
- Local library file date uses fileModTime instead of scannedAt
- SAF URI recovery for transient FD paths after download
- Improved SAF repair and download history path matching in library scan
- Extract quality probe logic into reusable enrichResultQualityFromFile
2026-02-12 00:19:02 +07:00
Zarz Eleutherius ab9456fff8 New translations app_en.arb (Turkish) 2026-02-11 16:12:15 +07:00
Zarz Eleutherius 2f673469aa New translations app_en.arb (Hindi) 2026-02-11 16:12:14 +07:00
Zarz Eleutherius 05fde22075 New translations app_en.arb (Indonesian) 2026-02-11 16:12:13 +07:00
Zarz Eleutherius deab7b7dd6 New translations app_en.arb (Chinese Traditional) 2026-02-11 16:12:11 +07:00
Zarz Eleutherius ae5da3b6e0 New translations app_en.arb (Chinese Simplified) 2026-02-11 16:12:10 +07:00
Zarz Eleutherius 4d0c8f49aa New translations app_en.arb (Russian) 2026-02-11 16:12:08 +07:00
Zarz Eleutherius 3068f4e367 New translations app_en.arb (Portuguese) 2026-02-11 16:12:07 +07:00
Zarz Eleutherius 3844704490 New translations app_en.arb (Dutch) 2026-02-11 16:12:06 +07:00
Zarz Eleutherius 12144b8220 New translations app_en.arb (Korean) 2026-02-11 16:12:04 +07:00
Zarz Eleutherius b639080494 New translations app_en.arb (Japanese) 2026-02-11 16:12:03 +07:00
Zarz Eleutherius e67d7d68cb New translations app_en.arb (German) 2026-02-11 16:12:01 +07:00
Zarz Eleutherius b8f18c1cf5 New translations app_en.arb (Spanish) 2026-02-11 16:12:00 +07:00
Zarz Eleutherius 529958c4af New translations app_en.arb (French) 2026-02-11 16:11:59 +07:00
Zarz Eleutherius 40077a577c Update source file app_en.arb 2026-02-11 16:11:57 +07:00
Zarz Eleutherius e0fbd706ce Merge pull request #145 from zarzet/renovate/go-dependencies
fix(deps): update go dependencies
2026-02-11 16:10:59 +07:00
Zarz Eleutherius b76879f204 Merge pull request #148 from zarzet/renovate/go-1.x
chore(deps): update dependency go to 1.26
2026-02-11 16:10:47 +07:00
zarzet 564dd8bf95 refactor: migrate queue_tab cover resolver to shared service, add supporter 2026-02-11 12:43:51 +07:00
zarzet b317f7cd76 fix: show library filter buttons while downloads are active
Previously filter/sort headers in All, Albums, and Singles tabs
were hidden when queue items existed, preventing users from
filtering their library (e.g. find MP3 tracks to re-download
as FLAC) during active downloads.
2026-02-11 12:43:51 +07:00
zarzet a3b49d2642 docs: add batch 3 performance entries to changelog, fix device ID entry 2026-02-11 12:43:51 +07:00
zarzet 6f20620c97 perf: parallel I/O, caching, and chunked DB operations (batch 3)
- Orphan cleanup: parallel file existence checks (chunk 16)
- LocalLibraryState: O(1) findByTrackAndArtist via _byTrackKey map
- Local library load: parallel DB + SharedPreferences fetch
- Legacy mod-time backfill: chunked parallel File.stat (chunk 24)
- Downloaded album screen: cache disc groups, quality, cover path
- Local album screen: cache common quality, map-based batch delete
- Cache management: parallel async init, chunked directory cleanup
- Cover resolver: throttled preview exists check (2.2s interval)
- History/Library DB: chunked SQL DELETE (500 per batch)
- Batch delete screens: O(1) item lookup via tracksById map
2026-02-11 12:43:50 +07:00
zarzet b6a055a01a docs: add performance, security, and UI sections to v3.6.5 changelog 2026-02-11 12:43:50 +07:00
zarzet 44ac593ddc perf+security: polling guards, sensitive data redaction, SAF path sanitization
Go backend:
- Add sensitive data redaction in log buffer (tokens, keys, passwords)
- Validate extension auth URLs (HTTPS only, no private IPs, no embedded creds)
- Block embedded credentials in extension HTTP requests
- Tighten extension storage file permissions (0644 -> 0600)
- Sanitize extension ID in store download path
- Summarize auth URLs in logs to prevent token leakage

Android (Kotlin):
- Add sanitizeRelativeDir to prevent path traversal in SAF operations
- Apply sanitizeFilename to all user-provided file names in SAF

Flutter:
- Add sensitive data redaction in Dart logger (mirrors Go patterns)
- Mask device ID in log exports
- Add in-flight guard to progress polling (download queue + local library)
- Remove redundant _downloadedSpotifyIds Set, use _bySpotifyId map
- Remove redundant _isrcSet, use _byIsrc map
- Expand DownloadQueueLookup with byItemId and itemIds
- Lazy search index building in queue tab
- Bound embedded cover cache in queue tab (max 180)
- Coalesce embedded cover refresh callbacks via postFrameCallback
- Cache album track filtering in downloaded album screen
- Cache thumbnail sizes by extension ID in home tab
- Simplify recent access aggregation (single-pass)
- Remove unused _isTyping state in home tab
- Cap pre-warm track batch size to 80
- Skip setShowingRecentAccess if value unchanged
- Use downloadQueueLookupProvider for granular queue selectors
- Move grouped album filtering before content data computation
2026-02-11 12:43:50 +07:00
zarzet ca4c2a661e perf: memory and rebuild optimizations across app
- Bound Deezer cache with LRU eviction and periodic cleanup
- Configure Flutter image cache limits (240 entries / 60 MiB)
- Add ResizeImage wrapper for precacheImage calls
- Add memCacheWidth/cacheWidth to cover images across screens
- Add DownloadedEmbeddedCoverResolver as centralized cover service
- Throttle download progress notifications with dedup checks
- Normalize progress/speed/bytes values to reduce UI rebuilds
- Optimize queue list with per-item ConsumerWidget and RepaintBoundary
- Preserve derived indexes in LocalLibraryState.copyWith
- Skip non-error logs when detailed logging disabled
- Use async file stat and early-break loops in queue filters
2026-02-11 12:43:50 +07:00
zarzet 8b3b39f390 ui: improve cover preview in edit metadata sheet and user changes
- Cover preview enlarged from 120x120 to 160x160 with shadow and better styling
- Layout changed from Wrap to Row with Expanded for side-by-side covers
- Label moved below image with labelMedium typography
- Cover editor section moved to top of edit form
- Added embedded cover preview cache with LRU eviction in metadata screen
- Added current cover extraction and preview in edit metadata sheet
- Added metadata sync to download history after edits
- Added embedded cover extraction cache in queue tab for downloaded items
- Added SAF mod-time tracking for cover refresh after metadata changes
2026-02-11 12:43:50 +07:00
zarzet 915934e5dd fix: various improvements and fixes 2026-02-11 12:43:50 +07:00
zarzet 42f15018ae v3.6.5: audio format conversion, PC v7.0.8 backend merge, Amazon re-enabled 2026-02-11 12:43:49 +07:00
zarzet 3554a7b5b9 chore: remove Buy Me a Coffee references (account suspended) 2026-02-11 12:43:49 +07:00
zarzet f2941939b7 refactor: preserve extension ID case in DownloadByStrategy, only lowercase built-in providers 2026-02-11 12:43:49 +07:00
zarzet 1a77ded997 refactor: remove deprecated download methods from PlatformBridge and MainActivity 2026-02-11 12:43:49 +07:00
zarzet 05d25d4d7c v3.6.1: fix lyrics_mode, notification v20, SAF duplicate, primary artist setting, unified download strategy 2026-02-11 12:43:49 +07:00
zarzet 7cc1fef989 feat: primary artist only folders, fix notifications v20, fix SAF duplicate dirs
- Add 'Use Primary Artist Only' setting to strip featured artists from folder names
- Fix flutter_local_notifications v20 breaking changes (positional params)
- Fix SAF duplicate folder bug: synchronized ensureDocumentDir to prevent race condition creating empty folders with (1), (2) suffixes during concurrent downloads
2026-02-11 12:43:49 +07:00
zarzet abc599d7f9 refactor: migrate queue_tab cover resolver to shared service, add supporter 2026-02-11 12:31:47 +07:00
renovate[bot] eefbb63299 fix(deps): update go dependencies 2026-02-11 05:31:18 +00:00
renovate[bot] fdbb474763 chore(deps): update dependency go to 1.26 2026-02-11 05:30:57 +00:00
zarzet 6a7eef6956 chore: add Elias el Autentico to supporters list 2026-02-11 12:28:50 +07:00
zarzet 9b27e86e0f fix: show library filter buttons while downloads are active
Previously filter/sort headers in All, Albums, and Singles tabs
were hidden when queue items existed, preventing users from
filtering their library (e.g. find MP3 tracks to re-download
as FLAC) during active downloads.
2026-02-11 08:01:11 +07:00
zarzet dbe8f5d814 docs: add batch 3 performance entries to changelog, fix device ID entry 2026-02-11 02:41:32 +07:00
zarzet 9847594ca1 perf: parallel I/O, caching, and chunked DB operations (batch 3)
- Orphan cleanup: parallel file existence checks (chunk 16)
- LocalLibraryState: O(1) findByTrackAndArtist via _byTrackKey map
- Local library load: parallel DB + SharedPreferences fetch
- Legacy mod-time backfill: chunked parallel File.stat (chunk 24)
- Downloaded album screen: cache disc groups, quality, cover path
- Local album screen: cache common quality, map-based batch delete
- Cache management: parallel async init, chunked directory cleanup
- Cover resolver: throttled preview exists check (2.2s interval)
- History/Library DB: chunked SQL DELETE (500 per batch)
- Batch delete screens: O(1) item lookup via tracksById map
2026-02-11 02:40:09 +07:00
Zarz Eleutherius 4a966e5e52 Update README 2026-02-11 02:23:38 +07:00
Zarz Eleutherius d8ba4549aa Merge pull request #144 from Amonoman/main
Update Screenshots
2026-02-11 02:21:09 +07:00
zarzet 986f5eafc8 docs: add performance, security, and UI sections to v3.6.5 changelog 2026-02-11 02:10:28 +07:00
zarzet 84df64fcfe perf+security: polling guards, sensitive data redaction, SAF path sanitization
Go backend:
- Add sensitive data redaction in log buffer (tokens, keys, passwords)
- Validate extension auth URLs (HTTPS only, no private IPs, no embedded creds)
- Block embedded credentials in extension HTTP requests
- Tighten extension storage file permissions (0644 -> 0600)
- Sanitize extension ID in store download path
- Summarize auth URLs in logs to prevent token leakage

Android (Kotlin):
- Add sanitizeRelativeDir to prevent path traversal in SAF operations
- Apply sanitizeFilename to all user-provided file names in SAF

Flutter:
- Add sensitive data redaction in Dart logger (mirrors Go patterns)
- Mask device ID in log exports
- Add in-flight guard to progress polling (download queue + local library)
- Remove redundant _downloadedSpotifyIds Set, use _bySpotifyId map
- Remove redundant _isrcSet, use _byIsrc map
- Expand DownloadQueueLookup with byItemId and itemIds
- Lazy search index building in queue tab
- Bound embedded cover cache in queue tab (max 180)
- Coalesce embedded cover refresh callbacks via postFrameCallback
- Cache album track filtering in downloaded album screen
- Cache thumbnail sizes by extension ID in home tab
- Simplify recent access aggregation (single-pass)
- Remove unused _isTyping state in home tab
- Cap pre-warm track batch size to 80
- Skip setShowingRecentAccess if value unchanged
- Use downloadQueueLookupProvider for granular queue selectors
- Move grouped album filtering before content data computation
2026-02-11 02:02:03 +07:00
zarzet a9150b85b9 perf: memory and rebuild optimizations across app
- Bound Deezer cache with LRU eviction and periodic cleanup
- Configure Flutter image cache limits (240 entries / 60 MiB)
- Add ResizeImage wrapper for precacheImage calls
- Add memCacheWidth/cacheWidth to cover images across screens
- Add DownloadedEmbeddedCoverResolver as centralized cover service
- Throttle download progress notifications with dedup checks
- Normalize progress/speed/bytes values to reduce UI rebuilds
- Optimize queue list with per-item ConsumerWidget and RepaintBoundary
- Preserve derived indexes in LocalLibraryState.copyWith
- Skip non-error logs when detailed logging disabled
- Use async file stat and early-break loops in queue filters
2026-02-11 01:44:05 +07:00
zarzet 68e6c8be35 ui: improve cover preview in edit metadata sheet and user changes
- Cover preview enlarged from 120x120 to 160x160 with shadow and better styling
- Layout changed from Wrap to Row with Expanded for side-by-side covers
- Label moved below image with labelMedium typography
- Cover editor section moved to top of edit form
- Added embedded cover preview cache with LRU eviction in metadata screen
- Added current cover extraction and preview in edit metadata sheet
- Added metadata sync to download history after edits
- Added embedded cover extraction cache in queue tab for downloaded items
- Added SAF mod-time tracking for cover refresh after metadata changes
2026-02-11 01:13:24 +07:00
zarzet bd42655c0e fix: various improvements and fixes 2026-02-11 00:22:48 +07:00
zarzet fe1c96ea12 v3.6.5: audio format conversion, PC v7.0.8 backend merge, Amazon re-enabled 2026-02-10 23:35:41 +07:00
zarzet bae2bf63eb chore: remove Buy Me a Coffee references (account suspended) 2026-02-10 20:46:45 +07:00
Zarz Eleutherius 803e0dc5a3 New translations app_en.arb (Turkish) 2026-02-10 19:46:50 +07:00
Zarz Eleutherius 474c37ec8e New translations app_en.arb (Hindi) 2026-02-10 19:46:46 +07:00
Zarz Eleutherius eb7726263a New translations app_en.arb (Indonesian) 2026-02-10 19:46:45 +07:00
Zarz Eleutherius f87ccc51c5 New translations app_en.arb (Chinese Traditional) 2026-02-10 19:46:43 +07:00
Zarz Eleutherius b0b4e7803c New translations app_en.arb (Chinese Simplified) 2026-02-10 19:46:42 +07:00
Zarz Eleutherius 450f19c656 New translations app_en.arb (Russian) 2026-02-10 19:46:41 +07:00
Zarz Eleutherius 55b9c08f99 New translations app_en.arb (Portuguese) 2026-02-10 19:46:39 +07:00
Zarz Eleutherius a5f3aab775 New translations app_en.arb (Dutch) 2026-02-10 19:46:38 +07:00
Zarz Eleutherius 7442c9b106 New translations app_en.arb (Korean) 2026-02-10 19:46:37 +07:00
Zarz Eleutherius ae66cb478b New translations app_en.arb (Japanese) 2026-02-10 19:46:35 +07:00
Zarz Eleutherius 2516c3e618 New translations app_en.arb (German) 2026-02-10 19:46:34 +07:00
Zarz Eleutherius 02a5893279 New translations app_en.arb (Spanish) 2026-02-10 19:46:33 +07:00
Zarz Eleutherius bd0d653210 New translations app_en.arb (French) 2026-02-10 19:46:31 +07:00
Zarz Eleutherius 62626ddc08 Update source file app_en.arb 2026-02-10 19:46:29 +07:00
zarzet b6574f0097 refactor: preserve extension ID case in DownloadByStrategy, only lowercase built-in providers 2026-02-10 12:30:38 +07:00
zarzet c35a8dd803 refactor: remove deprecated download methods from PlatformBridge and MainActivity 2026-02-10 10:16:55 +07:00
zarzet d54b2249b6 v3.6.1: fix lyrics_mode, notification v20, SAF duplicate, primary artist setting, unified download strategy 2026-02-10 10:11:02 +07:00
zarzet f7be2c1e12 feat: primary artist only folders, fix notifications v20, fix SAF duplicate dirs
- Add 'Use Primary Artist Only' setting to strip featured artists from folder names
- Fix flutter_local_notifications v20 breaking changes (positional params)
- Fix SAF duplicate folder bug: synchronized ensureDocumentDir to prevent race condition creating empty folders with (1), (2) suffixes during concurrent downloads
2026-02-10 09:07:18 +07:00
Amonoman 309568becc Readd Screenshots 2026-02-09 20:35:15 +01:00
Amonoman dd9b6dbfe3 Delete assets/images/4.jpg 2026-02-09 20:29:39 +01:00
Amonoman 4692b48174 Delete assets/images/3.jpg 2026-02-09 20:29:29 +01:00
Amonoman db82fa3ae1 Delete assets/images/2.jpg 2026-02-09 20:29:21 +01:00
Amonoman 5c42507b12 Delete assets/images/1.jpg 2026-02-09 20:29:12 +01:00
zarzet ebe7d87da7 docs: update VirusTotal hash for v3.6.0 2026-02-10 01:03:58 +07:00
zarzet 3a6b7eed59 perf: swap SpotubeDL as primary YouTube provider, Cobalt as fallback 2026-02-10 00:47:44 +07:00
zarzet 51d02d7764 chore: bump app_info version to 3.6.0+77 2026-02-09 23:36:34 +07:00
zarzet df39d61ed4 feat: save cover art, save lyrics, re-enrich metadata with full SAF support + YouTube Cobalt provider with SpotubeDL fallback + metadata summary logging 2026-02-09 23:07:18 +07:00
Zarz Eleutherius 9cd2b1d8c5 New translations app_en.arb (Russian) 2026-02-09 19:41:00 +07:00
Zarz Eleutherius 49f1fb43fa New translations app_en.arb (Spanish) 2026-02-09 19:40:58 +07:00
zarzet 7ec5d28caf feat: add YouTube provider for lossy downloads via Cobalt API
- New YouTube download provider with Opus 256kbps and MP3 320kbps options
- SongLink/Odesli integration for Spotify/Deezer ID to YouTube URL conversion
- YouTube video ID detection for YT Music extension compatibility
- Parallel cover art and lyrics fetching during download
- Queue progress shows bytes (X.X MB) for streaming downloads
- Full metadata embedding: cover, lyrics, title, artist, album, track#, disc#, year, ISRC
- Removed Tidal HIGH (lossy AAC) option - use YouTube for lossy instead
- Bumped version to 3.6.0
2026-02-09 18:15:43 +07:00
zarzet 23f5aa11b0 feat: responsive layout tuning, cache management page, and improved recent access UX
- Add responsive scaling across album, artist, playlist, downloaded album, local album, queue, setup, and tutorial screens to prevent overflow on smaller devices
- Add new Storage & Cache management page (Settings > Storage & Cache) with per-category clear and cleanup actions
- Extract normalizedHeaderTopPadding utility for consistent app bar padding
- Improve home search Recent Access behavior: show when focused with empty input, hide stale results during active recent mode
- Add excluded-downloaded-count tracking to local library scan stats
- Add recentEmpty and recentShowAllDownloads l10n keys (EN + ID)
- Add full cache management l10n keys (EN + ID)
- Fix about_page indentation and formatting consistency
- Fix appearance_settings_page formatting
- Fix downloaded_album_screen and local_album_screen formatting and responsive sizing
2026-02-09 15:58:50 +07:00
zarzet 5fdf1df5df feat: cross-script transliteration matching for Tidal/Qobuz and skip-downloaded option for CSV import 2026-02-09 10:57:52 +07:00
Zarz Eleutherius 65b521ff8b New translations app_en.arb (Turkish) 2026-02-08 19:17:59 +07:00
Zarz Eleutherius 6d578694e2 New translations app_en.arb (Hindi) 2026-02-08 19:17:58 +07:00
Zarz Eleutherius f7ec649b24 New translations app_en.arb (Indonesian) 2026-02-08 19:17:57 +07:00
Zarz Eleutherius 71a9e1baef New translations app_en.arb (Chinese Traditional) 2026-02-08 19:17:56 +07:00
Zarz Eleutherius 4a4adcb72e New translations app_en.arb (Chinese Simplified) 2026-02-08 19:17:55 +07:00
Zarz Eleutherius 3458f03158 New translations app_en.arb (Russian) 2026-02-08 19:17:54 +07:00
Zarz Eleutherius 4fe4a01840 New translations app_en.arb (Portuguese) 2026-02-08 19:17:53 +07:00
Zarz Eleutherius e5d6fddeda New translations app_en.arb (Dutch) 2026-02-08 19:17:52 +07:00
Zarz Eleutherius 370f5e3b8b New translations app_en.arb (Korean) 2026-02-08 19:17:51 +07:00
Zarz Eleutherius f5bb0820d5 New translations app_en.arb (Japanese) 2026-02-08 19:17:50 +07:00
Zarz Eleutherius feb6da3ecb New translations app_en.arb (German) 2026-02-08 19:17:48 +07:00
Zarz Eleutherius 39f28a12aa New translations app_en.arb (Spanish) 2026-02-08 19:17:47 +07:00
Zarz Eleutherius 416fc79637 New translations app_en.arb (French) 2026-02-08 19:17:46 +07:00
Zarz Eleutherius 1f43780bec Update source file app_en.arb 2026-02-08 19:17:44 +07:00
zarzet f9dd82010f fix: skip M4A conversion for existing files and prevent empty SAF folders on duplicates 2026-02-08 15:44:05 +07:00
zarzet f0790b627d perf: optimize album, artist, and playlist screens
- Scope settingsProvider watches with select() for localLibrary flags

- Wrap popular track items in Consumer for scoped provider watches

- Apply dart format reformatting
2026-02-08 15:00:57 +07:00
zarzet 55350fffa0 perf: optimize home tab and queue tab widget rebuilds
- Use ValueNotifier+ValueListenableBuilder for file existence checks instead of setState

- Scope Riverpod watches with field-level select() to reduce unnecessary rebuilds

- Pass precomputed params to _TrackItemWithStatus to avoid per-item provider watches

- Memoize filter/sort computations per build pass

- Isolate queue header/list into dedicated Consumer slivers

- Fix Positioned/ValueListenableBuilder nesting order in grid view
2026-02-08 14:20:18 +07:00
zarzet 7229602343 feat: replace date filter with sorting (latest/oldest/A-Z/Z-A)
- Remove broken date range filter (today/week/month/year)
- Add sort options: Latest, Oldest, A-Z, Z-A
- Sorting applies to tracks (all/singles tabs) and albums tab
- Add l10n keys for sort labels
2026-02-08 13:44:02 +07:00
zarzet 1c81c53699 fix: library filters now apply to date/albums and update tab counts
- Remove redundant manual export button from queue header
- Add date range filtering support for local library items
- Apply advanced filters (date, quality, format, source) to album tab
- Tab chip counts (All/Albums/Singles) now reflect filtered results
- Extract reusable filter helpers: _passesDateFilter, _passesQualityFilter, _passesFormatFilter
- Add _filterGroupedAlbums and _filterGroupedLocalAlbums methods
2026-02-08 13:09:19 +07:00
zarzet 5256d6197b fix: metadata enrichment bug and upgrade go-flac to v2
- Fix metadata enrichment bug where failed downloads poison connection pool
  - Create separate metadataTransport for Deezer API calls
  - Add immediate connection cleanup after download failures
- Fix Samsung One UI local library scan with MediaStore fallback
- Fix 'In Library' tracks still showing as downloadable
- Upgrade go-flac packages to v2 (flacpicture v2.0.2, flacvorbis v2.0.2, go-flac v2.0.4)
- Update CHANGELOG.md v3.5.2
2026-02-08 12:01:08 +07:00
Zarz Eleutherius 79a6c8cdc0 Merge pull request #139 from zarzet/renovate/major-go-dependencies
fix(deps): update go dependencies to v2 (major)
2026-02-08 08:31:29 +07:00
renovate[bot] aa3b4d7d1e fix(deps): update go dependencies to v2 2026-02-07 21:39:25 +00:00
zarzet cd220a4650 merge: sync main into dev (README updates) 2026-02-08 02:51:05 +07:00
Zarz Eleutherius d71b2a9ab8 Update README to remove Search Source and enhance Telegram links 2026-02-08 02:48:29 +07:00
zarzet a2efe7243d docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:36:15 +07:00
zarzet e0acda14e4 docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:33:56 +07:00
Zarz Eleutherius 029ab8ea47 Update VirusTotal badge link in README 2026-02-08 02:30:22 +07:00
zarzet 38f9498006 docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:26:27 +07:00
zarzet 67fc3e5de2 fix: revert AGP 9 to 8.13.2 - Flutter plugins not yet compatible with AGP 9 2026-02-07 20:46:23 +07:00
zarzet f1e6e9253f fix: opt out of AGP 9 newDsl for Flutter compatibility 2026-02-07 20:26:59 +07:00
zarzet 11c612e270 fix: remove kotlin-android plugin for AGP 9 built-in Kotlin support 2026-02-07 20:12:26 +07:00
zarzet cec5e49659 fix(deps): migrate flutter_local_notifications to v20 named params, update changelog with all dependency changes since 3.5.0 2026-02-07 20:02:11 +07:00
Zarz Eleutherius 1dbdb5f2c3 Update VirusTotal badge link in README 2026-02-07 19:57:44 +07:00
zarzet 086511d3e9 perf: unified parallel scheduler, dynamic concurrency 1-5, log truncation + FFmpeg command redaction 2026-02-07 19:57:44 +07:00
zarzet 3d366d21b7 perf: optimize providers, throttle polling, queued settings save, remove dead screens 2026-02-07 19:57:44 +07:00
zarzet 35f412dbd2 perf: replace PaletteService with blurred cover background, bump v3.5.1 2026-02-07 19:57:44 +07:00
Zarz Eleutherius c167aa0522 Merge pull request #136 from zarzet/renovate/major-go-dependencies
fix(deps): update go dependencies to v2 (major)
2026-02-07 19:56:07 +07:00
Zarz Eleutherius fccb3f3d78 Merge pull request #135 from zarzet/renovate/major-flutter-dependencies
fix(deps): update flutter dependencies (major)
2026-02-07 19:54:49 +07:00
Zarz Eleutherius 3a33283e94 Merge pull request #133 from zarzet/renovate/major-gradle-dependencies
chore(deps): update plugin com.android.application to v9
2026-02-07 19:49:33 +07:00
Zarz Eleutherius c74fb28a3a Merge pull request #131 from zarzet/renovate/actions-setup-java-5.x
chore(deps): update actions/setup-java action to v5
2026-02-07 19:49:18 +07:00
renovate[bot] ea504cc3ed fix(deps): update go dependencies to v2 2026-02-07 12:48:36 +00:00
renovate[bot] 61a2ad258e fix(deps): update flutter dependencies 2026-02-07 12:48:16 +00:00
Zarz Eleutherius ab62a8b1a9 Merge pull request #134 from zarzet/renovate/softprops-action-gh-release-2.x
chore(deps): update softprops/action-gh-release action to v2
2026-02-07 19:48:04 +07:00
Zarz Eleutherius 479eb1272d Merge pull request #132 from zarzet/renovate/major-github-artifact-actions
chore(deps): update github artifact actions (major)
2026-02-07 19:47:28 +07:00
renovate[bot] d23562e579 chore(deps): update softprops/action-gh-release action to v2 2026-02-07 12:47:07 +00:00
renovate[bot] 541d64bdd0 chore(deps): update plugin com.android.application to v9 2026-02-07 12:47:04 +00:00
renovate[bot] d4f7e6e494 chore(deps): update github artifact actions 2026-02-07 12:47:00 +00:00
renovate[bot] 532c08fe2e chore(deps): update actions/setup-java action to v5 2026-02-07 12:46:56 +00:00
Zarz Eleutherius 704b9674f4 Merge pull request #128 from zarzet/renovate/actions-cache-5.x
chore(deps): update actions/cache action to v5
2026-02-07 19:35:15 +07:00
Zarz Eleutherius 3de94280d2 Merge pull request #129 from zarzet/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2026-02-07 19:34:45 +07:00
Zarz Eleutherius 65897789f6 Merge pull request #130 from zarzet/renovate/actions-setup-go-6.x
chore(deps): update actions/setup-go action to v6
2026-02-07 19:34:29 +07:00
renovate[bot] 5d097c3a95 chore(deps): update actions/setup-go action to v6 2026-02-07 12:32:50 +00:00
renovate[bot] 4023e752a0 chore(deps): update actions/checkout action to v6 2026-02-07 12:32:47 +00:00
Zarz Eleutherius 9a722b1a24 Merge pull request #127 from zarzet/renovate/gradle-dependencies
fix(deps): update gradle dependencies
2026-02-07 19:31:18 +07:00
Zarz Eleutherius 481b4b03dc New translations app_en.arb (Turkish) 2026-02-07 19:20:11 +07:00
Zarz Eleutherius b7fd2f7902 New translations app_en.arb (Hindi) 2026-02-07 19:20:10 +07:00
Zarz Eleutherius f2e1e59d6a New translations app_en.arb (Indonesian) 2026-02-07 19:20:09 +07:00
Zarz Eleutherius 3af2ecf1f4 New translations app_en.arb (Chinese Traditional) 2026-02-07 19:20:08 +07:00
Zarz Eleutherius 1b2f2c891c New translations app_en.arb (Chinese Simplified) 2026-02-07 19:20:07 +07:00
Zarz Eleutherius 155f3259f2 New translations app_en.arb (Russian) 2026-02-07 19:20:06 +07:00
Zarz Eleutherius f52d8d68b8 New translations app_en.arb (Portuguese) 2026-02-07 19:20:05 +07:00
Zarz Eleutherius 216d6e152c New translations app_en.arb (Dutch) 2026-02-07 19:20:04 +07:00
Zarz Eleutherius b6f90e727c New translations app_en.arb (Korean) 2026-02-07 19:20:03 +07:00
Zarz Eleutherius 790bbc544f New translations app_en.arb (Japanese) 2026-02-07 19:20:02 +07:00
Zarz Eleutherius bd511f7dc6 New translations app_en.arb (German) 2026-02-07 19:20:01 +07:00
Zarz Eleutherius e91c8c28a8 New translations app_en.arb (Spanish) 2026-02-07 19:20:00 +07:00
Zarz Eleutherius 3c6d1afa97 New translations app_en.arb (French) 2026-02-07 19:19:59 +07:00
Zarz Eleutherius 3947e109b4 Update source file app_en.arb 2026-02-07 19:19:57 +07:00
renovate[bot] 37b4727a29 chore(deps): update actions/cache action to v5 2026-02-07 11:49:57 +00:00
renovate[bot] 2604d0002a fix(deps): update gradle dependencies 2026-02-07 11:49:46 +00:00
Zarz Eleutherius cca337ab31 Merge pull request #125 from zarzet/renovate/go-dependencies
chore(deps): update dependency go to v1.25.7
2026-02-07 18:48:46 +07:00
renovate[bot] bb6e766a09 chore(deps): update dependency go to v1.25.7 2026-02-07 09:14:48 +00:00
Zarz Eleutherius af203ae51f Update VirusTotal badge link in README 2026-02-07 14:44:19 +07:00
zarzet 01cbdde70e Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-02-07 14:39:08 +07:00
Zarz Eleutherius e70ed311ed Merge pull request #123 from zarzet/renovate/com.android.tools-desugar_jdk_libs-2.x
chore(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5
2026-02-07 14:36:30 +07:00
Zarz Eleutherius c732cddf06 Merge pull request #122 from zarzet/renovate/golang.org-x-mobile-digest
chore(deps): update golang.org/x/mobile digest to 1dceadb
2026-02-07 14:36:16 +07:00
zarzet 1f71f957e2 chore: add Renovate config targeting dev branch with automerge 2026-02-07 14:35:37 +07:00
renovate[bot] 757c5fab19 chore(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5 2026-02-07 07:32:37 +00:00
renovate[bot] cfa537db1f chore(deps): update golang.org/x/mobile digest to 1dceadb 2026-02-07 07:32:34 +00:00
zarzet 8b18bef5ab feat: add history message to donate notice card, fix l10n_id formatting 2026-02-07 13:53:13 +07:00
zarzet 76b01fb837 fix: SAF file descriptor handling to avoid ParcelFileDescriptor detach warning 2026-02-07 13:52:57 +07:00
zarzet 219ea593dd chore: add l10n strings for incremental scan and orphan cleanup 2026-02-07 13:20:15 +07:00
zarzet 5c54e04b69 feat: cleanup orphaned downloads from history 2026-02-07 13:20:00 +07:00
zarzet bef07b1583 feat: incremental library scan support and force full scan button 2026-02-07 13:19:46 +07:00
zarzet 859762e35c fix(l10n): improve Indonesian wording for orphaned download cleanup 2026-02-07 13:14:13 +07:00
zarzet ca136b8e17 fix: stabilize incremental library scan and fold 3.5.1 into 3.5.0 2026-02-07 13:11:23 +07:00
zarzet 03d29a73f7 feat: donate page - add GitHub Sponsors, custom icons, improved notice card 2026-02-07 12:40:46 +07:00
zarzet c6ee9cda35 fix: resolve Go staticcheck warnings in audio_metadata.go and qobuz.go 2026-02-07 11:58:46 +07:00
zarzet ad3fefac0b fix: skip tutorial for existing users upgrading to 3.5.0
Migration v2: auto-set hasCompletedTutorial=true when isFirstLaunch
is already false (existing users who completed setup before tutorial
feature was added)
2026-02-07 11:55:43 +07:00
zarzet ad606cca53 feat: v3.5.0 - SAF storage, onboarding redesign, library scan fixes
- SAF Storage Access Framework for Android 10+ downloads
- Redesigned Setup/Tutorial screens with Material 3 Expressive
- Library scan hero card now shows real-time scanned count
- Library folder picker uses SAF (no MANAGE_EXTERNAL_STORAGE needed)
- SAF migration prompt for users updating from pre-SAF versions
- Home feed caching, donate page, per-app language support
- Merged 3.6.0-beta.1 changelog entries into 3.5.0
2026-02-07 11:48:37 +07:00
zarzet c0a9cb756f chore: bump version to 3.5.0-beta.1 2026-02-07 08:13:23 +07:00
zarzet 5fa00c0051 feat: v3.5.0 - instant home feed, SAF display path, per-app language
- Cache home feed to SharedPreferences for instant restore on app launch
- Resolve SAF tree URIs to human-readable paths (e.g. /storage/emulated/0/Music)
- Add Android 13+ per-app language support (locale_config.xml)
- Bump version to 3.5.0+73
2026-02-06 21:22:56 +07:00
zarzet 239e073a8c feat: improve SAF file descriptor handling and Android platform compatibility
- Migrate MainActivity from FlutterActivity to FlutterFragmentActivity for SAF picker compatibility
- Add ImpellerAwareFlutterFragment to support Impeller fallback on legacy devices
- Add output_fd support in Go backend for direct file descriptor writes (SAF)
- Add helper functions in output_fd.go for FD-based file operations
- Refactor Tidal/Qobuz/Amazon downloaders to support FD output and skip metadata embedding for SAF (handled by Flutter)
- Add extractQobuzDownloadURLFromBody with unit tests for robust URL parsing
- Add storage mode picker (SAF vs App folder) in download settings for Android
- Fix FFmpeg output path building to avoid same-path conflicts
- Embed metadata to SAF FLAC files via temp file bridge in Flutter
- Upgrade Gradle wrapper to 9.3.1 and add activity-ktx dependency
2026-02-06 18:47:16 +07:00
Zarz Eleutherius bf87662f99 New translations app_en.arb (Russian) 2026-02-06 18:33:40 +07:00
zarzet 278ebf3472 feat: add Storage Access Framework (SAF) support for Android 10+
- Add SAF tree picker and persistent URI storage in settings
- Implement SAF file operations: exists, delete, stat, copy, create
- Update download pipeline to support SAF content URIs
- Add fallback to app-private storage when SAF write fails
- Support SAF in library scan with DocumentFile traversal
- Add history item repair for missing SAF URIs
- Create file_access.dart utilities for abstracted file operations
- Update Tidal/Qobuz/Amazon/Extensions for SAF-aware output
- Add runPostProcessingV2 API for SAF content URIs
- Update screens (album, artist, queue, track) for SAF awareness

Resolves Android 10+ scoped storage permission issues
2026-02-06 07:09:57 +07:00
Zarz Eleutherius 4273edd836 New translations app_en.arb (Russian) 2026-02-05 13:19:12 +07:00
Zarz Eleutherius 7ce41fc1c1 Update source file app_en.arb 2026-02-05 13:19:10 +07:00
zarzet 7ade57e010 perf: optimize all providers for mobile networks with retry logic
- Add retry logic with exponential backoff to all providers (Qobuz, Tidal, Amazon, Deezer)
- Increase API timeouts: 15s → 25s (Qobuz/Tidal/Deezer), 30s (Amazon)
- Extract QobuzID/TidalID directly from SongLink URLs
- Add SongLink lookup strategy before ISRC search in Qobuz
- Cache hit now uses GetTrackByID() directly instead of re-searching
- Pre-warm cache tries SongLink first before direct ISRC search
2026-02-05 09:12:25 +07:00
Zarz Eleutherius 6e7c766945 Fix VirusTotal badge link formatting in README 2026-02-04 18:29:22 +07:00
Zarz Eleutherius 55b457a4c0 Update VirusTotal badge link in README
Updated VirusTotal badge link in README.md.
2026-02-04 18:28:50 +07:00
zarzet 65a152cada fix: persist metadata and download provider priority across app restarts
- Save priority order to SharedPreferences when set
- Load from SharedPreferences on app start, sync to Go backend
- Fixes issue where custom order reverted to default after restart
2026-02-04 17:45:07 +07:00
Zarz Eleutherius fb7a576e00 New translations app_en.arb (Turkish) 2026-02-04 12:53:12 +07:00
Zarz Eleutherius 30a559b279 New translations app_en.arb (Hindi) 2026-02-04 12:53:11 +07:00
Zarz Eleutherius f77d5fdf14 New translations app_en.arb (Indonesian) 2026-02-04 12:53:10 +07:00
Zarz Eleutherius 0a0667889c New translations app_en.arb (Chinese Traditional) 2026-02-04 12:53:09 +07:00
Zarz Eleutherius 14d8cd54d7 New translations app_en.arb (Chinese Simplified) 2026-02-04 12:53:08 +07:00
Zarz Eleutherius 5fa3d405e6 New translations app_en.arb (Russian) 2026-02-04 12:53:07 +07:00
Zarz Eleutherius 34eb335fd0 New translations app_en.arb (Portuguese) 2026-02-04 12:53:06 +07:00
Zarz Eleutherius c910530927 New translations app_en.arb (Dutch) 2026-02-04 12:53:05 +07:00
Zarz Eleutherius 69e1a6cf6b New translations app_en.arb (Korean) 2026-02-04 12:53:04 +07:00
Zarz Eleutherius bd84613624 New translations app_en.arb (Japanese) 2026-02-04 12:53:02 +07:00
Zarz Eleutherius 0b4777fc6b New translations app_en.arb (German) 2026-02-04 12:53:01 +07:00
Zarz Eleutherius e22813caec New translations app_en.arb (Spanish) 2026-02-04 12:53:00 +07:00
Zarz Eleutherius 8f6e8432de New translations app_en.arb (French) 2026-02-04 12:52:59 +07:00
Zarz Eleutherius b3c98cecc3 New translations app_en.arb (Turkish) 2026-02-02 08:25:49 +07:00
Zarz Eleutherius 49a18a977b New translations app_en.arb (Hindi) 2026-02-02 08:25:48 +07:00
Zarz Eleutherius a5d0feeedf New translations app_en.arb (Indonesian) 2026-02-02 08:25:47 +07:00
Zarz Eleutherius a574e73b44 New translations app_en.arb (Chinese Traditional) 2026-02-02 08:25:46 +07:00
Zarz Eleutherius a66f6a739f New translations app_en.arb (Chinese Simplified) 2026-02-02 08:25:45 +07:00
Zarz Eleutherius cc7e1b54b6 New translations app_en.arb (Russian) 2026-02-02 08:25:44 +07:00
Zarz Eleutherius 28cb7fcd3d New translations app_en.arb (Portuguese) 2026-02-02 08:25:43 +07:00
Zarz Eleutherius aeb370beca New translations app_en.arb (Dutch) 2026-02-02 08:25:42 +07:00
Zarz Eleutherius 239707e2da New translations app_en.arb (Korean) 2026-02-02 08:25:41 +07:00
Zarz Eleutherius c1e2778735 New translations app_en.arb (Japanese) 2026-02-02 08:25:40 +07:00
Zarz Eleutherius fb608a554d New translations app_en.arb (German) 2026-02-02 08:25:39 +07:00
Zarz Eleutherius 7561065802 New translations app_en.arb (Spanish) 2026-02-02 08:25:38 +07:00
Zarz Eleutherius 56c8d89999 New translations app_en.arb (French) 2026-02-02 08:25:37 +07:00
Zarz Eleutherius 9192760f3c Update source file app_en.arb 2026-02-02 08:25:35 +07:00
Zarz Eleutherius 423695c24d Update VirusTotal badge link in README.md 2026-02-01 22:00:38 +07:00
Zarz Eleutherius 40ec24db69 New translations app_en.arb (Turkish) 2026-02-01 08:04:39 +07:00
Zarz Eleutherius ba8d0a3438 New translations app_en.arb (Hindi) 2026-02-01 08:04:38 +07:00
Zarz Eleutherius 82decf99a6 New translations app_en.arb (Indonesian) 2026-02-01 08:04:37 +07:00
Zarz Eleutherius 6ba9fc1fec New translations app_en.arb (Chinese Traditional) 2026-02-01 08:04:36 +07:00
Zarz Eleutherius 715d94c2ed New translations app_en.arb (Chinese Simplified) 2026-02-01 08:04:35 +07:00
Zarz Eleutherius e1a722f479 New translations app_en.arb (Russian) 2026-02-01 08:04:34 +07:00
Zarz Eleutherius edbe12c512 New translations app_en.arb (Portuguese) 2026-02-01 08:04:33 +07:00
Zarz Eleutherius 9fc6542792 New translations app_en.arb (Dutch) 2026-02-01 08:04:32 +07:00
Zarz Eleutherius 4c01ee26c2 New translations app_en.arb (Korean) 2026-02-01 08:04:31 +07:00
Zarz Eleutherius 813b9fcf61 New translations app_en.arb (Japanese) 2026-02-01 08:04:30 +07:00
Zarz Eleutherius fe070e0177 New translations app_en.arb (German) 2026-02-01 08:04:29 +07:00
Zarz Eleutherius 423bb87ed8 New translations app_en.arb (Spanish) 2026-02-01 08:04:28 +07:00
Zarz Eleutherius 1641f51b0c New translations app_en.arb (French) 2026-02-01 08:04:27 +07:00
Zarz Eleutherius 3f78a1f3d1 Update source file app_en.arb 2026-02-01 08:04:25 +07:00
359 changed files with 242176 additions and 57880 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.41.5"
}
+20
View File
@@ -0,0 +1,20 @@
* text=auto eol=lf
# Windows scripts
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# Binary files
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.zip binary
*.jar binary
*.aar binary
*.keystore binary
*.jks binary
-1
View File
@@ -1,4 +1,3 @@
github: zarzet
ko_fi: zarzet
buy_me_a_coffee: zarzet
+1 -1
View File
@@ -4,5 +4,5 @@ contact_links:
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
about: Check the README for setup instructions and FAQ
- name: Extension Development Guide
url: https://zarz.moe/docs
url: https://spotiflac.zarz.moe/docs
about: Documentation for building SpotiFLAC extensions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 2 # Need previous commit to compare
+44
View File
@@ -0,0 +1,44 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
paths:
- 'site/**'
- '.github/workflows/pages.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+147 -86
View File
@@ -60,23 +60,23 @@ jobs:
df -h
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "17"
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version: "1.25.8"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
- name: Cache Gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
@@ -93,12 +93,12 @@ jobs:
# Accept licenses
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
# Install NDK r29 (supports 16KB page size for Android 15+)
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
$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/27.3.13750724" >> $GITHUB_ENV
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
- name: Install gomobile
run: |
@@ -158,28 +158,33 @@ jobs:
ls -la
- name: Upload APK artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: android-apk
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
build-ios:
runs-on: macos-latest
runs-on: macos-15
needs: get-version # Only depends on version, NOT android build!
steps:
- name: Checkout repository
uses: actions/checkout@v4
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@v5
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version: "1.25.8"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
- name: Cache CocoaPods
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ios/Pods
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
@@ -252,6 +257,15 @@ jobs:
- name: Get Flutter dependencies
run: flutter pub get
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
run: |
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
while IFS= read -r -d '' f; do
perl -pi -e 's/\r$//' "$f"
chmod +x "$f"
echo "Normalized line endings: $f"
done
- name: Generate app icons
run: dart run flutter_launcher_icons
@@ -295,7 +309,7 @@ jobs:
fi
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ios-ipa
path: build/ios/ipa/SpotiFLAC-*.ipa
@@ -308,43 +322,33 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0 # Full history needed for git-cliff
- name: Extract changelog for version
- name: Generate changelog with git-cliff
id: changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip header
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OUTPUT: /tmp/changelog.txt
- name: Show generated changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
echo "Looking for version: $VERSION_NUM"
# Extract changelog section for this version using sed
# Find the line with version, then print until next version header or end
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
# If no changelog found, use default message
if [ -z "$CHANGELOG" ]; then
echo "No changelog found for version $VERSION_NUM"
CHANGELOG="See CHANGELOG.md for details."
else
echo "Found changelog content"
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
fi
# Save to file for multiline support
echo "$CHANGELOG" > /tmp/changelog.txt
echo "Extracted changelog:"
echo "Generated changelog:"
cat /tmp/changelog.txt
- name: Download Android APK
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: android-apk
path: ./release
- name: Download iOS IPA
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: ios-ipa
path: ./release
@@ -352,15 +356,22 @@ jobs:
- name: Prepare release body
run: |
VERSION=${{ needs.get-version.outputs.version }}
cat > /tmp/release_body.txt << 'HEADER'
### What's New
HEADER
cat /tmp/changelog.txt >> /tmp/release_body.txt
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD)
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true)
# Start with git-cliff changelog, but replace its compare footer with a
# deterministic previous-tag lookup from git.
sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt
if [ -n "$PREVIOUS_TAG" ]; then
printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \
"$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \
>> /tmp/release_body.txt
fi
# Append download section
cat >> /tmp/release_body.txt << FOOTER
---
@@ -385,7 +396,7 @@ jobs:
cat /tmp/release_body.txt
- name: Create Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
@@ -396,6 +407,63 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-altstore:
runs-on: ubuntu-latest
needs: [get-version, build-ios, create-release]
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
permissions:
contents: write
steps:
- name: Checkout main branch
uses: actions/checkout@v6
with:
ref: main
- name: Download iOS IPA
uses: actions/download-artifact@v7
with:
name: ios-ipa
path: ./release
- name: Update apps.json
run: |
VERSION="${{ needs.get-version.outputs.version }}"
VERSION_NUM="${VERSION#v}"
DATE=$(date -u +%Y-%m-%d)
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
if [ -z "$IPA_FILE" ]; then
echo "WARNING: IPA file not found, skipping apps.json update"
exit 0
fi
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
if [ ! -f apps.json ]; then
echo "WARNING: apps.json not found on main, skipping"
exit 0
fi
jq --arg ver "$VERSION_NUM" \
--arg date "$DATE" \
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
--argjson size "$IPA_SIZE" \
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
echo "Updated apps.json:"
cat apps.json
- name: Commit and push
run: |
VERSION="${{ needs.get-version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add apps.json
git diff --cached --quiet && echo "No changes to commit" || \
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
notify-telegram:
runs-on: ubuntu-latest
needs: [get-version, create-release]
@@ -403,66 +471,59 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download Android APK
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: android-apk
path: ./release
- name: Download iOS IPA
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: ios-ipa
path: ./release
- name: Extract changelog for version
- name: Generate changelog with git-cliff for Telegram
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip all
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OUTPUT: /tmp/cliff_tg.txt
- name: Convert changelog for Telegram
id: changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v}
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
# Use tr -d '\r' to handle CRLF line endings from Windows
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
if [ -z "$FULL_CHANGELOG" ]; then
CHANGELOG="See release notes on GitHub for details."
if [ ! -s /tmp/cliff_tg.txt ]; then
echo "See release notes on GitHub for details." > /tmp/changelog.txt
else
# Convert GitHub Markdown to Telegram HTML:
# - **text** → <b>text</b>
# - `code` → <code>code</code>
# - ### Header → <b>Header</b>
# - Escape HTML special chars first
# - Remove > blockquote prefix
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
sed 's/^> //' | \
# Convert Markdown to Telegram HTML
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
sed '/^\*\*Full Changelog\*\*/d' | \
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^- /• /g' | \
sed 's/^ - / ◦ /g')
# Take first 2500 characters, then cut at last complete line
sed 's/^- /• /g')
# Truncate for Telegram 4096 char limit
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
# Check if truncated
FULL_LEN=${#FULL_CHANGELOG}
if [ $FULL_LEN -gt 2500 ]; then
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
fi
echo "$CHANGELOG" > /tmp/changelog.txt
fi
echo "$CHANGELOG" > /tmp/changelog.txt
echo "DEBUG: Final changelog:"
echo "Telegram changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel
+13 -1
View File
@@ -12,6 +12,9 @@ Thumbs.db
# Kiro specs (development only)
.kiro/
# Design assets (banners, mockups)
design/
# Reference folder (development only)
referensi/
@@ -41,6 +44,7 @@ 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
@@ -54,7 +58,6 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
# Extension folder
extension/
@@ -63,7 +66,12 @@ extension/
AGENTS.md
# Temp/misc
.tmp/
nul
NUL
network_requests.txt
*.bak
/AndroidManifest.xml
# Log files
*.log
@@ -73,3 +81,7 @@ flutter_*.log
# Development tools
tool/
.claude/settings.local.json
.playwright-mcp/
# FVM Version Cache
.fvm/
+744 -3
View File
@@ -1,5 +1,746 @@
# Changelog
## [3.7.2] - 2026-03-07
### Changed
- **Amazon Music is now an Extension**: Amazon Music has been moved from a built-in service to a separate installable extension. Install the "Amazon Music" extension from the Store to continue using it.
### Fixed
- **Deezer Downloads Timing Out**: Deezer downloads were failing with "context deadline exceeded" on larger files. Now uses the proper download timeout, matching Tidal and Qobuz.
- **iOS Local Library Scan Fails**: Local library scanning was failing on iOS because the app lost access to user-picked folders after the FilePicker session ended. Implemented iOS security-scoped bookmark system:
- When a library folder is picked on iOS, a security-scoped bookmark is created and persisted in settings (`localLibraryBookmark`)
- Before each scan, the bookmark is resolved and security-scoped access is started; access is released in `finally` block after scan completes
- `cleanupMissingFiles` also activates the bookmark before checking file existence on iOS
- New `AppDelegate.swift` method channel handlers: `createIosBookmarkFromPath`, `startAccessingIosBookmark`, `stopAccessingIosBookmark`, `resolveIosBookmark`
- New `PlatformBridge` methods: `createIosBookmarkFromPath()`, `startAccessingIosBookmark()`, `stopAccessingIosBookmark()`
- All scan call-sites (Library Settings, Queue tab, Local Album screen) now pass the iOS bookmark to `startScan()`
### Added
- **Amazon Music Extension**: Available in `extension/Amazon-SpotiFLAC/` — same functionality as before, now as an installable extension.
- **Accessibility Tooltips**: Added localized tooltips to all `IconButton` and `PopupMenuButton` widgets across the entire UI for screen reader and long-press discoverability
- Back buttons use `MaterialLocalizations.backButtonTooltip`
- Close buttons use `MaterialLocalizations.closeButtonTooltip`
- Menu buttons use `MaterialLocalizations.showMenuTooltip`
- Search buttons use `MaterialLocalizations.searchFieldLabel`
- Contextual actions use descriptive labels: "Play track", "Dismiss", "Clear search", "Change folder", "Refresh"
- Screens affected: Album, Artist, Playlist, Downloaded Album, Local Album, Home, Search, Queue, Library Playlists, Library Tracks Folder, Setup, Tutorial, Track Metadata, Store, Extension Store Details, and all Settings sub-pages (About, Appearance, Cache Management, Donate, Download, Extensions, Extension Detail, Library, Log, Options, Provider Priority)
- **Semantics Wrappers**: Added `Semantics` widgets to interactive elements that previously had no accessibility information
- Album tiles in Artist screen: announces selection state and album name
- Recently downloaded track tiles in Home tab: announces track name and artist
- Explore items (albums/artists/playlists) in Home tab: announces item type and name
- Color palette picker in Appearance settings: announces selected state and color hex value
- Download button demo in Tutorial screen: added `ExcludeSemantics` on icon to prevent duplicate screen reader announcements
- Queue tab playlist cards: announces playlist name and item count
- Queue tab downloaded album cards: announces album name, artist, and track count
- Queue tab local album cards: announces album name, artist, and track count
- Queue tab play button on completed downloads: announces track name and artist with `ExcludeSemantics` on icon
- Queue tab download status indicators: "Finalizing download", "Download completed", "Downloaded file missing" labels with `ExcludeSemantics` on icons
### Improved
- **Code Formatting**: Reformatted and corrected indentation across multiple files to comply with Dart style guidelines
- `extension_detail_page.dart`: Fixed `SliverAppBar` and all subsequent slivers indentation (was 2 spaces short)
- `log_screen.dart`: Fixed `SliverAppBar` indentation alignment
- `donate_page.dart`: Reformatted ternary expressions and `_cr` function body
- `library_tracks_folder_screen.dart`: Minor line-break formatting
---
## [3.7.1] - 2026-03-06
### Added
- **Deezer Download Service**: Deezer is now available as a built-in download service (FLAC CD Quality).
- **Smarter YouTube Downloads**: If the YouTube Music extension is installed, the app now uses it first to find the correct song — more accurate than SongLink, especially for new releases.
- **Songs-Only Search Filter**: YouTube Music extension search now filters results server-side, so you only get actual songs — no music videos or covers mixed in.
- **Qobuz Squid.wtf Fallback**: Added Squid.wtf as an additional Qobuz download provider.
- **Qobuz Search Fallback**: If Qobuz API search returns nothing, the app now tries the Qobuz web store as a backup to find the track.
- **Better ISRC Lookup**: Tracks can now be resolved via ISRC even without a Spotify ID, using Deezer as an intermediary.
### Fixed
- **Download Queue Stability**: Fixed duplicate queue item IDs, cancel not working reliably, and "Clear All" not properly stopping active downloads.
- **Queue Restore on Restart**: Duplicate or broken queue item IDs are now auto-fixed when the app restarts.
### Changed
- **Update Checker**: The app can now detect updates across all versions, not just within the same major version.
- **Localization Cleanup**: Cleaned up and consolidated translation files across all 13 supported languages.
---
## [3.7.0] - 2026-03-04
Hey everyone, thank you so much for sticking with SpotiFLAC Mobile.
Starting from this release, we're rolling the version back from **v4.x to v3.x**.
### Removed
- **Internal Audio Player** — Removed `just_audio`, `audio_service`, and `audio_session` dependencies entirely. The internal playback engine (smart queue, media notification, shuffle/repeat, lyrics sync, prefetch, playback state persistence) has been completely removed. Playback now delegates to the system's external player.
- **PlaybackItem Model** — No longer needed without internal playback.
- **MiniPlayerBar Widget** — Removed the in-app mini player UI.
- **Media Notification Controls** — Removed notification drawables (`ic_stat_favorite`, `ic_stat_favorite_border`) and the `keep.xml` resource file.
- **Player Mode Setting** — The `playerMode` setting has been removed since external player is now the only mode.
- **Online Playback Feature** — Online streaming mode, DASH pipeline, and related components introduced in v4.0.0 are gone from the main branch.
### Changed
- **MainActivity** now extends `FlutterFragmentActivity` directly (previously `AudioServiceFragmentActivity`).
- **PlaybackController** simplified from ~1200 lines to ~87 lines — now only resolves local file paths and opens them via external player.
- **ProGuard rules** cleaned up — removed audio_service/just_audio/audio_session rules.
- **Qobuz** migrated to MusicDL API (Thanks @Ruubiiiii for Hosting the API).
### Note
There are three main reasons behind this decision:
1. **Respecting the API providers** — After giving it some thought, we realized that the streaming feature was indirectly hurting the API providers who have been generous enough to make their services available. They already offer streaming directly on their own websites, and it only feels right to direct streaming usage back to their platforms.
2. **Long-term sustainability** — We want SpotiFLAC to be around for as long as possible. Keeping certain features in the app could attract unwanted attention and put the project's continued existence at risk. Removing them is a proactive step to keep things running smoothly for everyone.
**Still want online playback? Check out these services:**
- [DabMusic](https://dabmusic.xyz)
- [SquidWTF](https://tidal.squid.wtf)
Thank you for your understanding and continued support. This decision was made to ensure the long-term sustainability of the app and to respect the ecosystem that has been supporting SpotiFLAC all along. You guys are the best, and we truly appreciate each and every one of you!
---
## [3.6.0] - 2026-02-19
### Added
- **Library Tab Redesign**: Wishlist, Loved, and individual Playlist collections now appear as unified list/grid items in the "All" tab alongside tracks, replacing the old "My Folders" horizontal card section
- **Drag-and-Drop Track Categorization**: Long-press-drag tracks onto playlist items to add them to that playlist; when multiple tracks are selected and one is dragged, all selected tracks are added to the target playlist
- Drag feedback widget displays multi-select count badge
- **Playlist Multi-Select Deletion**: Long-press playlists to enter selection mode, select multiple playlists, and batch-delete all selected at once via a dedicated selection bottom bar
- **Track Categorization System**: Tracks added to any playlist are automatically hidden from the main tracks list; removing a track from a playlist or deleting the playlist makes the track reappear — no actual file deletion ever occurs
- **Create Playlist Button**: New "+" `TextButton.icon` in Library tab header with dynamic theme colors, replacing the old "Select" button
- **Track Options Bottom Sheet**: Rewrote `TrackCollectionQuickActions` from inline action buttons to a single styled bottom sheet with track header (cover, title, artist), divider, and option tiles matching `DownloadServicePicker` visual style
- **Library Tracks Folder SliverAppBar**: Wishlist, Loved, and Playlist detail screens now feature a collapsible SliverAppBar with cover art (45% viewport height, parallax, gradient overlay), mode-specific icons (bookmark/heart/queue_music), title, and track count badge
- **Custom Playlist Cover Images**: Users can set custom cover images for playlists via long-press menu or camera icon in SliverAppBar
- Covers stored locally in app support directory with priority: custom cover > first track URL > icon fallback
- Cover options bottom sheet with change/remove actions
- Playlist list screen shows cover thumbnails
- **Long-Press Context Menus**: Track tiles in library folders and playlist list items now use long-press for styled bottom sheet context menus instead of trailing icon buttons, matching platform conventions
- **Wishlist Quick Download**: Tapping a track in Wishlist opens quality picker (respects "Ask quality before download" setting) and starts download
- **Playlist Track Playback**: Tapping a downloaded track in a Playlist opens it in the device's external music player via `openFile()` with file existence check
- **Collapsible AppBar on Playlist List Screen**: Playlist list screen now uses a collapsible SliverAppBar matching Settings sub-page style (animated title size 20→28px, animated left padding 56→24px) for visual consistency
- **`UnifiedLibraryItem.collectionKey` Getter**: Efficient playlist membership checking without constructing a full `Track` object
- **Multi-select Share**: Share multiple downloaded/local tracks at once from the selection bottom bar
- Supports SAF content URIs via native `ACTION_SEND_MULTIPLE` intent
- Supports regular file paths via SharePlus
- Available in Downloaded Album, Local Album, and Queue tab screens
- **Multi-select Batch Convert**: Convert multiple selected tracks to MP3 or Opus in one operation
- Bottom sheet UI with format (MP3 / Opus) and bitrate (128k / 192k / 256k / 320k) selection
- Full SAF support: copies to temp, converts, writes back, deletes original, updates history
- Progress and result snackbar feedback during conversion
- Available in Downloaded Album, Local Album, and Queue tab screens
- **Native `shareMultipleContentUris`**: New Android `ACTION_SEND_MULTIPLE` handler in `MainActivity` for sharing multiple SAF content URIs
- **Localization**: Added selection share/convert strings to all 13 supported locales (`selectionShareCount`, `selectionShareNoFiles`, `selectionConvertCount`, `selectionConvertNoConvertible`, `selectionBatchConvertConfirmTitle`, `selectionBatchConvertConfirmMessage`, `selectionBatchConvertProgress`, `selectionBatchConvertSuccess`)
- **Localization**: Added library collection l10n keys (`trackOptionAddToLoved`, `trackOptionRemoveFromLoved`, `trackOptionAddToWishlist`, `trackOptionRemoveFromWishlist`, `libraryTracksUnit`, `collectionPlaylistChangeCover`, `collectionPlaylistRemoveCover`)
- **Global Network Compatibility Mode**: New Download settings toggle to help restricted/ISP-filtered networks
- Applies to backend API requests (not SongLink-only)
- Enables HTTP scheme fallback and optional insecure TLS behavior in one switch
- Synced end-to-end across Flutter settings, platform channel (Android/iOS), and Go backend
### Changed
- **Removed "My Folders" Section**: Horizontal card section removed from Library tab header; collections are now inline items in the unified main list/grid
- **Playlist Subtitle Simplified**: Playlist items now show "N tracks" instead of "Playlist • N tracks"
- **Pinned App Bar on All Detail Screens**: `SliverAppBar` changed from `pinned: false` to `pinned: true` in 6 detail screens (album, downloaded album, local album, playlist, track metadata, library tracks folder) so the app bar stays visible when scrolling
- **Local Album Multi-select Action Updated**: Replaced batch `Share` action with batch `Re-enrich`
- Local album selection bar now uses `Re-enrich` + `Convert` actions
- Added batch re-enrich processing for local tracks (FLAC native path and MP3/Opus FFmpeg path, including SAF write-back flow)
- After batch re-enrich completes, local library is refreshed via incremental scan so updated metadata appears in UI immediately
- **Queue Multi-select Local Action Updated**: Queue selection bar now switches the first action to `Re-enrich` when selected items are local-only
- If selection contains downloaded or mixed items, action remains `Share`
- Local-only selection now supports batch re-enrich with the same native/FFmpeg + SAF flow and auto-refreshes local library metadata after completion
- **SongLink Network Option Scope Expanded**: The previous SongLink compatibility path now routes through global network compatibility controls so all supported backend API clients can benefit under problematic networks
- **Removed Per-Track Action Buttons**: Album, playlist, home, artist, and search screens no longer show individual download/add buttons on each track tile; all actions accessed via `TrackCollectionQuickActions` bottom sheet
- **Loved SliverAppBar Always Shows Heart Icon**: Loved tracks folder always displays the heart icon as cover, never uses first track's cover art (like Spotify's Liked Songs)
- **Wishlist Long-Press Menu Conditional Actions**: "Add to Playlist" option only appears when the track is already downloaded
- **Loved Track Tap Disabled**: Tapping a track in the Loved folder performs no action (long-press for options only)
- **Removed Duplicate Create Playlist Button**: Removed `+` IconButton from playlist list screen AppBar since the FAB already serves the same purpose
- **`coverImagePath` Field on `UserPlaylistCollection`**: Model now supports nullable custom cover path with `copyWith` using `String? Function()?` pattern for explicit null assignment
### Fixed
- **Local Cover Path Handling**: All cover image renderers (Library tab, playlist detail screen hero cover, per-track tiles, options bottom sheet) now detect whether `coverUrl` is a URL or local file path and use `Image.file` for local paths instead of `CachedNetworkImage`
- **Empty Playlists Now Clickable**: Empty playlist items in Library tab can now be tapped to navigate to their detail screen
- **RenderFlex Overflow**: Fixed overflow in unified library item Row layout when track metadata text was too long
- **SAF FD Permission Denied on Tidal Downloads**: Fixed `failed to create file: open /proc/self/fd/*: permission denied` on some devices/providers
- Android SAF bridge now hands off detached raw FD (`output_fd`) to Go instead of forcing procfs path reopen
- Go output writer includes safer procfs fallback behavior for providers that reject truncate semantics
- **Batch Convert Lyrics Embedding Gap**: Batch convert in Downloaded Album, Local Album, and Queue now preserves/adds lyrics consistently like single convert
- Reuses embedded lyrics when available
- Falls back to sidecar `.lrc` when present
- Falls back to online lyrics fetch and injects into conversion metadata when embedding is enabled
---
## [3.6.9] - 2026-02-17
### Added
- **YouTube Bitrate Presets**: YouTube bitrate selection now uses supported presets only
- Opus: 128 / 256 kbps
- MP3: 128 / 256 / 320 kbps
- **Go Test Coverage for YouTube Quality Parsing**: Added tests for supported-bitrate normalization behavior
- **Localization for YouTube Bitrate UI**: Added localized strings (EN/ID) for YouTube bitrate titles and labels
### Fixed
- **Cover Image Cache Clear Not Working**: Clearing "Cover image cache" now performs a full on-disk wipe, clears in-memory image cache, and reinitializes cache manager state
- Prevents stale/orphaned cache files from keeping the same storage usage after clear
- **YouTube Queue Fallback Quality Mismatch**: Queue fallback now normalizes YouTube quality IDs so conversion paths use valid bitrate format IDs
### Changed
- **Default Lyrics Behavior**: `Apple/QQ Multi-Person Word-by-Word` is now OFF by default for new installs
- **Removed Dynamic YouTube Bitrate Mode**: Arbitrary values are now normalized to nearest supported Spotube preset across settings, picker, queue fallback, and Go backend parser
- **Lyrics Embedding Control**: Users can now disable the embedded-lyrics process from settings (`Embed Lyrics` off)
---
## [3.6.8] - 2026-02-14
### Added
- **Lyrics Source Tracking**: Track Metadata screen now displays the source of loaded lyrics (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music, Embedded, or Extension)
- New `getLyricsLRCWithSource` API returns lyrics with source metadata
- Source badge appears below lyrics section in Track Metadata screen
- **Dedicated Lyrics Provider Priority Page**: Lyrics providers can now be configured from a dedicated settings page with full-screen reorderable list
- Replaced inline bottom sheet with `LyricsProviderPriorityPage`
- Cleaner UI with provider descriptions and priority ordering
- **Paxsenix Integration**: Added Paxsenix API as official lyrics proxy partner for Apple Music, QQ Music, Musixmatch, and Netease sources
- Listed in About page and Partners page on project site
- README updated with partner attribution
### Fixed
- **LRC Background Vocal Preservation**: Apple Music/QQ Music `[bg:...]` background vocal tags are now preserved during LRC parsing instead of being stripped
- Background vocals attach to the previous timed line in exported LRC files
- **LRC Display Improvements**:
- Inline word-by-word timestamps (`<mm:ss.xx>`) are stripped from lyrics display
- Speaker prefixes (`v1:`, `v2:`) are removed for cleaner display
- Multi-line background vocals converted to readable secondary vocal lines
- **Apple Music Lyrics Case Sensitivity**: Fixed `lyricsType` comparison to use case-insensitive matching for "Syllable" type
### Changed
- Track Metadata lyrics fetching now uses `getLyricsLRCWithSource` for consistent source attribution across embedded and online lyrics
---
## [3.6.7] - 2026-02-13
### Added
- "Advanced Filename Templates" - new placeholders for custom track/disc formatting and date patterns
- `{track_raw}` and `{disc_raw}` - unpadded raw numbers
- `{track:N}` and `{disc:N}` - zero-padded to N digits (e.g. `{track:02}``01`)
- `{date}` - full release date from metadata
- `{date:%Y-%m-%d}` - date formatting with strftime patterns
- "Show advanced tags" toggle in Settings > Download > Filename Format to reveal these placeholders
- Low-RAM / ARM32-only device profiling - detects constrained devices at startup and reduces image cache (120 items / 24 MiB) and disables overscroll effects for smoother performance
- Responsive selection bar on artist screen - switches to compact stacked layout on narrow screens (< 430dp) or large text scale (> 1.15x)
- Quality picker dialog before downloading individual tracks from artist screen (when "Ask quality before download" is enabled)
- Project website with GitHub Pages deployment workflow
- Mobile burger menu navigation for all site pages
- Go filename template test suite
- "Lyrics Provider" extension type - extensions can now provide lyrics (synced or plain text) via `fetchLyrics()` function
- Lyrics provider extensions are called before built-in providers, giving extensions highest priority
- New `lyrics_provider` manifest type alongside `metadata_provider` and `download_provider`
- Shows "Lyrics Provider" capability badge on extension detail page
- "Lyrics Providers" settings - configurable provider cascade order and per-provider options
- Reorderable provider list: LRCLIB, Musixmatch, Netease, Apple Music, QQ Music
- Netease: toggle translated/romanized lyrics appending
- Apple Music / QQ Music: multi-person word-by-word speaker tags
- Musixmatch: selectable language code for localized lyrics
- "Documentation Search" - global search modal on all site pages
- Opens with Ctrl+K / Cmd+K / `/` keyboard shortcuts on every page
- Search button with bordered pill styling in desktop nav and mobile hamburger menu
- On non-docs pages, search results navigate to the docs page at the matching section
- Full keyboard navigation: arrow keys, Enter to select, Esc to close
### Fixed
- Fixed ICU plural syntax errors in DE, ES, PT, RU translations - incorrect `=1` clause was causing missing plural forms
- Fixed featured-artist regex incorrectly splitting on `&` character (e.g. "Simon & Garfunkel" was being split) - removed `&` from separator pattern
- Fixed `{date}` placeholder not working in filename templates - release date was not being passed to the template builder across all providers (Amazon, Qobuz, Tidal, YouTube, extensions)
### Changed
- Improved Go backend metadata handling - filename builder now supports fallback metadata keys and automatic type conversion for more robust template rendering
- Extension providers now pass full metadata set to filename builder (track, disc, year, date, release_date)
- Updated translations: added filename advanced tags strings (EN, ID), regenerated all locale dart files
- Updated app screenshot assets
---
## [3.6.6] - 2026-02-12
### Added
- "Filter Contributing Artists in Album Artist" setting - strips featured/contributing artists from Album Artist metadata tag
- Library scan notifications (Android and iOS) - shows progress, completion, failure, and cancellation status
- Collapsible "Artist Name Filters" section in download settings UI
### Fixed
- Fixed downloads not working on iOS - missing `downloadByStrategy` and `downloadFromYouTube` method channel handlers in AppDelegate.swift
- Fixed extended metadata (genre, label, copyright) lost during service fallback (e.g. Tidal unavailable, falls back to Qobuz) - Go backend now enriches metadata from Deezer by ISRC before download and preserves it through the fallback chain
- Fixed local library showing incorrect "16-bit" quality label for lossy formats (MP3, Opus) - now displays actual bitrate (e.g. "MP3 320kbps")
- Fixed inaccurate Opus/Vorbis duration calculation (e.g. 4:11 showing as 8:44) - now reads granule position from last Ogg page for precise duration
- Fixed MP3 duration/bitrate inaccuracy for VBR files - added Xing/Info and VBRI header parsing with MPEG2/2.5 bitrate table support
- Fixed Track Metadata screen showing scan date instead of file date for local library items
- Fixed SAF content URI paths displayed as raw `content://` strings in Track Metadata - now shows human-readable paths
### Changed
- Removed legacy iOS download handlers (`downloadTrack`, `downloadWithFallback`, `downloadFromYouTube`) - iOS now uses `downloadByStrategy` only
- Updated translations from Crowdin (all 14 languages)
---
## [3.6.5] - 2026-02-10
### Highlights
- **Audio Format Conversion**: Convert between FLAC, MP3, and Opus directly from Track Metadata screen with full metadata and cover art preservation
- **PC v7.0.8 Backend Merge**: Adopts several Go backend improvements from SpotiFLAC PC v7.0.8 including Amazon encrypted stream support, SpotFetch metadata fallback, and Qobuz API update
- **Amazon Music Re-enabled**: Amazon provider back in service with new API
### Added
- "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization
- Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x`
- Available in Settings > Download > below "Use Album Artist for folders"
- Audio format conversion from Track Metadata screen
- Convert between FLAC, MP3, and Opus formats (any direction)
- Selectable bitrate: 128k, 192k, 256k, 320k
- Full metadata and cover art preservation during conversion
- Confirmation dialog before converting (original file deleted after)
- SAF storage support: copies to temp, converts, writes back via SAF
- Download history automatically updated with new file path
- Unified download request contract (`DownloadRequestPayload`) for all providers/flows
- Includes full superset fields: lyrics mode, genre/label/copyright, provider IDs, SAF params, cover/quality settings
- Added strategy flags in payload: `use_extensions`, `use_fallback`
- New Go unified router entrypoint: `DownloadByStrategy(requestJSON)`
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
- SpotFetch metadata fallback integration for Spotify-blocked regions
- New backend client for `sp.afkarxyz.qzz.io/api`
- Automatic fallback in Spotify metadata fetch path when primary source fails
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
- Includes heuristic detection of lyrics stored in Comment fields
- Edit Metadata now supports manual cover selection (pick/replace cover image) and embeds it into audio tags on save
- Save Lyrics now shows an immediate in-progress snackbar (`Saving lyrics...`) so users know the operation has started
### Changed
- Merged several Go backend improvements from SpotiFLAC PC v7.0.8: Amazon new API with encrypted stream/decryption support, SpotFetch metadata fallback for Spotify-blocked regions, multi-format lyrics extraction (MP3/Opus/OGG), Qobuz Jumo API update.
- Download queue execution now builds one payload and uses a single bridge entrypoint (`PlatformBridge.downloadByStrategy`) instead of branching into multiple bridge methods
- Dart `downloadByStrategy` now sends a single request to Go (`downloadByStrategy` channel); routing concern is centralized in Go backend
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
- Edit Metadata cover section moved to the top of the form and now previews current embedded cover before replacement (plus selected replacement preview)
- Edit Metadata cover preview enlarged (120px to 160px) with shadow, side-by-side layout for current vs selected cover, and label repositioned below image
### Fixed
- Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed"
- Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters
- Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup
- Track Metadata lyrics section now hides "Embed Lyrics" when lyrics are already embedded in file, preventing redundant embed attempts
- Fixed lyrics embed path to support FLAC/MP3/Opus consistently (including SAF files) without forcing unsupported parser paths
- Inconsistent parameter parity across download paths
- `downloadWithExtensions` now carries `copyright`
- YouTube path now carries `embed_max_quality_cover` and metadata parity fields
- Inconsistent success response metadata between direct/fallback flows
- Added shared Go response builder for `DownloadTrack` and `DownloadWithFallback`
- Success responses now consistently include `genre`, `label`, `copyright`, and `lyrics_lrc`
- YouTube success response now also includes extended metadata fields (`cover_url`, `genre`, `label`, `copyright`) for parity with other providers
- Fixed `Save Lyrics` crash on Android (`java.lang.Integer cannot be cast to java.lang.Long`) by normalizing `duration_ms` channel argument as `Number -> Long`
- Fixed FLAC Re-enrich cover edge case where metadata could be written without cover when temp cover file creation failed; FLAC cover embed now uses in-memory bytes and verifies cover after write
- Fixed FLAC picture-block embed robustness by detecting image MIME via magic bytes (JPEG/PNG/GIF/WEBP) instead of relying on filename extension
- Fixed MP3/Opus metadata rewrite flows to preserve existing embedded cover when no new cover is available
- Fixed Library tab cover not updating after manual cover edit/re-embed for downloaded tracks
- Queue/Library now prefers embedded cover art extracted from local files (not just cached `coverUrl`)
- Added per-track extraction cache with file-modification invalidation so updated embedded art is reflected in Library
- Extraction is now on-demand for edited tracks only (not full-library reload)
- Returning from Track Metadata now refreshes cover cache only for the affected track
- Cover refresh is now skipped when file modification time is unchanged, removing unnecessary flash when simply opening/closing metadata screen
- Fixed repeated cover preview extraction in Track Metadata screen (`track_cover_preview_*`) causing visible flash when reopening
- Added in-memory preview cache keyed by file path so reopening metadata reuses existing preview without re-extract
- Cache validation uses file modification time for filesystem paths; SAF paths are refreshed only after successful edit actions
- Queue/Library now also compares SAF file last-modified (`getSafFileModTimes`) before refreshing embedded-cover cache
- Preview cache key is now stable per track item (not volatile temp SAF path), eliminating false cache misses on SAF-backed files
- Track Metadata no longer auto-extracts cover preview on every screen open; extraction now runs only after actual edit/re-enrich changes (or when explicitly forced)
- Track metadata edits/re-enrich now sync updated tags back into `downloadHistoryProvider` + SQLite history rows
- Non-Library screens that read download history (Home/album/history views) now reflect updated title/artist/album/tags without manual rescan
- Track Metadata back-navigation now returns an explicit update result after successful edits/re-enrich, enabling History-tab cover refresh fallback when SAF timestamps are unreliable
### Performance
- Configured Flutter image cache limits (240 entries / 60 MiB) and added `ResizeImage` wrappers for cover art precaching across all screens, reducing peak memory usage on cover-heavy pages
- Added LRU eviction to Deezer cache with configurable max entries per cache type (search/album/artist/ISRC) and periodic expired-entry cleanup to prevent unbounded memory growth in long sessions
- Download progress notifications are now normalized (2-decimal progress, 1-decimal speed, 0.1 MiB byte steps) and deduplicated by track/artist/percent/queue-count, reducing notification overhead during batch downloads
- Each queue item now uses a dedicated `ConsumerWidget` with per-item `.select()` instead of rebuilding the entire list on any item change; items are wrapped in `RepaintBoundary` for paint isolation
- Queue/Library search indexes are now built on-demand per item instead of upfront for all items, with bounded LRU caches (max 4000 entries)
- `copyWith` now preserves derived lookup indexes (ISRC map, track key set) when items list is unchanged, avoiding O(n) rebuild on every scan progress update
- Scan progress polling now compares values before calling `setState`, skipping unnecessary widget rebuilds when nothing changed
- Added in-flight flag to download progress and library scan polling to prevent concurrent timer callbacks from overlapping
- New `DownloadedEmbeddedCoverResolver` service replaces per-screen cover extraction logic with a shared bounded cache (160 entries), mod-time validation, and throttled refresh checks
- Multiple embedded cover change callbacks are now coalesced into a single frame via `addPostFrameCallback`, preventing redundant rebuilds
- Downloaded album screen now caches filtered/sorted track lists and reuses them when the source data reference is unchanged
- Home tab recent downloads now use single-pass aggregation instead of building full per-album lists, and store only IDs instead of full item objects for the clear-all action
- Removed duplicate `_downloadedSpotifyIds` Set and `_isrcSet` (both now use existing map lookups), removed unused `_isTyping` state in home tab
- Track cache pre-warming is now capped at 80 tracks per request to avoid excessive backend calls on large playlists
- About page contributor avatars now use `memCacheWidth`/`memCacheHeight` to decode at display size instead of full resolution
- Orphaned download cleanup now checks file existence in parallel (chunk 16) instead of sequentially
- Local library `findByTrackAndArtist` now uses O(1) map lookup (`_byTrackKey`) instead of O(n) linear scan
- Local library database load and SharedPreferences fetch now run in parallel
- Legacy mod-time backfill now uses chunked parallel `File.stat` (chunk 24) with per-chunk cancel check
- Downloaded album screen now caches disc grouping, sorted disc numbers, common quality, and embedded cover path with reference-identity invalidation
- Local album screen common quality is now computed once during cache rebuild instead of per-build
- Batch delete in album screens now uses O(1) map lookup (`tracksById`) instead of `.where().firstOrNull`
- Cache management page now fires all async init calls in parallel and uses chunked async directory deletion (chunk 24)
- Cover resolver preview file existence check is now throttled (2.2s interval) to reduce synchronous I/O in build path
- History and library database DELETE operations are now chunked (500 per batch) to stay within SQLite variable limits
- Library database `cleanupMissingFiles` now checks file existence in parallel (chunk 16) and deletes in batched SQL
### Security
- All logs (Go and Dart) now automatically redact Bearer tokens, access/refresh tokens, client secrets, API keys, and passwords using regex-based sanitization before storage
- Extension auth URLs are now validated for HTTPS-only, no embedded credentials, and no private/local network targets before opening
- Auth URLs in logs are summarized to scheme+host+path only (query params stripped) to prevent token leakage; token exchange error bodies are truncated and sanitized
- Extension HTTP requests now block URLs with embedded credentials (`user:pass@host`)
- Extension storage files changed from `0644` to `0600` (owner-only read/write)
- All SAF relative directory paths are now sanitized per-segment with `.`/`..` filtering; all user-provided file names pass through `sanitizeFilename()` before use
- Extension ID is sanitized before building download destination path
- Log export device info now shows Build ID and Security Patch level instead of masked Device ID
### Technical
- Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model
- Go strategy router normalizes incoming service casing before dispatch
- Extension runtime: `customSearch` now passes query/options via VM globals instead of string interpolation, preventing parser edge cases on certain devices
- Extension runtime: JS panic handler now logs full stack trace for easier debugging
- `DownloadQueueLookup` expanded with `byItemId` map and `itemIds` list for O(1) queue item access from UI
- Non-error/non-fatal log entries are now skipped entirely (not just hidden) when detailed logging is disabled, reducing buffer growth and Go log polling overhead
### Removed
- Buy Me a Coffee references removed from donate page, FUNDING.yml, README, and all localization files (account suspended)
---
## [3.6.0] - 2026-02-09
### Highlights
- **YouTube Provider (Lossy)**: New download option via Cobalt API for tracks not available on lossless services
- Opus 256kbps (recommended) or MP3 320kbps quality options
- Full metadata embedding: cover art, title, artist, album, track/disc number, year, ISRC
- Lyrics fetching from lrclib.net with embed and external .lrc support
- Works as fallback when Tidal/Qobuz/Amazon downloads fail
- **Edit Metadata**: Edit embedded metadata directly from the Track Metadata screen (FLAC, MP3, Opus)
- Editable fields: Title, Artist, Album, Album Artist, Date, Track#, Disc#, Genre, ISRC
- Advanced fields: Label, Copyright, Composer, Comment
- FLAC: native Go writer, MP3/Opus: FFmpeg-based writer
- UI refreshes in-place after save without needing to re-open the screen
- iOS and Android support
### Added
- Save Cover Art: download high-quality album art as standalone .jpg from track metadata screen
- Save Lyrics (.lrc): fetch and save lyrics as standalone .lrc file without downloading the song
- Re-enrich Metadata: re-embed metadata, cover art, and lyrics into existing audio files without re-downloading (FLAC native, MP3/Opus via FFmpeg)
- Re-enrich now supports local library items: searches Spotify/Deezer by track name + artist to fetch complete metadata from the internet, then embeds cover art, lyrics, genre, label, and all tags into the file
- YouTube download provider using Cobalt API with SongLink/Odesli integration for Spotify/Deezer ID → YouTube URL conversion
- SpotubeDL as fallback Cobalt proxy when primary API fails
- YouTube video ID detection for YT Music extension compatibility
- Parallel cover art and lyrics fetching during YouTube download
- Queue progress now shows "X.X MB" instead of "0%" for streaming downloads where total size is unknown (Cobalt tunnel mode)
- Full metadata pipeline for YouTube downloads: cover art, lyrics, title, artist, album, track#, disc#, year, ISRC
### Changed
- Removed Tidal HIGH (lossy AAC) quality option - use YouTube provider for lossy downloads instead
- Simplified download service picker by removing dead lossy format code
- Removed Amazon from download settings UI (now only used as automatic fallback)
- Cleaned up dead disabled-chip code in download service selector
### Fixed
- Fixed `error.api.youtube.login` by using YouTube Music URLs instead of regular YouTube URLs for Cobalt requests
- Fixed SongLink to prioritize `youtubeMusic` platform URL over `youtube` for Cobalt compatibility
- Fixed YouTube metadata not being overwritten by setting `DisableMetadata: true` in Cobalt requests
- Fixed ISRC validation in metadata enrichment flow - invalid ISRCs no longer trigger failed Deezer lookups
- Fixed YouTube metadata enrichment to work like other providers (SongLink Deezer ID extraction, proper metadata embedding)
- Go metadata parsers now read Composer, Comment, Label, Copyright from FLAC, MP3 (ID3v2.2/v2.3/v2.4), and Opus/OGG files
- Added proper COMM frame parser for ID3v2 (handles language code + description prefix correctly)
- Fixed Re-enrich Metadata failing on SAF storage files (`content://` URIs) - Kotlin now copies SAF file to temp, Go processes temp file, then writes back for FLAC or returns temp path for FFmpeg (MP3/Opus)
- Fixed Save Cover Art and Save Lyrics crashing on SAF-stored download history items - now saves to temp then writes to SAF tree via `createSafFileFromPath`
- Fixed `_getFileDirectory()` crash when called with `content://` URI by adding SAF guard
- Fixed `readAudioMetadata` Kotlin handler not handling SAF URIs - now copies to temp for reading
- Added metadata summary log in Re-enrich flow showing all fields before embedding (title, artist, album, track#, disc#, date, ISRC, genre, label)
---
## [3.5.3] - 2026-02-09
### Added
- CSV import flow now includes a new option: **Skip already downloaded songs** before enqueueing tracks
- Added regression test suite for cross-script matching behavior in Go backend (`go_backend/matching_test.go`)
### Changed
- CSV import confirmation dialog now supports filtering out tracks already present in download history (matched by Spotify ID and ISRC)
- CSV import enqueue feedback now reports added/skipped counts when duplicate downloads are skipped
- Home search now prioritizes **Recent Access** when search field is focused with empty input, even if old search results still exist in memory
- Search filter/result sections are now hidden while Recent Access mode is active to avoid stale-result overlap
- Recent Access now shows a localized empty-state message when no recent items are available
- Normalized collapsing AppBar top inset across iOS/Android so header height/animation stays visually consistent on Apple devices
- Storage & Cache UX improved: `Clear all cache` now preserves web/runtime cache by default (optional), with explicit warnings/actions for runtime cache resets
- Local library settings now include a display count for tracks excluded because they already exist in download history
- Responsive layout tuning applied across key screens to reduce hardcoded-height overflow issues on smaller devices
### Fixed
- Fixed false-positive cross-script matching in Qobuz/Tidal where unrelated titles/artists in different scripts could be incorrectly accepted
- Cross-script title/artist matching now requires transliteration-aware normalization and strict similarity checks instead of auto-accepting script differences
- Qobuz metadata fallback no longer scans all results when zero title matches are found; title verification is now required
- Qobuz metadata final validation now rejects results when title does not match expected track name
- Fixed Home search regression where Recent Access panel could disappear after previous searches
- Fixed Local Library card/layout crash caused by `Flex` usage under unbounded height constraints
- Hardened FFmpeg metadata embedding temp-file naming to prevent rare collisions during parallel downloads/fallback flows (Qobuz → Tidal) that could cause missing embedded metadata
- Fixed SAF external lyrics naming where some providers saved `.lrc` files as `.lrc.txt`; LRC export now uses neutral MIME to preserve `.lrc` extension
## [3.5.2] - 2026-02-08
### Performance
- Home tab search result sections are now virtualized with `SliverList` (lazy item build) instead of eager `Column` rendering, reducing frame drops on large result sets
- Home tab now narrows Riverpod subscriptions using field-level `select(...)` for search/provider state to reduce unnecessary full-tab rebuilds
- Search provider dropdown now watches only required fields (`searchProvider`, `metadataSource`, `extensions`) instead of full provider states
- Track row rendering in Home search now receives precomputed thumbnail sizing/local-library flags from parent to avoid repeated per-item provider watches
- Removed thumbnail `debugPrint` calls inside track row `build()` to reduce runtime overhead during scrolling/rebuilds
- Queue tab root subscription no longer watches full queue item list; it now watches only queue presence (`items.isNotEmpty`) to avoid full Library UI rebuilds on every progress tick
- Queue download header/list rendering has been isolated into dedicated `Consumer` slivers; header now watches only queue length (`items.length`) while item list watches queue item updates
- Queue filter/sort computations are now centralized and memoized per filter mode within a build pass (`all`/`albums`/`singles`), reducing repeated list transforms for chip counts and page content
- Selection bottom bar content is now computed only when selection mode is active, removing hidden-state heavy list preparation
- File existence checks in queue/library rows now use per-path `ValueNotifier` + `ValueListenableBuilder` updates instead of triggering global `setState`, reducing unnecessary whole-tab repaints
### Changed
- Replaced date range filter with sorting options in Library tab: Latest, Oldest, A-Z, Z-A
- Sorting applies to all views: unified items, downloaded albums, and local library albums
- Local library items now use file modification time (`fileModTime`) for sorting instead of scan time, providing more accurate chronological ordering
- Removed redundant manual "Export Failed Downloads" button from Library UI (auto-export setting in Settings is sufficient)
- Library filters (quality, format, source) now correctly apply to album tabs and update tab chip counts (All/Albums/Singles)
### Fixed
- Fixed local library scan crashing on Samsung One UI devices due to MediaStore URI mismatch in SAF tree traversal
- Added MediaStore URI fallback in SAF file reader: when SAF permission is denied for Samsung-returned MediaStore URIs, automatically retries using READ_MEDIA_AUDIO permission
- Hardened SAF scan with per-directory and per-file error handling: scan now skips problematic files instead of aborting entirely
- Added visited directory tracking to prevent infinite loops from circular SAF references
- Fixed metadata enrichment cascading failure after one queued download fails: metadata APIs (Deezer, SongLink, Spotify) now use isolated `metadataTransport` so failed download connections cannot poison metadata requests
- Added immediate connection cleanup on every download failure path (error response and exception), not only periodic cleanup every N downloads
- Fixed incremental SAF scan edge case where `lastModified()` failure could misclassify existing files as removed (`removedUris`)
- Fixed tracks marked "In Library" still showing active download button - download button now shows as completed (checkmark) for local library tracks across all screens (album, playlist, artist, home/search)
- Fixed FFmpeg M4A-to-FLAC conversion erroneously triggered on already-existing FLAC files when re-downloading duplicates via Tidal
- Fixed SAF download creating empty artist/album folders when re-downloading duplicate tracks; directory is now only created after confirming the file does not already exist
## [3.5.1] - 2026-02-08
### Performance
- Removed PaletteService (palette_generator) from all screens for faster navigation and reduced memory usage
- Album, Playlist, Downloaded Album, Local Album, and Track Metadata screens now use blurred cover art as header background instead of dominant color extraction
- Removed `palette_generator` dependency
- App startup now renders immediately (`runApp`) while service initialization runs asynchronously in eager init
- Main shell provider subscriptions now use field-level `select(...)` to reduce unnecessary rebuilds
- Settings persistence now uses single-flight + queued save coalescing to avoid redundant disk writes
- Progress polling cadence adjusted to 800ms for download queue, local library scan progress, and Go log polling
- Android foreground download service progress updates are throttled (change-based updates + 5s heartbeat)
- SAF history repair is now batched (`20` items per batch) and capped per launch (`60`) to reduce startup I/O spikes
- Incremental library scan now builds final item list in-memory instead of reloading from database
- Local cover images in queue/library use direct `Image.file` with `errorBuilder` instead of `FutureBuilder` existence check
- CSV parser `_parseLine` rewritten: correct escaped-quote handling, no quote characters in output
- Removed unused legacy screen files (`home_screen.dart`, `queue_screen.dart`, `settings_screen.dart`, `settings_tab.dart`)
- Incremental local library scan now merges delta results in-memory and sorts once, avoiding full-state reload churn
- Queue local cover rendering now uses direct `Image.file` + `errorBuilder` (removed repeated async file-exists checks)
### Added
- Auto-cleanup orphaned downloads on history load (files that no longer exist are automatically removed from history)
### Changed
- Removed legacy screen files that were no longer used after the tab/part refactor:
- `lib/screens/home_screen.dart`
- `lib/screens/queue_screen.dart`
- `lib/screens/settings_screen.dart`
- `lib/screens/settings_tab.dart`
- Concurrent download limit increased from `3` to `5` (settings clamp + Options UI chips now support `1..5`)
- Download queue now uses a single parallel scheduler path; `1` concurrency is handled as parallel-with-limit-1 (no separate sequential engine)
- Download queue now listens to settings updates in real-time so concurrency/output settings stay in sync while queue is active
### Fixed
- CSV parser now correctly handles escaped quotes (`""`) inside quoted fields during import
- Fixed dynamic concurrency update during active downloads: changing limit (e.g. `1 -> 3`) now schedules additional queued items without waiting current active item to finish
- Queue scheduler now re-checks capacity/queued items on short intervals to avoid blocking on long-running single active download
### Dependencies
#### Flutter
- `flutter_local_notifications` 19.x → 20.0.0 (breaking: all positional params converted to named params)
- `connectivity_plus` 6.x → 7.0.0
- `flutter_secure_storage` 9.x → 10.0.0
- Removed `palette_generator` dependency
#### Go
- `go-flac/go-flac` v1.0.0 → v2.0.4
- `go-flac/flacvorbis` v0.2.0 → v2.0.2
- `go-flac/flacpicture` v0.3.0 → v2.0.2
- Go toolchain 1.24 → 1.25.7
#### Android
- Android Gradle Plugin 8.x → 9.0.0
- Kotlin 2.1.x → 2.3.10
- `desugar_jdk_libs` → 2.1.5
- `kotlinx-coroutines-android` → 1.10.2
- `lifecycle-runtime-ktx` → 2.10.0
- `activity-ktx` → 1.12.3
#### CI/CD
- `actions/cache` v4 → v5
- `actions/checkout` v4 → v6
- `actions/setup-go` v5 → v6
- `actions/setup-java` v4 → v5
- `softprops/action-gh-release` v1 → v2
- GitHub artifact actions updated
---
## [3.5.0] - 2026-02-07
### Highlights
- **SAF Storage (Android 10+)**: Proper Storage Access Framework support for download destination (content URIs)
- Select download folder via SAF tree picker
- Downloads now write to SAF file descriptors (`/proc/self/fd/*`) instead of raw filesystem paths
- Works around Android 10+ scoped storage permission errors
- **Modern Onboarding Experience**: Completely redesigned Setup and Tutorial screens
### Added
- Home feed disk caching via SharedPreferences for instant restore on app startup
- SAF display path resolver in native Android layer (converts tree URIs to readable paths)
- New settings fields for storage mode + SAF tree URI
- SAF platform bridge methods: pick tree, stat/exists/delete, open content URI, copy to temp, write back to SAF
- SAF library scan mode (DocumentFile traversal + metadata read)
- Incremental library scanning for filesystem and SAF paths (only scans new/modified files and detects removed files)
- Force Full Scan action in Library Settings to rescan all files on demand
- Downloaded files are now excluded from Local Library scan results to prevent duplicate entries
- Legacy library rows now support `file_mod_time` backfill before incremental scans (faster follow-up scans after upgrade)
- Library UI toggle to show SAF-repaired history items
- Scan cancelled banner + retry action for library scans
- Android DocumentFile dependency for SAF operations
- Post-processing API v2 (SAF-aware, ready to replace v1)
- Donate page in Settings with Ko-fi and Buy Me a Coffee links
- Per-App Language support on Android 13+ (locale_config.xml)
- Interactive tutorial with working search bar simulation and clickable download buttons
- Tutorial completion state is persisted after onboarding
- Visual feedback animations for page transitions, entrance effects, and feature lists
- New dedicated welcome step in setup wizard with improved branding
### Changed
- Download pipeline supports `output_path` + `output_ext` for Go backend
- Tidal/Qobuz/Amazon/Extension downloads use SAF-aware output when enabled
- Post-processing hooks run for SAF content URIs (via temp file bridge)
- File operations in Library/Queue/Track screens now SAF-aware (`open`, `exists`, `delete`, `stat`)
- Local Library scan defaults to incremental mode; full rescan is available via Force Full Scan
- Local library database upgraded to schema v3 with `file_mod_time` tracking for incremental scan cache
- Platform channels expanded with incremental scan APIs (`scanLibraryFolderIncremental`) on Android and iOS
- Android platform channel adds `getSafFileModTimes` for SAF legacy cache backfill
- Android build tooling upgraded to Gradle 9.3.1 (wrapper)
- Android build path validated with Java 25 (Gradle/Kotlin/assemble debug)
- SAF tree picker flow in `MainActivity` migrated to Activity Result API (`registerForActivityResult`)
- `MainActivity` host migrated to `FlutterFragmentActivity` for SAF picker compatibility
- Legacy `startActivityForResult` / `onActivityResult` SAF picker path removed
- Setup screen UI polish: smaller logo, thin outline borders on text fields
- Removed support section from About page (moved to Donate page)
- Qobuz squid.wtf region fallback for blocked regions
- Setup screen converted to PageView flow with animated progress bar and modern card layouts
- Tutorial screen aligned with Setup Screen design, updated typography and softened UI shapes
- Larger, more accessible navigation buttons for onboarding flow
- Reduced visual noise by removing unnecessary glow effects
### Fixed
- Android 10+ `permission denied` when writing to `/storage/emulated/0` (now handled via SAF)
- SAF history repair: auto-resolve missing content URIs using tree + filename
- SAF download fallback: retry in app-private storage when SAF write fails
- Tidal DASH manifest writing when output path is a file descriptor (no invalid `.m4a` path)
- External LRC output in SAF mode
- Restored old-device renderer fallback while using `FlutterFragmentActivity` by injecting shell args from a custom `FlutterFragment` (`--enable-impeller=false` on problematic devices)
- Preserved Flutter fragment creation behavior (cached engine, engine group, new engine) while adding Impeller fallback support
- SAF tree picker result now consistently returns `tree_uri` payload with persisted URI permission handling
- SAF share file now copies to temp before sharing (fixes share from SAF content URI)
- Home feed not updating after installing extension with homeFeed capability (no longer requires app restart)
- Library scan hero card showing 0 tracks during scan (now shows scanned file count in real-time)
- Library folder picker no longer requires MANAGE_EXTERNAL_STORAGE on Android 10+ (uses SAF tree picker)
- One-time SAF migration prompt for users updating from pre-SAF versions
- Fixed `fileModTime` propagation across Go/Android/Dart so incremental scan cache is stored and reused correctly
- Fixed SAF incremental scan key mismatch (`lastModified` vs `fileModTime`) and normalized result fields (`skippedCount`, `totalFiles`)
- Fixed incremental scan progress when all files are skipped (`scanned_files` now reaches total files)
- Removed duplicate `"removeExtension"` branch in Android method channel handler (eliminates Kotlin duplicate-branch warning)
---
## [3.4.2] - 2026-02-04
### Improved
- **Mobile Network Reliability**: All providers (Qobuz, Tidal, Amazon, Deezer) now have retry logic with exponential backoff
- Increased API timeouts: 15s → 25s (Deezer, Qobuz, Tidal), 30s (Amazon)
- Up to 3 retry attempts per API call (500ms → 1s → 2s backoff)
- Retryable: timeout, connection reset/refused, EOF, HTTP 5xx, HTTP 429
- **SongLink ID Extraction**: Extract QobuzID/TidalID directly from SongLink URLs
- New fields in `TrackAvailability`: `QobuzID`, `TidalID`
- Qobuz/Tidal now use direct Track ID from SongLink instead of re-parsing URLs
- **Qobuz Download Flow**: New Strategy 3 - get QobuzID from SongLink before ISRC search
- Cache hit now uses `GetTrackByID()` directly instead of searching again
- Pre-warm cache tries SongLink first before direct ISRC search
- **Tidal Download Flow**: Use `availability.TidalID` directly from SongLink struct
---
## [3.4.1] - 2026-02-04
### Fixed
- Metadata Priority order now persists after app restart
- Download Provider Priority order now persists after app restart
---
## [3.4.0] - 2026-02-03
### Highlights
@@ -78,7 +819,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
- **Album/Playlist Search**: Deezer search now includes albums and playlists
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re*Index.(ot_inc))
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re\*Index.(ot_inc))
- **Optional All Files Access**: Android 13+ no longer requires full storage access; enable in Settings if needed
- **Improved VPN Compatibility**: Better HTTP/2 support for users behind VPN or restricted networks
@@ -198,7 +939,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
- Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model
- Metadata is stored in download history and persists across app restarts
- New localization strings: `trackGenre`, `trackLabel`, `trackCopyright`
- `**utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings
- `**utils.randomUserAgent()` for Extensions\*\*: New utility function for extensions to get random browser User-Agent strings
- Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0`
- Useful for extensions that need to rotate User-Agents to avoid detection
@@ -495,4 +1236,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
---
*For older versions, see [GitHub Releases*](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
_For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
+17 -3
View File
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Install dependencies**
3. **Use FVM (Flutter Version: 3.41.5)**
```bash
fvm use
```
4. **Install dependencies**
```bash
flutter pub get
```
4. **Generate code** (for Riverpod, JSON serialization, etc.)
5. **Generate code** (for Riverpod, JSON serialization, etc.)
```bash
dart run build_runner build --delete-conflicting-outputs
```
5. **Run the app**
6. **Set up Go environment (Go Version: 1.25.7)**
```bash
cd go_backend
mkdir -p ../android/app/libs
gomobile init
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
cd ..
```
7. **Run the app**
```bash
flutter run
```
+140 -70
View File
@@ -1,113 +1,183 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center">
<img src="icon.png" width="128" />
<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">
</picture>
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
<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>
</p>
</div>
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
</div>
## Screenshots
<p align="center">
<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" />
<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" />
</p>
## Search Source
SpotiFLAC supports multiple search sources for finding music metadata:
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Extensions** | Install additional search providers from the Store |
---
## Extensions
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
### Installing Extensions
1. Go to **Store** tab in the app
2. Browse and install extensions with one tap
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
4. Configure extension settings if needed
5. Set provider priority in **Settings > Extensions > Provider Priority**
1. Open the **Store** tab in the app
2. On first launch, enter an **Extension Repository URL** when prompted
3. Browse and install extensions with one tap
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
5. Configure extension settings if needed
6. Set provider priority under **Settings > Extensions > Provider Priority**
### Developing Extensions
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
## Other project
> [!NOTE]
> 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 Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
## Telegram
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
<p align="center">
<a href="https://t.me/spotiflac">
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflac_chat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
---
## FAQ
**Q: Why is my download failing with "Song not found"?**
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
<details>
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
<br>
**Q: Why are some tracks downloading in lower quality?**
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
**Q: Can I download playlists?**
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
</details>
**Q: Why do I need to grant storage permission?**
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
<details>
<summary><b>Why is my download failing with "Song not found"?</b></summary>
<br>
**Q: Is this app safe?**
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
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.
**Q: Why is download not working in my country?**
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
</details>
<details>
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
<br>
### Want to support SpotiFLAC-Mobile?
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.
_If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going._
</details>
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet) <a href="https://www.buymeacoffee.com/zarzet" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;" ></a>
<details>
<summary><b>Can I download playlists?</b></summary>
<br>
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
</details>
<details>
<summary><b>Why do I need to grant storage permission?</b></summary>
<br>
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
</details>
<details>
<summary><b>Is this app safe?</b></summary>
<br>
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
</details>
<details>
<summary><b>Why is downloading not working in my country?</b></summary>
<br>
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
</details>
<details>
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
<br>
Yes! Add the official source to receive updates directly within the app. Copy this link:
```
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
```
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
</details>
> [!NOTE]
> If SpotiFLAC is useful to you, consider supporting development:
>
> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
---
## Contributors
Thanks to everyone who has contributed to SpotiFLAC Mobile!
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
</a>
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
---
## API Credits
| | | | | |
|---|---|---|---|---|
| [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) | | | | |
---
## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
The application is purely a user interface that facilitates communication between your device and existing third-party services.
You are solely responsible for:
1. Ensuring your use of this software complies with your local laws.
2. Reading and adhering to the Terms of Service of the respective platforms.
3. Any legal consequences resulting from the misuse of this tool.
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
This repository and its contents are provided strictly for educational and research purposes. The software is provided "as-is" without warranty of any kind, express or implied, as stated in the [MIT License](LICENSE).
- No copyrighted content is hosted, stored, mirrored, or distributed by this repository.
- Users must ensure that their use of this software is properly authorized and complies with all applicable laws, regulations, and third-party terms of service.
- This software is provided free of charge by the maintainer. If you paid a third party for access to this software in its original form from this repository, you may have been misled or scammed. Any redistribution or commercial use by third parties must comply with the terms of the repository license. No affiliation, endorsement, or support by the maintainer is implied unless explicitly stated in writing.
- SpotiFLAC Mobile is an independent project. It is not affiliated with, endorsed by, or connected to any other project or version on other platforms that may share a similar name. The maintainer of this repository has no control over or responsibility for third-party projects.
- The author(s) disclaim all liability for any direct, indirect, incidental, or consequential damages arising from the use or misuse of this software. Users assume all risk associated with its use.
- If you are a copyright holder or authorized representative and believe this repository infringes upon your rights, please contact the maintainer with sufficient detail (including relevant URLs and proof of ownership). The matter will be promptly investigated and appropriate action will be taken, which may include removal of the referenced material.
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
> **Star the repo** to get notified about all new releases directly from GitHub.
+20
View File
@@ -9,6 +9,19 @@
# 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/**
- .dart_tool/**
- lib/**/*.g.dart
- lib/l10n/*.dart
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
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`
@@ -23,6 +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
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
-71
View File
@@ -1,71 +0,0 @@
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'
}
+25 -3
View File
@@ -20,6 +20,10 @@ android {
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
buildFeatures {
buildConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
@@ -57,6 +61,18 @@ android {
}
buildTypes {
getByName("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
@@ -71,6 +87,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
ndk {
debugSymbolLevel = "FULL"
}
}
}
@@ -96,11 +115,14 @@ repositories {
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// 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.7.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.activity:activity-ktx:1.13.0")
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
}
+19 -2
View File
@@ -12,16 +12,19 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="SpotiFLAC"
android:label="SpotiFLAC Mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"
android:enableOnBackInvokedCallback="true">
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
android:localeConfig="@xml/locale_config">
<activity
android:name=".MainActivity"
@@ -83,6 +86,20 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="music.youtube.com" />
</intent-filter>
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="spotify-callback" />
</intent-filter>
</activity>
<!-- Download Service -->
@@ -1,5 +0,0 @@
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
@@ -0,0 +1,496 @@
package com.zarz.spotiflac
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import org.json.JSONObject
import java.io.File
import java.util.Locale
/**
* Shared SAF download wrapper for foreground activity calls and service-owned
* native workers.
*/
object SafDownloadHandler {
private val safDirLock = Any()
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
val req = JSONObject(requestJson)
val storageMode = req.optString("storage_mode", "")
val treeUriStr = req.optString("saf_tree_uri", "")
if (storageMode != "saf" || treeUriStr.isBlank()) {
return downloader(requestJson)
}
val treeUri = Uri.parse(treeUriStr)
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt)
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
val existingDir = findDocumentDir(context, treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
if (useStagedOutput || deferSafPublish) {
deleteStaleStagedFiles(existingDir, fileName, outputExt)
}
val obj = JSONObject()
obj.put("success", true)
obj.put("message", "File already exists")
obj.put("file_path", existing.uri.toString())
obj.put("file_name", existing.name ?: fileName)
obj.put("already_exists", true)
return obj.toString()
}
}
val targetDir = ensureDocumentDir(context, treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory")
if (deferSafPublish) {
deleteStaleStagedFiles(targetDir, fileName, outputExt)
val workingExt = outputExt.ifBlank { ".tmp" }
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
return try {
req.put("output_path", workingFile.absolutePath)
req.put("output_ext", outputExt)
req.remove("output_fd")
val response = downloader(req.toString())
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
val reportedPath = respObj.optString("file_path", "").trim()
if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) {
respObj.put("file_path", workingFile.absolutePath)
} else if (reportedPath != workingFile.absolutePath) {
workingFile.delete()
}
respObj.put("file_name", respObj.optString("file_name", "").ifBlank { fileName })
respObj.put("saf_deferred_publish", true)
respObj.put("saf_final_file_name", fileName)
respObj.put("saf_relative_dir", relativeDir)
respObj.put("saf_tree_uri", treeUriStr)
respObj.put("saf_output_ext", outputExt)
respObj.put("saf_final_mime_type", mimeType)
} else {
workingFile.delete()
}
respObj.toString()
} catch (e: Exception) {
workingFile.delete()
errorJson("SAF deferred download failed: ${e.message}")
}
}
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
?: return errorJson("Failed to create SAF file")
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
?: return errorJson("Failed to open SAF file")
var detachedFd: Int? = null
try {
detachedFd = pfd.detachFd()
req.put("output_path", "")
req.put("output_fd", detachedFd)
req.put("output_ext", outputExt)
val response = downloader(req.toString())
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
!goFilePath.startsWith("/proc/self/fd/")
) {
try {
val srcFile = File(goFilePath)
if (!srcFile.exists() || srcFile.length() <= 0) {
throw IllegalStateException("extension output missing or empty: $goFilePath")
}
val actualExt = normalizeExt(srcFile.extension)
if (actualExt.isNotBlank()) {
respObj.put("actual_extension", actualExt)
}
if (actualExt.isNotBlank() && actualExt != outputExt) {
val actualFileName = buildSafFileName(req, actualExt)
val actualStagedFileName = if (useStagedOutput) {
buildStagedSafFileName(actualFileName)
} else {
actualFileName
}
val actualMimeType = mimeTypeForExt(actualExt)
val replacement = createOrReuseDocumentFile(
targetDir,
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
actualStagedFileName
) ?: throw IllegalStateException(
"failed to create SAF output with actual extension"
)
if (replacement.uri != document.uri) {
document.delete()
document = replacement
}
}
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: throw IllegalStateException("failed to open SAF output stream")
srcFile.delete()
} catch (e: Exception) {
document.delete()
android.util.Log.w(
"SpotiFLAC",
"Failed to copy extension output to SAF: ${e.message}"
)
return errorJson("Failed to copy extension output to SAF: ${e.message}")
}
}
respObj.put("file_path", document.uri.toString())
respObj.put("file_name", document.name ?: fileName)
if (useStagedOutput) {
respObj.put("saf_staged_output", true)
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
}
} else {
document.delete()
}
return respObj.toString()
} catch (e: Exception) {
document.delete()
return errorJson("SAF download failed: ${e.message}")
} finally {
if (detachedFd == null) {
try {
pfd.close()
} catch (_: Exception) {
}
}
}
}
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
return try {
val uri = Uri.parse(uriStr)
val extension = DocumentFile.fromSingleUri(context, uri)
?.name
?.substringAfterLast('.', "")
?.takeIf { it.isNotBlank() }
?.let { ".$it" }
?: ".tmp"
val temp = File.createTempFile("native_saf_", extension, context.cacheDir)
context.contentResolver.openInputStream(uri)?.use { input ->
temp.outputStream().use { output ->
input.copyTo(output)
}
} ?: return null
temp.absolutePath
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to copy SAF URI to temp: ${e.message}")
null
}
}
fun writeFileToSaf(
context: Context,
treeUriStr: String,
relativeDir: String,
fileName: String,
mimeType: String,
srcPath: String
): String? {
var stagedDocument: DocumentFile? = null
return try {
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
val finalName = sanitizeFilename(fileName)
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
val stagedName = buildStagedSafFileName(finalName)
deleteStaleStagedFiles(targetDir, finalName, ext)
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
?: return null
stagedDocument = document
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
if (outputStream == null) {
document.delete()
stagedDocument = null
return null
}
outputStream.use { output ->
File(srcPath).inputStream().use { input ->
input.copyTo(output)
}
}
val existingFinal = targetDir.findFile(finalName)
if (existingFinal != null && existingFinal.uri != document.uri) {
existingFinal.delete()
}
if (!document.renameTo(finalName)) {
document.delete()
return null
}
stagedDocument = null
targetDir.findFile(finalName)?.uri?.toString() ?: document.uri.toString()
} catch (e: Exception) {
stagedDocument?.delete()
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
null
}
}
fun deleteContentUri(context: Context, uriStr: String): Boolean {
return try {
DocumentFile.fromSingleUri(context, Uri.parse(uriStr))?.delete() == true
} catch (_: Exception) {
false
}
}
private fun normalizeExt(ext: String?): String {
if (ext.isNullOrBlank()) return ""
return if (ext.startsWith(".")) {
ext.lowercase(Locale.ROOT)
} else {
".${ext.lowercase(Locale.ROOT)}"
}
}
private fun mimeTypeForExt(ext: String?): String {
return when (normalizeExt(ext)) {
".m4a", ".mp4" -> "audio/mp4"
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".flac" -> "audio/flac"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
}
private fun forceFilenameExt(name: String, outputExt: String): String {
val normalizedExt = normalizeExt(outputExt)
if (normalizedExt.isBlank()) return sanitizeFilename(name)
val safeName = sanitizeFilename(name)
val lower = safeName.lowercase(Locale.ROOT)
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".lrc")
for (knownExt in knownExts) {
if (lower.endsWith(knownExt)) {
return safeName.dropLast(knownExt.length) + normalizedExt
}
}
return safeName + normalizedExt
}
private fun buildStagedSafFileName(fileName: String): String {
val safeName = sanitizeFilename(fileName)
return "$safeName.partial"
}
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
val safeName = sanitizeFilename(fileName)
val ext = normalizeExt(outputExt)
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
}
val dot = safeName.lastIndexOf('.')
if (dot > 0 && dot < safeName.lastIndex) {
return safeName.substring(0, dot).trimEnd('.', ' ') +
".partial" +
safeName.substring(dot)
}
return "$safeName.partial"
}
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
val stagedNames = linkedSetOf(
buildStagedSafFileName(fileName),
buildLegacyStagedSafFileName(fileName, outputExt)
)
for (stagedName in stagedNames) {
try {
parent.findFile(stagedName)?.delete()
} catch (_: Exception) {
}
}
}
private fun sanitizeFilename(name: String): String {
var sanitized = name
.replace("/", " ")
.replace(Regex("[\\\\:*?\"<>|]"), " ")
.filter { ch ->
val code = ch.code
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
code == 0x7F ||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
}
.trim()
.trim('.', ' ')
sanitized = sanitized
.replace(Regex("\\s+"), " ")
.replace(Regex("_+"), "_")
.trim('_', ' ')
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
return if (sanitized.isBlank()) "Unknown" else sanitized
}
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
val dotIndex = name.lastIndexOf('.')
val ext = if (
dotIndex > 0 &&
dotIndex < name.length - 1 &&
name.length - dotIndex <= 10
) {
name.substring(dotIndex)
} else {
""
}
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
}
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
val builder = StringBuilder()
var usedBytes = 0
var index = 0
while (index < value.length) {
val codePoint = value.codePointAt(index)
val char = String(Character.toChars(codePoint))
val charBytes = char.toByteArray(Charsets.UTF_8).size
if (usedBytes + charBytes > maxBytes) break
builder.append(char)
usedBytes += charBytes
index += Character.charCount(codePoint)
}
return builder.toString()
}
private fun sanitizeRelativeDir(relativeDir: String): String {
if (relativeDir.isBlank()) return ""
return relativeDir
.split("/")
.map { sanitizeFilename(it) }
.filter { it.isNotBlank() && it != "." && it != ".." }
.joinToString("/")
}
private fun ensureDocumentDir(
context: Context,
treeUri: Uri,
relativeDir: String
): DocumentFile? {
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
if (safeRelativeDir.isBlank()) {
return DocumentFile.fromTreeUri(context, treeUri)
}
synchronized(safDirLock) {
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
current = if (existing != null && existing.isDirectory) {
existing
} else {
val created = current.createDirectory(part) ?: return null
val createdName = created.name ?: part
if (createdName != part) {
created.delete()
current.findFile(part) ?: return null
} else {
created
}
}
}
return current
}
}
private fun findDocumentDir(
context: Context,
treeUri: Uri,
relativeDir: String
): DocumentFile? {
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
if (safeRelativeDir.isBlank()) return current
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
if (existing == null || !existing.isDirectory) return null
current = existing
}
return current
}
private fun createOrReuseDocumentFile(
parent: DocumentFile,
mimeType: String,
fileName: String
): DocumentFile? {
val safeFileName = sanitizeFilename(fileName)
if (safeFileName.isBlank()) return null
synchronized(safDirLock) {
val existing = parent.findFile(safeFileName)
if (existing != null && existing.isFile) {
return existing
}
val created = parent.createFile(mimeType, safeFileName) ?: return null
val createdName = created.name ?: safeFileName
if (createdName == safeFileName) {
return created
}
val winner = parent.findFile(safeFileName)
if (winner != null && winner.isFile) {
if (winner.uri != created.uri) {
try {
created.delete()
} catch (_: Exception) {
}
}
return winner
}
return created
}
}
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
val trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "")
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
return forceFilenameExt(baseName, outputExt)
}
private fun errorJson(message: String): String {
val obj = JSONObject()
obj.put("success", false)
obj.put("error", message)
obj.put("message", message)
return obj.toString()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,12 +1,4 @@
<?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: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -1,12 +1,4 @@
<?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,4 +6,9 @@
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: 932 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -1,17 +1,8 @@
<?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 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1a1a2e</color>
<color name="ic_launcher_background">#000000</color>
</resources>
@@ -1,17 +1,8 @@
<?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>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ru" />
<locale android:name="es-ES" />
<locale android:name="id" />
<locale android:name="pt-PT" />
<locale android:name="ja" />
<locale android:name="tr" />
<locale android:name="de" />
<locale android:name="fr" />
<locale android:name="hi" />
<locale android:name="ko" />
<locale android:name="nl" />
<locale android:name="zh" />
</locale-config>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<!-- Allow local loopback cleartext for FFmpeg live decrypt tunnel only. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>
+1 -1
View File
@@ -22,7 +22,7 @@ subprojects {
}
// Add desugaring dependency to all Android subprojects
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
+5 -1
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -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
+5 -1
View File
@@ -1,5 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-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 "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
}
include(":app")
+18
View File
@@ -0,0 +1,18 @@
{
"name": "SpotiFLAC Mobile Source",
"identifier": "com.zarzet.spotiflac.source",
"subtitle": "FLAC Downloader for iOS",
"apps": [
{
"name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.5.6",
"versionDate": "2026-06-01",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.6/SpotiFLAC-v4.5.6-ios-unsigned.ipa",
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 34059797
}
]
}
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+103
View File
@@ -0,0 +1,103 @@
# git-cliff configuration for SpotiFLAC Mobile
# https://git-cliff.org/docs/configuration
[changelog]
# Template for the changelog body
body = """
{%- macro remote_url() -%}
https://github.com/zarzet/SpotiFLAC-Mobile
{%- endmacro -%}
{% if version %}\
## {{ version | trim_start_matches(pat="v") }}
{% else %}\
## Unreleased
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
{{ commit.message | upper_first }}\
{% if commit.github.pr_number %} \
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
{% endif %}\
{%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
{%- endfor %}
{% endfor %}
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
### New Contributors
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution
{%- if contributor.pr_number %} in \
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
{%- endif %}
{%- endfor %}
{%- endif -%}
{% if version %}
{% if previous.version %}
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
{% endif %}
{% else -%}
{% raw %}\n{% endraw %}
{% endif %}
"""
# Remove leading and trailing whitespace
trim = true
[git]
# Parse conventional commits
conventional_commits = true
filter_unconventional = true
# Process each line of a commit as an individual commit
split_commits = false
# Regex for preprocessing the commit messages
commit_preprocessors = [
# Strip conventional commit prefix for cleaner messages
# (group header already shows the type)
]
# Regex for parsing and grouping commits
commit_parsers = [
# Skip noise: translation commits from Crowdin
{ message = "^New translations", skip = true },
{ message = "^Update source file", skip = true },
# Skip merge commits
{ message = "^Merge", skip = true },
# Skip version bump commits
{ message = "^v\\d+", skip = true },
{ message = "^chore: update VirusTotal", skip = true },
# Group by conventional commit type
{ message = "^feat", group = "<!-- 0 -->New Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 2 -->Performance" },
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
{ message = "^doc", group = "<!-- 4 -->Documentation" },
{ message = "^style", group = "<!-- 5 -->Styling" },
{ message = "^test", group = "<!-- 6 -->Testing" },
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
{ message = "^chore\\(l10n\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
]
# Protect breaking changes from being skipped
protect_breaking_commits = true
# Filter out commits by matching patterns
filter_commits = false
# Tag pattern for version detection
tag_pattern = "v[0-9].*"
# Sort commits by newest first
sort_commits = "newest"
[remote.github]
owner = "zarzet"
repo = "SpotiFLAC-Mobile"
+8 -4
View File
@@ -3,17 +3,21 @@ files:
translation: /lib/l10n/arb/app_%locale%.arb
languages_mapping:
locale:
# Short codes for single-variant languages
# 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
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
# Full codes for Chinese variants
tr: tr
uk: uk
zh-CN: zh_CN
zh-TW: zh_TW
-422
View File
@@ -1,422 +0,0 @@
package gobackend
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
type AmazonDownloader struct {
client *http.Client
}
var (
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
)
// AfkarXYZResponse is the response from AfkarXYZ API
type AfkarXYZResponse struct {
Success bool `json:"success"`
Data struct {
DirectLink string `json:"direct_link"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
} `json:"data"`
}
func amazonIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second),
}
})
return globalAmazonDownloader
}
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := a.client.Do(req)
if err != nil {
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("failed to read response: %w", err)
}
var apiResp AfkarXYZResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", "", fmt.Errorf("failed to decode response: %w", err)
}
if !apiResp.Success || apiResp.Data.DirectLink == "" {
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
}
fileName := apiResp.Data.FileName
if fileName == "" {
fileName = "track.flac"
}
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
GoLog("[Amazon] AfkarXYZ returned: %s (%.2f MB)\n", fileName, float64(apiResp.Data.FileSize)/(1024*1024))
return apiResp.Data.DirectLink, fileName, nil
}
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
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 request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := a.client.Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := os.Create(outputPath)
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 {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
return nil
}
// AmazonDownloadResult contains download result with quality info
type AmazonDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
}
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
songlink := NewSongLinkClient()
var availability *TrackAvailability
var err error
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" {
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
} else {
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
}
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
if !availability.Amazon || availability.AmazonURL == "" {
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
if req.OutputDir != "." {
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
}
}
// Download using AfkarXYZ API
downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL)
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
}
GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return AmazonDownloadResult{}, ErrDownloadCancelled
}
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
actualDate := req.ReleaseDate
actualAlbum := req.AlbumName
actualTitle := req.TrackName
actualArtist := req.ArtistName
if metaErr == nil && existingMeta != nil {
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
}
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
actualDiscNum = existingMeta.DiscNumber
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
}
if existingMeta.Date != "" && req.ReleaseDate == "" {
actualDate = existingMeta.Date
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
}
if existingMeta.Album != "" && req.AlbumName == "" {
actualAlbum = existingMeta.Album
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
}
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
}
metadata := Metadata{
Title: actualTitle,
Artist: actualArtist,
Album: actualAlbum,
AlbumArtist: req.AlbumArtist,
Date: actualDate,
TrackNumber: actualTrackNum,
TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
coverData = parallelResult.CoverData
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} else {
existingCover, coverErr := ExtractCoverArt(outputPath)
if coverErr == nil && len(existingCover) > 0 {
coverData = existingCover
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
} else {
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
}
}
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
GoLog("[Amazon] 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" {
GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
GoLog("[Amazon] Lyrics embedded successfully\n")
}
}
} else if req.EmbedLyrics {
GoLog("[Amazon] No lyrics available from parallel fetch\n")
}
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
quality, err := GetAudioQuality(outputPath)
if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
} else {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
finalMeta, metaReadErr := ReadMetadata(outputPath)
if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" {
req.ReleaseDate = finalMeta.Date
}
}
// Add to ISRC index for fast duplicate checking
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
bitDepth := 0
sampleRate := 0
if err == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
}
return AmazonDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: actualTrackNum,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
}, nil
}
+607
View File
@@ -0,0 +1,607 @@
package gobackend
import (
"encoding/binary"
"fmt"
"io"
"os"
"strings"
)
// APEv2 tag format constants.
const (
apeTagPreamble = "APETAGEX"
apeTagHeaderSize = 32
apeTagVersion2 = 2000
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
apeTagFlagReadOnly = 1 << 0
// Item flags: bits 1-2 encode content type
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
apeItemFlagBinary = 1 << 1 // 01: binary data
apeItemFlagLink = 2 << 1 // 10: external link
)
// APETagItem represents a single key-value item in an APEv2 tag.
type APETagItem struct {
Key string
Value string
Flags uint32
}
// APETag represents a complete APEv2 tag block.
type APETag struct {
Version uint32
Items []APETagItem
ReadOnly bool
}
// ReadAPETags reads APEv2 tags from a file.
// APEv2 tags are typically appended at the end of the file.
// The layout is: [audio data] [APEv2 header (optional)] [items...] [APEv2 footer]
// We locate the footer first (last 32 bytes), then read the tag block.
func ReadAPETags(filePath string) (*APETag, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("failed to stat file: %w", err)
}
fileSize := fi.Size()
if fileSize < apeTagHeaderSize {
return nil, fmt.Errorf("file too small for APE tag")
}
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
if err == nil {
return tag, nil
}
// Retry: skip ID3v1 tag (128 bytes) if present
if fileSize > apeTagHeaderSize+128 {
tag, err = readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize-128)
if err == nil {
return tag, nil
}
}
return nil, fmt.Errorf("no APEv2 tag found")
}
func readAPETagAtOffset(f *os.File, fileSize, footerOffset int64) (*APETag, error) {
if footerOffset < 0 || footerOffset+apeTagHeaderSize > fileSize {
return nil, fmt.Errorf("invalid footer offset")
}
footer := make([]byte, apeTagHeaderSize)
if _, err := f.ReadAt(footer, footerOffset); err != nil {
return nil, fmt.Errorf("failed to read APE footer: %w", err)
}
if string(footer[0:8]) != apeTagPreamble {
return nil, fmt.Errorf("APE preamble not found")
}
version := binary.LittleEndian.Uint32(footer[8:12])
tagSize := binary.LittleEndian.Uint32(footer[12:16]) // size of items + footer (32 bytes)
itemCount := binary.LittleEndian.Uint32(footer[16:20])
flags := binary.LittleEndian.Uint32(footer[20:24])
if version != apeTagVersion2 && version != 1000 {
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
}
if tagSize < apeTagHeaderSize {
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
}
if itemCount > 1000 {
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
}
// This should be the footer (bit 29 clear)
isHeader := (flags & apeTagFlagHeader) != 0
if isHeader {
return nil, fmt.Errorf("expected APE footer but found header")
}
// tagSize includes items + footer (32 bytes), but NOT the header.
itemsSize := int64(tagSize) - apeTagHeaderSize
if itemsSize < 0 {
return nil, fmt.Errorf("invalid APE tag: items size negative")
}
itemsOffset := footerOffset - itemsSize
if itemsOffset < 0 {
return nil, fmt.Errorf("APE tag items extend before file start")
}
itemsData := make([]byte, itemsSize)
if _, err := f.ReadAt(itemsData, itemsOffset); err != nil {
return nil, fmt.Errorf("failed to read APE items: %w", err)
}
items, err := parseAPEItems(itemsData, int(itemCount))
if err != nil {
return nil, fmt.Errorf("failed to parse APE items: %w", err)
}
return &APETag{
Version: version,
Items: items,
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
}, nil
}
func parseAPEItems(data []byte, count int) ([]APETagItem, error) {
items := make([]APETagItem, 0, count)
pos := 0
for i := 0; i < count && pos < len(data); i++ {
if pos+8 > len(data) {
break
}
valueSize := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
itemFlags := binary.LittleEndian.Uint32(data[pos+4 : pos+8])
pos += 8
// Key is null-terminated ASCII (2-255 bytes, case-insensitive)
keyEnd := pos
for keyEnd < len(data) && data[keyEnd] != 0 {
keyEnd++
}
if keyEnd >= len(data) {
break
}
key := string(data[pos:keyEnd])
pos = keyEnd + 1
if pos+valueSize > len(data) {
break
}
value := string(data[pos : pos+valueSize])
pos += valueSize
items = append(items, APETagItem{
Key: key,
Value: value,
Flags: itemFlags,
})
}
return items, nil
}
// WriteAPETags writes APEv2 tags to the end of a file.
// If the file already has APEv2 tags, they are replaced.
// The tag is written with both header and footer.
func WriteAPETags(filePath string, tag *APETag) error {
existingSize, err := findExistingAPETagSize(filePath)
if err != nil {
return fmt.Errorf("failed to check existing APE tag: %w", err)
}
tagData, err := marshalAPETag(tag)
if err != nil {
return fmt.Errorf("failed to marshal APE tag: %w", err)
}
if existingSize > 0 {
fi, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
newSize := fi.Size() - int64(existingSize)
if err := os.Truncate(filePath, newSize); err != nil {
return fmt.Errorf("failed to truncate existing APE tag: %w", err)
}
}
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("failed to open file for writing: %w", err)
}
defer f.Close()
if _, err := f.Write(tagData); err != nil {
return fmt.Errorf("failed to write APE tag: %w", err)
}
return nil
}
// findExistingAPETagSize returns the total size of an existing APE tag
// (header + items + footer) at the end of the file, or 0 if none exists.
func findExistingAPETagSize(filePath string) (int64, error) {
f, err := os.Open(filePath)
if err != nil {
return 0, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return 0, err
}
fileSize := fi.Size()
offsets := []int64{fileSize - apeTagHeaderSize}
if fileSize > apeTagHeaderSize+128 {
offsets = append(offsets, fileSize-apeTagHeaderSize-128)
}
for _, offset := range offsets {
if offset < 0 {
continue
}
footer := make([]byte, apeTagHeaderSize)
if _, err := f.ReadAt(footer, offset); err != nil {
continue
}
if string(footer[0:8]) != apeTagPreamble {
continue
}
flags := binary.LittleEndian.Uint32(footer[20:24])
if (flags & apeTagFlagHeader) != 0 {
continue
}
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
totalSize := tagSize
if hasHeader {
totalSize += apeTagHeaderSize
}
// Include any trailing data after the footer (e.g. ID3v1 128-byte tag).
// When truncating, we must remove the APE tag AND everything after it.
trailingBytes := fileSize - (offset + apeTagHeaderSize)
totalSize += trailingBytes
return totalSize, nil
}
return 0, nil
}
// marshalAPETag serializes an APETag into bytes (header + items + footer).
func marshalAPETag(tag *APETag) ([]byte, error) {
if tag == nil || len(tag.Items) == 0 {
return nil, fmt.Errorf("empty APE tag")
}
var itemsData []byte
for _, item := range tag.Items {
keyBytes := []byte(item.Key)
valueBytes := []byte(item.Value)
// 4 bytes: value size (LE)
sizeBuf := make([]byte, 4)
binary.LittleEndian.PutUint32(sizeBuf, uint32(len(valueBytes)))
// 4 bytes: item flags (LE)
flagsBuf := make([]byte, 4)
binary.LittleEndian.PutUint32(flagsBuf, item.Flags)
itemsData = append(itemsData, sizeBuf...)
itemsData = append(itemsData, flagsBuf...)
itemsData = append(itemsData, keyBytes...)
itemsData = append(itemsData, 0)
itemsData = append(itemsData, valueBytes...)
}
// tagSize = items data + footer (32 bytes)
tagSize := uint32(len(itemsData) + apeTagHeaderSize)
itemCount := uint32(len(tag.Items))
version := uint32(apeTagVersion2)
if tag.Version != 0 {
version = tag.Version
}
// flags: bit 29 = 1 (is header), bit 31 = 1 (contains header)
headerFlags := uint32(apeTagFlagHeader | (1 << 31))
header := buildAPEHeaderFooter(version, tagSize, itemCount, headerFlags)
// flags: bit 29 = 0 (is footer), bit 31 = 1 (contains header)
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...)
result = append(result, footer...)
return result, nil
}
func buildAPEHeaderFooter(version, tagSize, itemCount, flags uint32) []byte {
buf := make([]byte, apeTagHeaderSize)
copy(buf[0:8], apeTagPreamble)
binary.LittleEndian.PutUint32(buf[8:12], version)
binary.LittleEndian.PutUint32(buf[12:16], tagSize)
binary.LittleEndian.PutUint32(buf[16:20], itemCount)
binary.LittleEndian.PutUint32(buf[20:24], flags)
// bytes 24-31 are reserved (zeros)
return buf
}
// APETagToAudioMetadata converts an APETag to our unified AudioMetadata struct.
func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
if tag == nil {
return nil
}
metadata := &AudioMetadata{}
for _, item := range tag.Items {
key := strings.ToUpper(strings.TrimSpace(item.Key))
value := strings.TrimSpace(item.Value)
if value == "" {
continue
}
switch key {
case "TITLE":
metadata.Title = value
case "ARTIST":
metadata.Artist = value
case "ALBUM":
metadata.Album = value
case "ALBUMARTIST", "ALBUM ARTIST":
metadata.AlbumArtist = value
case "GENRE":
metadata.Genre = value
case "YEAR":
metadata.Year = value
case "DATE":
metadata.Date = value
case "TRACK", "TRACKNUMBER":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "DISC", "DISCNUMBER":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "ISRC":
metadata.ISRC = value
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
case "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
case "COMPOSER":
metadata.Composer = value
case "COMMENT":
metadata.Comment = value
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = value
case "REPLAYGAIN_TRACK_PEAK":
metadata.ReplayGainTrackPeak = value
case "REPLAYGAIN_ALBUM_GAIN":
metadata.ReplayGainAlbumGain = value
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = value
}
}
return metadata
}
// AudioMetadataToAPEItems converts metadata fields to APE tag items.
func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
if metadata == nil {
return nil
}
var items []APETagItem
addItem := func(key, value string) {
if value != "" {
items = append(items, APETagItem{Key: key, Value: value})
}
}
addItem("Title", metadata.Title)
addItem("Artist", metadata.Artist)
addItem("Album", metadata.Album)
addItem("Album Artist", metadata.AlbumArtist)
addItem("Genre", metadata.Genre)
if metadata.Date != "" {
addItem("Year", metadata.Date)
} else if metadata.Year != "" {
addItem("Year", metadata.Year)
}
if metadata.TrackNumber > 0 {
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
}
if metadata.DiscNumber > 0 {
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
}
addItem("ISRC", metadata.ISRC)
addItem("Lyrics", metadata.Lyrics)
addItem("Label", metadata.Label)
addItem("Copyright", metadata.Copyright)
addItem("Composer", metadata.Composer)
addItem("Comment", metadata.Comment)
addItem("REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
addItem("REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
addItem("REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
addItem("REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak)
return items
}
// apeKeysFromFields builds a set of upper-case APE tag keys corresponding to
// the metadata fields map sent by the editor. This is used during merge to
// ensure that even empty (cleared) fields override old values.
func apeKeysFromFields(fields map[string]string) map[string]struct{} {
mapping := map[string]string{
"title": "TITLE",
"artist": "ARTIST",
"album": "ALBUM",
"album_artist": "ALBUM ARTIST",
"date": "DATE",
"genre": "GENRE",
"track_number": "TRACK",
"disc_number": "DISC",
"isrc": "ISRC",
"lyrics": "LYRICS",
"label": "LABEL",
"copyright": "COPYRIGHT",
"composer": "COMPOSER",
"comment": "COMMENT",
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
"replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK",
}
result := make(map[string]struct{})
for fk, apeKey := range mapping {
if _, present := fields[fk]; present {
result[strings.ToUpper(apeKey)] = struct{}{}
}
}
// Some fields have reader aliases that must also be cleared when the
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
if _, present := fields["date"]; present {
result["DATE"] = 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{}{}
}
if _, present := fields["label"]; present {
result["PUBLISHER"] = struct{}{}
}
if _, present := fields["lyrics"]; present {
result["UNSYNCEDLYRICS"] = struct{}{}
}
return result
}
// MergeAPEItems overlays newItems on top of existing items.
// For each new item, if a matching key exists (case-insensitive) in existing,
// it is replaced. New keys are appended. Existing items whose keys are NOT
// in newItems are preserved (cover art, ReplayGain, custom tags, etc.).
//
// overrideKeys is an optional set of upper-case keys that should be removed
// from existing even if they do not appear in newItems. This handles field
// 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 {
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
for k := range overrideKeys {
combined[strings.ToUpper(k)] = struct{}{}
}
for _, item := range newItems {
combined[strings.ToUpper(item.Key)] = struct{}{}
}
var merged []APETagItem
for _, item := range existing {
if _, overwritten := combined[strings.ToUpper(item.Key)]; !overwritten {
merged = append(merged, item)
}
}
merged = append(merged, newItems...)
return merged
}
// ReadAPETagsFromReader reads APEv2 tags from an io.ReaderAt + size.
// This is useful for reading APE tags from files opened via SAF or other abstractions.
func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
if fileSize < apeTagHeaderSize {
return nil, fmt.Errorf("file too small for APE tag")
}
footer := make([]byte, apeTagHeaderSize)
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
return nil, fmt.Errorf("failed to read APE footer: %w", err)
}
if string(footer[0:8]) == apeTagPreamble {
tag, err := parseAPETagFromFooter(r, fileSize, fileSize-apeTagHeaderSize, footer)
if err == nil {
return tag, nil
}
}
// Retry: skip ID3v1 tag (128 bytes)
if fileSize > apeTagHeaderSize+128 {
offset := fileSize - apeTagHeaderSize - 128
if _, err := r.ReadAt(footer, offset); err == nil {
if string(footer[0:8]) == apeTagPreamble {
tag, err := parseAPETagFromFooter(r, fileSize, offset, footer)
if err == nil {
return tag, nil
}
}
}
}
return nil, fmt.Errorf("no APEv2 tag found")
}
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])
flags := binary.LittleEndian.Uint32(footer[20:24])
if version != apeTagVersion2 && version != 1000 {
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
}
if tagSize < apeTagHeaderSize {
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
}
if itemCount > 1000 {
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
}
if (flags & apeTagFlagHeader) != 0 {
return nil, fmt.Errorf("expected footer, found header")
}
itemsSize := int64(tagSize) - apeTagHeaderSize
itemsOffset := footerOffset - itemsSize
if itemsOffset < 0 {
return nil, fmt.Errorf("APE items extend before file start")
}
itemsData := make([]byte, itemsSize)
if _, err := r.ReadAt(itemsData, itemsOffset); err != nil {
return nil, fmt.Errorf("failed to read APE items: %w", err)
}
items, err := parseAPEItems(itemsData, int(itemCount))
if err != nil {
return nil, fmt.Errorf("failed to parse APE items: %w", err)
}
return &APETag{
Version: version,
Items: items,
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
}, nil
}
+121
View File
@@ -0,0 +1,121 @@
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")
}
}
+494 -92
View File
@@ -5,13 +5,13 @@ import (
"encoding/binary"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strconv"
"strings"
)
// AudioMetadata represents common audio file metadata
type AudioMetadata struct {
Title string
Artist string
@@ -21,11 +21,22 @@ type AudioMetadata struct {
Year string
Date string
TrackNumber int
TotalTracks int
DiscNumber int
TotalDiscs int
ISRC string
Lyrics string
Label string
Copyright string
Composer string
Comment string
// ReplayGain fields (text values, e.g. "-6.50 dB", "0.988831")
ReplayGainTrackGain string
ReplayGainTrackPeak string
ReplayGainAlbumGain string
ReplayGainAlbumPeak string
}
// MP3Quality represents MP3 specific quality info
type MP3Quality struct {
SampleRate int
BitDepth int
@@ -33,17 +44,13 @@ type MP3Quality struct {
Bitrate int
}
// OggQuality represents Ogg/Opus specific quality info
type OggQuality struct {
SampleRate int
BitDepth int
Duration int
Bitrate int // estimated bitrate in bps
}
// =============================================================================
// ID3 Tag Reading (MP3)
// =============================================================================
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
file, err := os.Open(filePath)
if err != nil {
@@ -168,9 +175,24 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
case "TCO":
metadata.Genre = cleanGenre(value)
case "TRK":
metadata.TrackNumber = parseTrackNumber(value)
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "TPA":
metadata.DiscNumber = parseTrackNumber(value)
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "TCM":
metadata.Composer = value
case "TPB":
metadata.Label = value
case "TCR":
metadata.Copyright = value
case "ULT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXX":
desc, userValue := extractUserTextFrame(frameData)
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
metadata.Lyrics = userValue
}
}
pos += 6 + frameSize
@@ -272,11 +294,41 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
case "TCON":
metadata.Genre = cleanGenre(value)
case "TRCK":
metadata.TrackNumber = parseTrackNumber(value)
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "TPOS":
metadata.DiscNumber = parseTrackNumber(value)
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "TSRC":
metadata.ISRC = value
case "TCOM":
metadata.Composer = value
case "TPUB":
metadata.Label = value
case "TCOP":
metadata.Copyright = value
case "COMM":
if v := extractCommentFrame(frameData); v != "" {
metadata.Comment = v
}
case "USLT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXXX":
desc, userValue := extractUserTextFrame(frameData)
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
metadata.Lyrics = userValue
}
upperDesc := strings.ToUpper(desc)
switch upperDesc {
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = userValue
case "REPLAYGAIN_TRACK_PEAK":
metadata.ReplayGainTrackPeak = userValue
case "REPLAYGAIN_ALBUM_GAIN":
metadata.ReplayGainAlbumGain = userValue
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = userValue
}
}
pos += 10 + frameSize
@@ -304,7 +356,6 @@ func readID3v1(file *os.File) (*AudioMetadata, error) {
Year: strings.TrimRight(string(tag[93:97]), " \x00"),
}
// ID3v1.1 track number (if byte 125 is 0 and byte 126 is not)
if tag[125] == 0 && tag[126] != 0 {
metadata.TrackNumber = int(tag[126])
}
@@ -339,6 +390,135 @@ func extractTextFrame(data []byte) string {
}
}
func extractCommentFrame(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 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 "", ""
}
encoding := data[0]
payload := data[1:]
var descRaw, valueRaw []byte
switch encoding {
case 1, 2:
for i := 0; i+1 < len(payload); i += 2 {
if payload[i] == 0 && payload[i+1] == 0 {
descRaw = payload[:i]
valueRaw = payload[i+2:]
break
}
}
default:
idx := bytes.IndexByte(payload, 0)
if idx >= 0 {
descRaw = payload[:idx]
if idx+1 <= len(payload) {
valueRaw = payload[idx+1:]
}
}
}
if len(valueRaw) == 0 {
return "", ""
}
descFramed := make([]byte, 1+len(descRaw))
descFramed[0] = encoding
copy(descFramed[1:], descRaw)
valueFramed := make([]byte, 1+len(valueRaw))
valueFramed[0] = encoding
copy(valueFramed[1:], valueRaw)
return strings.TrimSpace(extractTextFrame(descFramed)), strings.TrimSpace(extractTextFrame(valueFramed))
}
func isLyricsDescription(description string) bool {
switch strings.ToLower(strings.TrimSpace(description)) {
case
"lyrics",
"lyric",
"unsyncedlyrics",
"unsynced lyrics",
"uslt",
"lrc":
return true
default:
return false
}
}
func decodeUTF16(data []byte) string {
if len(data) < 2 {
return ""
@@ -402,14 +582,28 @@ func cleanGenre(genre string) string {
}
func parseTrackNumber(s string) int {
s = strings.TrimSpace(s)
if idx := strings.Index(s, "/"); idx > 0 {
s = s[:idx]
}
num, _ := strconv.Atoi(s)
num, _ := parseIndexPair(s)
return num
}
func parseIndexPair(s string) (int, int) {
s = strings.TrimSpace(s)
if s == "" {
return 0, 0
}
first := s
second := ""
if idx := strings.Index(s, "/"); idx > 0 {
first = s[:idx]
second = s[idx+1:]
}
num, _ := strconv.Atoi(strings.TrimSpace(first))
total, _ := strconv.Atoi(strings.TrimSpace(second))
return num, total
}
func removeUnsync(data []byte) []byte {
if len(data) == 0 {
return data
@@ -430,11 +624,12 @@ func extendedHeaderSize(data []byte, version byte) int {
return 0
}
var size int
if version == 3 {
switch version {
case 3:
size = int(binary.BigEndian.Uint32(data[:4]))
} else if version == 4 {
case 4:
size = syncsafeToInt(data[:4])
} else {
default:
return 0
}
if size <= 0 {
@@ -493,49 +688,128 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
file.Seek(audioStart, io.SeekStart)
frameHeader := make([]byte, 4)
for i := 0; i < 10000; i++ { // Search first 10KB
var frameStart int64 = -1
for i := 0; i < 10000; i++ {
if _, err := io.ReadFull(file, frameHeader); err != nil {
break
}
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
version := (frameHeader[1] >> 3) & 0x03
layer := (frameHeader[1] >> 1) & 0x03
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
sampleRates := [][]int{
{11025, 12000, 8000},
{0, 0, 0},
{22050, 24000, 16000},
{44100, 48000, 32000},
}
if version < 4 && sampleRateIdx < 3 {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
quality.BitDepth = 16
if quality.Bitrate > 0 {
audioSize := fileSize - audioStart - 128
if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
}
}
pos, _ := file.Seek(0, io.SeekCurrent)
frameStart = pos - 4
break
}
file.Seek(-3, io.SeekCurrent)
}
if frameStart < 0 {
return quality, nil
}
version := (frameHeader[1] >> 3) & 0x03
layer := (frameHeader[1] >> 1) & 0x03
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
channelMode := (frameHeader[3] >> 6) & 0x03
sampleRates := [][]int{
{11025, 12000, 8000},
{0, 0, 0},
{22050, 24000, 16000},
{44100, 48000, 32000},
}
if version < 4 && sampleRateIdx < 3 {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
if (version == 0 || version == 2) && layer == 1 {
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
samplesPerFrame := 1152 // MPEG1 Layer III
if version == 0 || version == 2 {
samplesPerFrame = 576 // MPEG2/2.5 Layer III
}
var xingOffset int
if version == 3 { // MPEG1
if channelMode == 3 { // Mono
xingOffset = 17
} else {
xingOffset = 32
}
} else { // MPEG2/2.5
if channelMode == 3 {
xingOffset = 9
} else {
xingOffset = 17
}
}
xingBuf := make([]byte, 200)
file.Seek(frameStart+4, io.SeekStart)
n, _ := io.ReadFull(file, xingBuf)
xingBuf = xingBuf[:n]
vbrFrames := 0
vbrBytes := int64(0)
isVBR := false
if xingOffset+8 <= n {
tag := string(xingBuf[xingOffset : xingOffset+4])
if tag == "Xing" || tag == "Info" {
flags := binary.BigEndian.Uint32(xingBuf[xingOffset+4 : xingOffset+8])
off := xingOffset + 8
if flags&0x01 != 0 && off+4 <= n { // Frames flag
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[off : off+4]))
off += 4
}
if flags&0x02 != 0 && off+4 <= n { // Bytes flag
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[off : off+4]))
}
if vbrFrames > 0 {
isVBR = true
}
}
}
if !isVBR && 36+26 <= n {
if string(xingBuf[32:36]) == "VBRI" {
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[36+10 : 36+14]))
if vbrFrames > 0 {
isVBR = true
}
}
}
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
quality.Duration = int(totalSamples / int64(quality.SampleRate))
if vbrBytes > 0 && quality.Duration > 0 {
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
} else if quality.Duration > 0 {
audioSize := fileSize - audioStart
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
}
} else if quality.Bitrate > 0 {
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
}
}
return quality, nil
}
@@ -624,14 +898,6 @@ func readOggPageWithHeader(file *os.File) (*oggPage, error) {
}, nil
}
func readOggPage(file *os.File) ([]byte, error) {
page, err := readOggPageWithHeader(file)
if err != nil {
return nil, err
}
return page.data, nil
}
func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) {
const maxPacketSize = 10 * 1024 * 1024
var packets [][]byte
@@ -721,8 +987,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
}
reader := bytes.NewReader(data)
artistValues := make([]string, 0, 1)
albumArtistValues := make([]string, 0, 1)
// Read vendor string length
var vendorLen uint32
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
return
@@ -747,9 +1014,14 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
break
}
if commentLen > 10000 {
remaining := uint32(reader.Len())
if commentLen > remaining {
break
}
if commentLen > 512*1024 {
reader.Seek(int64(commentLen), io.SeekCurrent)
continue
}
comment := make([]byte, commentLen)
if _, err := reader.Read(comment); err != nil {
@@ -768,9 +1040,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
case "TITLE":
metadata.Title = value
case "ARTIST":
metadata.Artist = value
artistValues = append(artistValues, value)
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
metadata.AlbumArtist = value
albumArtistValues = append(albumArtistValues, value)
case "ALBUM":
metadata.Album = value
case "DATE", "YEAR":
@@ -781,13 +1053,40 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
case "GENRE":
metadata.Genre = value
case "TRACKNUMBER", "TRACK":
metadata.TrackNumber = parseTrackNumber(value)
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "DISCNUMBER", "DISC":
metadata.DiscNumber = parseTrackNumber(value)
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "ISRC":
metadata.ISRC = value
case "COMPOSER":
metadata.Composer = value
case "COMMENT", "DESCRIPTION":
metadata.Comment = value
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = value
case "REPLAYGAIN_TRACK_PEAK":
metadata.ReplayGainTrackPeak = value
case "REPLAYGAIN_ALBUM_GAIN":
metadata.ReplayGainAlbumGain = value
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = value
}
}
if len(artistValues) > 0 {
metadata.Artist = joinVorbisCommentValues(artistValues)
}
if len(albumArtistValues) > 0 {
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
}
}
func GetOggQuality(filePath string) (*OggQuality, error) {
@@ -798,7 +1097,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
defer file.Close()
quality := &OggQuality{}
isOpus := false
packets, err := collectOggPackets(file, 5, 10)
if err != nil && len(packets) == 0 {
@@ -814,15 +1112,17 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
}
}
if streamType == oggStreamOpus {
isOpus = true
isOpus := streamType == oggStreamOpus
var preSkip int
if isOpus {
for _, pkt := range packets {
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
if quality.SampleRate == 0 {
quality.SampleRate = 48000
}
quality.BitDepth = 16
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
break
}
}
@@ -830,29 +1130,96 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
for _, pkt := range packets {
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
quality.BitDepth = 16
break
}
}
}
stat, err := file.Stat()
if err == nil {
// Very rough duration estimate based on file size
// Assume ~128kbps average for Opus, ~160kbps for Vorbis
avgBitrate := 128000
if !isOpus {
avgBitrate = 160000
if err != nil {
return quality, nil
}
fileSize := stat.Size()
granule := readLastOggGranulePosition(file, fileSize)
if granule > 0 {
if isOpus {
totalSamples := granule - int64(preSkip)
if totalSamples > 0 {
durationSec := float64(totalSamples) / 48000.0
if durationSec > 0 {
quality.Duration = int(math.Round(durationSec))
quality.Bitrate = int(float64(fileSize*8) / durationSec)
}
}
} else if quality.SampleRate > 0 {
durationSec := float64(granule) / float64(quality.SampleRate)
if durationSec > 0 {
quality.Duration = int(math.Round(durationSec))
quality.Bitrate = int(float64(fileSize*8) / durationSec)
}
}
quality.Duration = int(stat.Size() * 8 / int64(avgBitrate))
}
if quality.Bitrate <= 0 && quality.Duration > 0 {
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
}
if quality.Duration > 24*60*60 {
quality.Duration = 0
quality.Bitrate = 0
}
if quality.Bitrate > 0 && quality.Bitrate < 8000 {
quality.Bitrate = 0
}
return quality, nil
}
// =============================================================================
// ID3v1 Genre List
// =============================================================================
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
searchSize := int64(65536)
if searchSize > fileSize {
searchSize = fileSize
}
buf := make([]byte, searchSize)
offset := fileSize - searchSize
if offset < 0 {
offset = 0
}
n, err := file.ReadAt(buf, offset)
if err != nil && n == 0 {
return 0
}
buf = buf[:n]
for i := n - 4; i >= 0; i-- {
if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' {
continue
}
if i+27 > n {
continue
}
version := buf[i+4]
headerType := buf[i+5]
if version != 0 || headerType > 0x07 {
continue
}
segmentCount := int(buf[i+26])
headerLen := 27 + segmentCount
if i+headerLen > n {
continue
}
payloadLen := 0
for s := 0; s < segmentCount; s++ {
payloadLen += int(buf[i+27+s])
}
if i+headerLen+payloadLen > n {
continue
}
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
}
return 0
}
var id3v1Genres = []string{
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
@@ -884,10 +1251,6 @@ var id3v1Genres = []string{
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
}
// =============================================================================
// Cover Art Extraction
// =============================================================================
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath)
if err != nil {
@@ -912,7 +1275,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
return nil, "", err
}
// Parse frames looking for APIC (Attached Picture)
pos := 0
var frameIDLen, headerLen int
if majorVersion == 2 {
@@ -930,11 +1292,12 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
}
var frameSize int
if majorVersion == 2 {
switch majorVersion {
case 2:
frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5])
} else if majorVersion == 4 {
case 4:
frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7])
} else {
default:
frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7])
}
@@ -942,7 +1305,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
break
}
// Check for APIC (ID3v2.3/2.4) or PIC (ID3v2.2)
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
@@ -1220,7 +1582,14 @@ func base64StdDecode(dst, src []byte) (int, error) {
}
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 == "" {
ext = strings.ToLower(filepath.Ext(displayNameHint))
}
switch ext {
case ".flac":
@@ -1241,7 +1610,22 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
return extractOggCoverArt(filePath)
case ".m4a":
return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
data, err := extractCoverFromM4A(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) >= 8 &&
data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4E &&
data[3] == 0x47 {
mimeType = "image/png"
}
return data, mimeType, nil
case ".wav", ".aiff", ".aif", ".aifc":
return extractWAVAIFFCover(filePath)
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
@@ -1249,10 +1633,28 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
}
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 != "" {
return explicitKey
}
cacheKey := filePath
if stat, err := os.Stat(filePath); err == nil {
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
}
return cacheKey
}
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
hash := hashString(cacheKey)
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
@@ -1265,7 +1667,7 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
return pngPath, nil
}
imageData, mimeType, err := extractAnyCoverArt(filePath)
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
if err != nil {
return "", err
}
+34
View File
@@ -0,0 +1,34 @@
package gobackend
import (
"os"
"strings"
"testing"
)
func TestResolveLibraryCoverCacheKeyUsesExplicitKey(t *testing.T) {
t.Parallel()
const explicitKey = "content://media/external/audio/media/42|123456"
got := resolveLibraryCoverCacheKey("/tmp/saf_random.flac", explicitKey)
if got != explicitKey {
t.Fatalf("expected explicit cache key %q, got %q", explicitKey, got)
}
}
func TestResolveLibraryCoverCacheKeyUsesFilePathAndStatWhenNoExplicitKey(t *testing.T) {
t.Parallel()
tempFile, err := os.CreateTemp("", "cover-cache-*.flac")
if err != nil {
t.Fatalf("CreateTemp failed: %v", err)
}
tempPath := tempFile.Name()
tempFile.Close()
defer os.Remove(tempPath)
got := resolveLibraryCoverCacheKey(tempPath, "")
if !strings.HasPrefix(got, tempPath+"|") {
t.Fatalf("expected stat-based cache key to start with %q, got %q", tempPath+"|", got)
}
}
+133
View File
@@ -0,0 +1,133 @@
package gobackend
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func ffmpegCommand(args ...string) *exec.Cmd {
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
return exec.Command(ffmpegPath, args...)
}
return exec.Command("ffmpeg", args...)
}
func runFFmpegTestCommand(t *testing.T, args ...string) {
t.Helper()
cmd := ffmpegCommand(args...)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
}
}
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not available")
}
tempDir := t.TempDir()
sourceFlac := filepath.Join(tempDir, "source.flac")
baseMp3 := filepath.Join(tempDir, "base.mp3")
finalMp3 := filepath.Join(tempDir, "final.mp3")
coverPath := filepath.Join(tempDir, "cover.jpg")
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
runFFmpegTestCommand(
t,
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=1",
"-c:a",
"flac",
sourceFlac,
)
runFFmpegTestCommand(
t,
"-y",
"-f",
"lavfi",
"-i",
"color=c=red:s=32x32:d=1",
"-frames:v",
"1",
coverPath,
)
runFFmpegTestCommand(
t,
"-y",
"-i",
sourceFlac,
"-b:a",
"320k",
"-metadata",
"title=Test Song",
"-metadata",
"artist=Test Artist",
"-metadata",
"lyrics="+lyrics,
baseMp3,
)
runFFmpegTestCommand(
t,
"-y",
"-i",
baseMp3,
"-i",
coverPath,
"-map",
"0:a",
"-map_metadata",
"-1",
"-map",
"1:0",
"-c:v:0",
"copy",
"-id3v2_version",
"3",
"-metadata",
"title=Test Song",
"-metadata",
"artist=Test Artist",
"-metadata",
"lyrics="+lyrics,
"-metadata:s:v",
"title=Album cover",
"-metadata:s:v",
"comment=Cover (front)",
"-c:a",
"copy",
finalMp3,
)
meta, err := ReadID3Tags(finalMp3)
if err != nil {
t.Fatalf("ReadID3Tags failed: %v", err)
}
if meta == nil {
t.Fatalf("ReadID3Tags returned nil metadata")
}
embeddedLyrics, err := ExtractLyrics(finalMp3)
if err != nil {
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
}
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
}
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
}
if _, err := os.Stat(finalMp3); err != nil {
t.Fatalf("expected final mp3 to exist: %v", err)
}
}
@@ -0,0 +1,517 @@
package gobackend
import (
"bytes"
"encoding/base64"
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAudioMetadataID3ParsingBranches(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tagged.mp3")
tag := buildID3v23Tag(
id3TextFrame("TIT2", "Title"),
id3TextFrame("TPE1", "Artist"),
id3TextFrame("TPE2", "Album Artist"),
id3TextFrame("TALB", "Album"),
id3TextFrame("TDRC", "2026-05-04"),
id3TextFrame("TCON", "(13)Pop"),
id3TextFrame("TRCK", "4/12"),
id3TextFrame("TPOS", "1/2"),
id3TextFrame("TSRC", "USRC17607839"),
id3TextFrame("TCOM", "Composer"),
id3TextFrame("TPUB", "Label"),
id3TextFrame("TCOP", "Copyright"),
id3CommentFrame("COMM", "Comment"),
id3CommentFrame("USLT", "Lyrics"),
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.50 dB"),
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_PEAK", "0.98"),
)
if err := os.WriteFile(path, append(tag, []byte("audio")...), 0600); err != nil {
t.Fatalf("write ID3v2: %v", err)
}
meta, err := ReadID3Tags(path)
if err != nil {
t.Fatalf("ReadID3Tags: %v", err)
}
if meta.Title != "Title" || meta.TrackNumber != 4 || meta.TotalTracks != 12 || meta.Genre != "Pop" {
t.Fatalf("metadata = %#v", meta)
}
if meta.Comment != "Comment" || meta.Lyrics != "Lyrics" || meta.ReplayGainTrackGain == "" {
t.Fatalf("metadata comments/lyrics/replaygain = %#v", meta)
}
id3v1Path := filepath.Join(dir, "id3v1.mp3")
if err := os.WriteFile(id3v1Path, append([]byte("audio"), buildID3v1Tag("V1 Title", "V1 Artist", "V1 Album", "1999", 7, 13)...), 0600); err != nil {
t.Fatalf("write ID3v1: %v", err)
}
v1, err := ReadID3Tags(id3v1Path)
if err != nil {
t.Fatalf("ReadID3Tags v1: %v", err)
}
if v1.Title != "V1 Title" || v1.Artist != "V1 Artist" || v1.Genre == "" {
t.Fatalf("v1 = %#v", v1)
}
v22Path := filepath.Join(dir, "id3v22.mp3")
v22 := buildID3v22Tag(
id3v22TextFrame("TT2", "V22 Title"),
id3v22TextFrame("TP1", "V22 Artist"),
id3v22TextFrame("TRK", "2/5"),
id3v22CommentFrame("ULT", "V22 Lyrics"),
)
if err := os.WriteFile(v22Path, append(v22, []byte("audio")...), 0600); err != nil {
t.Fatalf("write ID3v2.2: %v", err)
}
v22Meta, err := ReadID3Tags(v22Path)
if err != nil {
t.Fatalf("ReadID3Tags v2.2: %v", err)
}
if v22Meta.Title != "V22 Title" || v22Meta.Artist != "V22 Artist" || v22Meta.Lyrics != "V22 Lyrics" {
t.Fatalf("v22 = %#v", v22Meta)
}
if got := decodeUTF16([]byte{0xff, 0xfe, 'H', 0, 'i', 0}); got != "Hi" {
t.Fatalf("decodeUTF16 = %q", got)
}
if got := decodeUTF16BE([]byte{0, 'O', 0, 'K'}); got != "OK" {
t.Fatalf("decodeUTF16BE = %q", got)
}
if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 {
t.Fatalf("parseIndexPair = %d/%d", n, total)
}
if got := parseTrackNumber("9/11"); got != 9 {
t.Fatalf("parseTrackNumber = %d", got)
}
if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) {
t.Fatalf("removeUnsync = %#v", got)
}
if got := extendedHeaderSize([]byte{0, 0, 0, 6, 0, 0, 0, 0, 0, 0}, 3); got != 10 {
t.Fatalf("extendedHeaderSize = %d", got)
}
if got := syncsafeToInt([]byte{0, 0, 2, 0}); got != 256 {
t.Fatalf("syncsafe = %d", got)
}
}
func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0}
if detectCoverMIME("cover.jpg", png) != "image/png" || detectCoverMIME("cover.webp", []byte("RIFFxxxxWEBPdata")) != "image/webp" {
t.Fatal("cover MIME detection mismatch")
}
if _, err := buildPictureBlock("", nil); err == nil {
t.Fatal("expected empty picture block error")
}
apic := append([]byte{3}, []byte("image/png\x00")...)
apic = append(apic, 3, 0)
apic = append(apic, png...)
image, mime := parseAPICFrame(apic, 3)
if mime != "image/png" || !bytes.Equal(image, png) {
t.Fatalf("APIC = %s/%v", mime, image)
}
pic := append([]byte{0}, []byte("PNG")...)
pic = append(pic, 3, 0)
pic = append(pic, png...)
image, mime = parseAPICFrame(pic, 2)
if mime != "image/png" || !bytes.Equal(image, png) {
t.Fatalf("PIC = %s/%v", mime, image)
}
frame := make([]byte, 10)
copy(frame[:4], "APIC")
binary.BigEndian.PutUint32(frame[4:8], uint32(len(apic)))
tag := append(frame, apic...)
header := []byte{'I', 'D', '3', 3, 0, 0, byte(len(tag) >> 21), byte(len(tag) >> 14), byte(len(tag) >> 7), byte(len(tag))}
mp3CoverPath := filepath.Join(t.TempDir(), "cover.mp3")
if err := os.WriteFile(mp3CoverPath, append(append(header, tag...), []byte("audio")...), 0600); err != nil {
t.Fatal(err)
}
extracted, extractedMIME, err := extractMP3CoverArt(mp3CoverPath)
if err != nil || extractedMIME != "image/png" || !bytes.Equal(extracted, png) {
t.Fatalf("extractMP3CoverArt = %s/%v/%v", extractedMIME, extracted, err)
}
var picture bytes.Buffer
binary.Write(&picture, binary.BigEndian, uint32(3))
binary.Write(&picture, binary.BigEndian, uint32(len("image/png")))
picture.WriteString("image/png")
binary.Write(&picture, binary.BigEndian, uint32(0))
binary.Write(&picture, binary.BigEndian, uint32(1))
binary.Write(&picture, binary.BigEndian, uint32(1))
binary.Write(&picture, binary.BigEndian, uint32(32))
binary.Write(&picture, binary.BigEndian, uint32(0))
binary.Write(&picture, binary.BigEndian, uint32(len(png)))
picture.Write(png)
flacImage, flacMIME := parseFLACPictureBlock(picture.Bytes())
if flacMIME != "image/png" || !bytes.Equal(flacImage, png) {
t.Fatalf("FLAC picture = %s/%v", flacMIME, flacImage)
}
comment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture.Bytes())
var vorbis bytes.Buffer
binary.Write(&vorbis, binary.LittleEndian, uint32(6))
vorbis.WriteString("vendor")
binary.Write(&vorbis, binary.LittleEndian, uint32(1))
binary.Write(&vorbis, binary.LittleEndian, uint32(len(comment)))
vorbis.WriteString(comment)
commentImage, commentMIME := extractPictureFromVorbisComments(vorbis.Bytes())
if commentMIME != "image/png" || !bytes.Equal(commentImage, png) {
t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage)
}
decoded := make([]byte, base64StdDecodeLen(len("SGV sbG8="))+4)
n, err := base64StdDecode(decoded, []byte("SGV sbG8="))
if err != nil || strings.TrimRight(string(decoded[:n]), "\x00") != "Hello" {
t.Fatalf("base64 decode = %q/%v", decoded[:n], err)
}
if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus {
t.Fatal("expected opus stream")
}
if detectOggStreamType([][]byte{append([]byte{1}, []byte("vorbisxxxx")...)}) != oggStreamVorbis {
t.Fatal("expected vorbis stream")
}
mp3Path := filepath.Join(t.TempDir(), "quality.mp3")
audio := append([]byte{0xFF, 0xFB, 0x90, 0x64}, bytes.Repeat([]byte{0}, 2000)...)
if err := os.WriteFile(mp3Path, audio, 0600); err != nil {
t.Fatal(err)
}
quality, err := GetMP3Quality(mp3Path)
if err != nil || quality.SampleRate != 44100 || quality.Bitrate != 128000 {
t.Fatalf("MP3 quality = %#v/%v", quality, err)
}
if _, _, err := extractMP3CoverArt(filepath.Join(t.TempDir(), "missing.mp3")); err == nil {
t.Fatal("expected missing MP3 cover error")
}
}
func TestM4AMetadataAtomHelpers(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tagged.m4a")
cover := []byte{0xFF, 0xD8, 0xFF, 0x00}
ilstPayload := []byte{}
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9nam", "M4A Title")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9ART", "M4A Artist")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9alb", "M4A Album")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("aART", "Album Artist")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9day", "2026")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9gen", "Pop")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9wrt", "Composer")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9cmt", "[ti:Comment Lyrics]")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("cprt", "Copyright")...)
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9lyr", "[00:00.00]M4A Lyrics")...)
ilstPayload = append(ilstPayload, buildM4AIndexTag("trkn", 3, 12)...)
ilstPayload = append(ilstPayload, buildM4AIndexTag("disk", 1, 2)...)
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("ISRC", "USRC17607839")...)
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("LABEL", "Label")...)
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("REPLAYGAIN_TRACK_GAIN", "-6.50 dB")...)
ilstPayload = append(ilstPayload, buildM4AAtom("covr", buildM4AAtom("data", append([]byte{0, 0, 0, 13, 0, 0, 0, 0}, cover...)))...)
fileData := buildM4AFileWithIlst(ilstPayload, true)
if err := os.WriteFile(path, fileData, 0600); err != nil {
t.Fatal(err)
}
meta, err := ReadM4ATags(path)
if err != nil {
t.Fatalf("ReadM4ATags: %v", err)
}
if meta.Title != "M4A Title" || meta.Artist != "M4A Artist" || meta.TrackNumber != 3 || meta.TotalTracks != 12 || meta.ISRC != "USRC17607839" {
t.Fatalf("M4A metadata = %#v", meta)
}
if lyrics, err := extractLyricsFromM4A(path); err != nil || !strings.Contains(lyrics, "M4A Lyrics") {
t.Fatalf("extractLyricsFromM4A = %q/%v", lyrics, err)
}
if image, err := extractCoverFromM4A(path); err != nil || !bytes.Equal(image, cover) {
t.Fatalf("extractCoverFromM4A = %#v/%v", image, err)
}
if pathInfo, err := func() (m4aMetadataPath, error) {
f, err := os.Open(path)
if err != nil {
return m4aMetadataPath{}, err
}
defer f.Close()
info, _ := f.Stat()
return findM4AMetadataPath(f, info.Size())
}(); err != nil || pathInfo.udta == nil {
t.Fatalf("findM4AMetadataPath = %#v/%v", pathInfo, err)
}
if err := EditM4AReplayGain(path, map[string]string{"replaygain_track_gain": "-5.00 dB", "replaygain_track_peak": "0.98"}); err != nil {
t.Fatalf("EditM4AReplayGain: %v", err)
}
edited, err := ReadM4ATags(path)
if err != nil || edited.ReplayGainTrackGain != "-5.00 dB" || edited.ReplayGainTrackPeak != "0.98" {
t.Fatalf("edited M4A = %#v/%v", edited, err)
}
noUdtaPath := filepath.Join(dir, "noudta.m4a")
if err := os.WriteFile(noUdtaPath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "No Udta"), false), 0600); err != nil {
t.Fatal(err)
}
if meta, err := ReadM4ATags(noUdtaPath); err != nil || meta.Title != "No Udta" {
t.Fatalf("ReadM4ATags no udta = %#v/%v", meta, err)
}
if _, err := ReadM4ATags(filepath.Join(dir, "missing.m4a")); err == nil {
t.Fatal("expected missing M4A error")
}
emptyM4A := filepath.Join(dir, "empty.m4a")
if err := os.WriteFile(emptyM4A, buildM4AFileWithIlst(nil, true), 0600); err != nil {
t.Fatal(err)
}
if _, err := ReadM4ATags(emptyM4A); err == nil {
t.Fatal("expected empty M4A tags error")
}
if _, err := extractCoverFromM4A(emptyM4A); err == nil {
t.Fatal("expected missing M4A cover error")
}
if _, err := extractLyricsFromM4A(emptyM4A); err == nil {
t.Fatal("expected missing M4A lyrics error")
}
sidecarAudio := filepath.Join(dir, "sidecar.mp3")
if err := os.WriteFile(sidecarAudio, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte(" [00:00.00]Sidecar "), 0600); err != nil {
t.Fatal(err)
}
if lyrics, err := extractLyricsFromSidecarLRC(sidecarAudio); err != nil || !strings.Contains(lyrics, "Sidecar") {
t.Fatalf("sidecar lyrics = %q/%v", lyrics, err)
}
if !looksLikeEmbeddedLyrics("[ti:Song]") || !looksLikeEmbeddedLyrics("[00:00.00]Line\n[00:01.00]Next") || looksLikeEmbeddedLyrics("plain") {
t.Fatal("embedded lyric heuristic mismatch")
}
if formatIndexValue(3, 12) != "3/12" || formatIndexValue(3, 0) != "3" || formatIndexValue(0, 12) != "" {
t.Fatal("formatIndexValue mismatch")
}
if parsePositiveInt(" 42 ") != 42 || parsePositiveInt("bad") != 0 {
t.Fatal("parsePositiveInt mismatch")
}
if !hasMapKey(map[string]string{"x": "y"}, "x") {
t.Fatal("expected map key")
}
if _, ok := parseReplayGainDb("-6.50 dB"); !ok {
t.Fatal("expected ReplayGain dB parse")
}
if _, ok := parseReplayGainPeak("0.98"); !ok {
t.Fatal("expected ReplayGain peak parse")
}
if norm := buildITunNORMTag("-6.50 dB", "0.98"); norm == "" {
t.Fatal("expected iTunNORM")
}
if fields := collectM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-6 dB", "replaygain_track_peak": "0.9"}); fields["iTunNORM"] == "" {
t.Fatalf("ReplayGain fields = %#v", fields)
}
qualityPath := filepath.Join(dir, "quality-alac.m4a")
mvhd := make([]byte, 20)
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
sampleEntry := make([]byte, 32)
copy(sampleEntry[0:4], "alac")
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
sampleEntry[28] = 0xAC
sampleEntry[29] = 0x44
alacConfig := make([]byte, 24)
alacConfig[5] = 24
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
t.Fatal(err)
}
if quality, err := GetM4AQuality(qualityPath); err != nil || quality.BitDepth != 24 || quality.SampleRate != 44100 || quality.Duration != 180 {
t.Fatalf("GetM4AQuality = %#v/%v", quality, err)
}
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
}
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
copy(sampleEntry[0:4], "mp4a")
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
t.Fatal(err)
}
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
}
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
zeroMvhd := make([]byte, 20)
eac3SampleEntry := make([]byte, 32)
copy(eac3SampleEntry[0:4], "ec-3")
eac3SampleEntry[28] = 0xBB
eac3SampleEntry[29] = 0x80
mdhd := make([]byte, 20)
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
eac3QualityFile := append(
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
buildM4AAtom("moov", append(
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
eac3SampleEntry...,
))...,
)
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
t.Fatal(err)
}
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
}
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
t.Fatal("short ALAC config should not parse")
}
alac := make([]byte, 24)
alac[5] = 16
binary.BigEndian.PutUint32(alac[20:24], 48000)
if depth, rate, ok := parseALACSpecificConfig(alac); !ok || depth != 16 || rate != 48000 {
t.Fatalf("ALAC config = %d/%d/%v", depth, rate, ok)
}
}
func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
dir := t.TempDir()
opusHead := make([]byte, 19)
copy(opusHead[0:8], "OpusHead")
binary.LittleEndian.PutUint16(opusHead[10:12], 312)
binary.LittleEndian.PutUint32(opusHead[12:16], 48000)
var comments bytes.Buffer
binary.Write(&comments, binary.LittleEndian, uint32(6))
comments.WriteString("vendor")
entries := []string{
"TITLE=Ogg Title",
"ARTIST=Artist",
"ALBUMARTIST=Album Artist",
"TRACKNUMBER=2/9",
"DISCNUMBER=1/2",
"LYRICS=[00:00.00]Ogg Lyrics",
}
binary.Write(&comments, binary.LittleEndian, uint32(len(entries)))
for _, entry := range entries {
binary.Write(&comments, binary.LittleEndian, uint32(len(entry)))
comments.WriteString(entry)
}
opusTags := append([]byte("OpusTags"), comments.Bytes()...)
oggPath := filepath.Join(dir, "tagged.opus")
oggData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, opusTags)...)
if err := os.WriteFile(oggPath, oggData, 0600); err != nil {
t.Fatal(err)
}
quality, err := GetOggQuality(oggPath)
if err != nil || quality.SampleRate != 48000 || quality.Duration != 1 {
t.Fatalf("GetOggQuality = %#v/%v", quality, err)
}
meta, err := ReadOggVorbisComments(oggPath)
if err != nil || meta.Title != "Ogg Title" || meta.TrackNumber != 2 || meta.TotalTracks != 9 {
t.Fatalf("ReadOggVorbisComments = %#v/%v", meta, err)
}
picture := buildTestFLACPictureBlock([]byte{0x89, 0x50, 0x4E, 0x47}, "image/png")
pictureComment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture)
var coverComments bytes.Buffer
binary.Write(&coverComments, binary.LittleEndian, uint32(6))
coverComments.WriteString("vendor")
binary.Write(&coverComments, binary.LittleEndian, uint32(1))
binary.Write(&coverComments, binary.LittleEndian, uint32(len(pictureComment)))
coverComments.WriteString(pictureComment)
coverPath := filepath.Join(dir, "cover.opus")
coverData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, append([]byte("OpusTags"), coverComments.Bytes()...))...)
if err := os.WriteFile(coverPath, coverData, 0600); err != nil {
t.Fatal(err)
}
if image, mime, err := extractOggCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
t.Fatalf("extractOggCoverArt = %s/%#v/%v", mime, image, err)
}
if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 {
t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err)
}
if image, mime, err := extractAnyCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
t.Fatalf("extractAnyCoverArt = %s/%#v/%v", mime, image, err)
}
extractedCoverPath := filepath.Join(dir, "extracted.png")
if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil {
t.Fatalf("ExtractCoverToFile = %v", err)
}
if data := mustReadFile(t, extractedCoverPath); len(data) == 0 {
t.Fatal("expected extracted cover data")
}
cachePath, err := SaveCoverToCacheWithHintAndKey(coverPath, "cover.opus", dir, "key")
if err != nil || cachePath == "" {
t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err)
}
cacheDir := filepath.Join(dir, "cache")
if path, err := SaveCoverToCache(coverPath, cacheDir); err != nil || !strings.HasSuffix(path, ".png") {
t.Fatalf("SaveCoverToCache = %q/%v", path, err)
}
if path, err := SaveCoverToCacheWithHint(coverPath, "cover.opus", cacheDir); err != nil || path == "" {
t.Fatalf("SaveCoverToCacheWithHint = %q/%v", path, err)
}
hitPath, err := SaveCoverToCache(coverPath, cacheDir)
if err != nil || hitPath == "" {
t.Fatalf("SaveCoverToCache cache hit = %q/%v", hitPath, err)
}
if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil {
t.Fatal("expected missing cover cache error")
}
badPath := filepath.Join(dir, "bad.ogg")
if err := os.WriteFile(badPath, []byte("bad"), 0600); err != nil {
t.Fatal(err)
}
if _, err := GetOggQuality(badPath); err == nil {
t.Fatal("expected invalid Ogg quality error")
}
}
func buildM4ADataPayload(payload []byte) []byte {
return append([]byte{0, 0, 0, 1, 0, 0, 0, 0}, payload...)
}
func buildM4ATextTag(atomType, value string) []byte {
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload([]byte(value))))
}
func buildM4AIndexTag(atomType string, number, total int) []byte {
payload := []byte{0, 0, 0, byte(number), 0, byte(total), 0, 0}
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload(payload)))
}
func buildM4AFileWithIlst(ilstPayload []byte, withUdta bool) []byte {
ilst := buildM4AAtom("ilst", ilstPayload)
meta := buildM4AAtom("meta", append([]byte{0, 0, 0, 0}, ilst...))
moovPayload := meta
if withUdta {
moovPayload = buildM4AAtom("udta", meta)
}
return append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", moovPayload)...)
}
func buildOggPage(headerType byte, granule uint64, packet []byte) []byte {
header := make([]byte, 27)
copy(header[0:4], "OggS")
header[4] = 0
header[5] = headerType
binary.LittleEndian.PutUint64(header[6:14], granule)
header[26] = 1
return append(append(header, byte(len(packet))), packet...)
}
func buildTestFLACPictureBlock(image []byte, mime string) []byte {
var picture bytes.Buffer
binary.Write(&picture, binary.BigEndian, uint32(3))
binary.Write(&picture, binary.BigEndian, uint32(len(mime)))
picture.WriteString(mime)
binary.Write(&picture, binary.BigEndian, uint32(0))
binary.Write(&picture, binary.BigEndian, uint32(1))
binary.Write(&picture, binary.BigEndian, uint32(1))
binary.Write(&picture, binary.BigEndian, uint32(32))
binary.Write(&picture, binary.BigEndian, uint32(0))
binary.Write(&picture, binary.BigEndian, uint32(len(image)))
picture.Write(image)
return picture.Bytes()
}
+105 -1
View File
@@ -9,14 +9,23 @@ 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 {
@@ -27,10 +36,25 @@ 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
}
@@ -73,6 +97,86 @@ func clearDownloadCancel(itemID string) {
}
cancelMu.Lock()
delete(cancelMap, itemID)
if entry, ok := cancelMap[itemID]; ok {
entry.refs--
if entry.refs <= 0 {
delete(cancelMap, itemID)
}
}
cancelMu.Unlock()
}
func initExtensionRequestCancel(requestID string) context.Context {
if requestID == "" {
return context.Background()
}
extensionRequestCancelMu.Lock()
defer extensionRequestCancelMu.Unlock()
if entry, ok := extensionRequestCancelMap[requestID]; ok {
if entry.ctx == nil {
ctx, cancel := context.WithCancel(context.Background())
entry.ctx = ctx
entry.cancel = cancel
if entry.canceled && entry.cancel != nil {
entry.cancel()
}
}
entry.refs++
return entry.ctx
}
ctx, cancel := context.WithCancel(context.Background())
extensionRequestCancelMap[requestID] = &cancelEntry{
ctx: ctx,
cancel: cancel,
canceled: false,
refs: 1,
}
return ctx
}
func cancelExtensionRequest(requestID string) {
if requestID == "" {
return
}
extensionRequestCancelMu.Lock()
if entry, ok := extensionRequestCancelMap[requestID]; ok {
entry.canceled = true
if entry.cancel != nil {
entry.cancel()
}
} else {
extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true}
}
extensionRequestCancelMu.Unlock()
}
func isExtensionRequestCancelled(requestID string) bool {
if requestID == "" {
return false
}
extensionRequestCancelMu.Lock()
entry, ok := extensionRequestCancelMap[requestID]
canceled := ok && entry.canceled
extensionRequestCancelMu.Unlock()
return canceled
}
func clearExtensionRequestCancel(requestID string) {
if requestID == "" {
return
}
extensionRequestCancelMu.Lock()
if entry, ok := extensionRequestCancelMap[requestID]; ok {
entry.refs--
if entry.refs <= 0 {
delete(extensionRequestCancelMap, requestID)
}
}
extensionRequestCancelMu.Unlock()
}
+36 -5
View File
@@ -17,6 +17,10 @@ const (
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
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)
@@ -40,7 +44,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
// Log already printed by upgradeToMaxQuality for Deezer
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
@@ -86,16 +89,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
}
func upgradeToMaxQuality(coverURL string) string {
// Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
// Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
}
if strings.Contains(coverURL, "resources.tidal.com") {
return upgradeTidalCover(coverURL)
}
if strings.Contains(coverURL, "static.qobuz.com") {
return upgradeQobuzCover(coverURL)
}
return coverURL
}
@@ -104,7 +113,6 @@ func upgradeDeezerCover(coverURL string) string {
return coverURL
}
// Replace any size pattern with 1800x1800
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
if upgraded != coverURL {
GoLog("[Cover] Deezer: upgraded to 1800x1800")
@@ -112,12 +120,35 @@ func upgradeDeezerCover(coverURL string) string {
return upgraded
}
func upgradeTidalCover(coverURL string) string {
if !strings.Contains(coverURL, "resources.tidal.com") {
return coverURL
}
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
if upgraded != coverURL {
GoLog("[Cover] Tidal: upgraded to origin resolution")
}
return upgraded
}
func upgradeQobuzCover(coverURL string) string {
if !strings.Contains(coverURL, "static.qobuz.com") {
return coverURL
}
upgraded := qobuzSizeRegex.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 ""
}
// Always upgrade small to medium first
result := convertSmallToMedium(imageURL)
if maxQuality {
+401
View File
@@ -0,0 +1,401 @@
package gobackend
import (
"archive/zip"
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func newTestLoadedExtension(t *testing.T, types ...ExtensionType) *loadedExtension {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.js"), []byte(testExtensionJS), 0600); err != nil {
t.Fatalf("write index.js: %v", err)
}
return &loadedExtension{
ID: "coverage-ext",
Manifest: &ExtensionManifest{
Name: "coverage-ext",
Description: "Coverage extension",
Version: "1.0.0",
Types: types,
Permissions: ExtensionPermissions{File: true, Network: []string{"example.test"}},
SearchBehavior: &SearchBehaviorConfig{
Enabled: true,
Placeholder: "Search coverage",
Primary: true,
Icon: "search",
},
URLHandler: &URLHandlerConfig{Enabled: true, Patterns: []string{"https://example.test/"}},
TrackMatching: &TrackMatchingConfig{CustomMatching: true},
PostProcessing: &PostProcessingConfig{
Enabled: true,
Hooks: []PostProcessingHook{{ID: "hook", Name: "Hook", DefaultEnabled: true, SupportedFormats: []string{"flac"}}},
},
},
Enabled: true,
SourceDir: dir,
DataDir: t.TempDir(),
}
}
const testExtensionJS = `
function track(id) {
return {
id: id,
name: "Track " + id,
artists: "Artist",
albumName: "Album",
albumArtist: "Album Artist",
durationMs: 180000,
coverUrl: "https://example.test/cover.jpg",
releaseDate: "2026-05-04",
trackNumber: 1,
totalTracks: 10,
discNumber: 1,
totalDiscs: 1,
isrc: "USRC17607839",
itemType: "track",
albumType: "album",
tidalId: "tidal-1",
qobuzId: "qobuz-1",
deezerId: "deezer-1",
spotifyId: "spotify:track:1",
externalLinks: { tidal: "https://tidal.example/1" },
label: "Label",
copyright: "Copyright",
genre: "Pop",
composer: "Composer",
audioQuality: "FLAC 24-bit",
audioModes: "DOLBY_ATMOS"
};
}
registerExtension({
searchTracks: function(query, limit) {
return { tracks: [track("search-1")], total: 1 };
},
customSearch: function(query, options) {
var t = track("custom-1");
t.name = "Custom " + query;
return [t];
},
getHomeFeed: function() {
return [{ id: "home-1", title: "Home", tracks: [track("home-track")] }];
},
getBrowseCategories: function() {
return [{ id: "cat-1", title: "Category" }];
},
getTrack: function(id) {
return track(id);
},
getAlbum: function(id) {
return {
id: id,
name: "Album " + id,
artists: "Artist",
artistId: "artist-1",
coverUrl: "https://example.test/album.jpg",
releaseDate: "2026-05-04",
totalTracks: 1,
albumType: "album",
tracks: [track("album-track")]
};
},
getPlaylist: function(id) {
return {
id: id,
name: "Playlist " + id,
artists: "Owner",
coverUrl: "https://example.test/playlist.jpg",
totalTracks: 1,
tracks: [track("playlist-track")]
};
},
getArtist: function(id) {
return {
id: id,
name: "Artist",
imageUrl: "https://example.test/artist.jpg",
headerImage: "https://example.test/header.jpg",
listeners: 123,
albums: [{ id: "album-1", name: "Album", artists: "Artist", totalTracks: 1 }],
releases: [{ id: "release-1", name: "Release", artists: "Artist", totalTracks: 1, tracks: [track("release-track")] }],
topTracks: [track("top-track")]
};
},
enrichTrack: function(input) {
var t = track(input.id || "enriched");
t.name = "Enriched";
return t;
},
checkAvailability: function(isrc, name, artist, ids) {
return { available: true, reason: "ok", trackId: "download-track", skipFallback: true };
},
getDownloadUrl: function(id, quality) {
return { url: "https://example.test/audio.flac", format: "flac", bitDepth: 24, sampleRate: 96000 };
},
download: function(id, quality, outputPath, onProgress) {
if (onProgress) onProgress(100);
return {
success: true,
filePath: "EXISTS:" + outputPath,
alreadyExists: false,
bitDepth: 24,
sampleRate: 96000,
title: "Downloaded",
artist: "Artist",
album: "Album",
albumArtist: "Album Artist",
trackNumber: 1,
totalTracks: 10,
discNumber: 1,
totalDiscs: 1,
releaseDate: "2026-05-04",
coverUrl: "https://example.test/cover.jpg",
isrc: "USRC17607839",
genre: "Pop",
label: "Label",
copyright: "Copyright",
composer: "Composer",
lyricsLrc: "[00:00.00]Hello",
decryptionKey: "001122",
decryption: { strategy: "mp4_decryption_key", options: { kid: "1" } }
};
},
fetchLyrics: function(name, artist, album, duration) {
return { syncType: "LINE_SYNCED", provider: "coverage-ext", lines: [{ startTimeMs: 0, endTimeMs: 1000, words: "Hello" }] };
},
handleUrl: function(url) {
return { type: "track", name: "Handled", coverUrl: "https://example.test/cover.jpg", track: track("url-track"), tracks: [track("url-track")], album: this.getAlbum("url-album"), artist: this.getArtist("url-artist") };
},
matchTrack: function(req) {
return { matched: true, trackId: "download-track", confidence: 0.95, reason: "exact" };
},
postProcess: function(path, req) {
return { success: true, newFilePath: path, bitDepth: 24, sampleRate: 96000 };
},
postProcessV2: function(input, metadata, hookId) {
return { success: true, newFilePath: input.path || input.uri, newFileUri: input.uri || "", bitDepth: 24, sampleRate: 96000 };
}
});
`
func mustReadFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file: %v", err)
}
return data
}
func buildID3v23Tag(frames ...[]byte) []byte {
body := bytes.Join(frames, nil)
header := []byte{'I', 'D', '3', 3, 0, 0, 0, 0, 0, 0}
copy(header[6:10], syncsafeBytes(len(body)))
return append(header, body...)
}
func id3TextFrame(id, value string) []byte {
return id3v23Frame(id, append([]byte{3}, []byte(value)...))
}
func id3CommentFrame(id, value string) []byte {
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
return id3v23Frame(id, payload)
}
func id3UserTextFrame(id, desc, value string) []byte {
payload := append([]byte{3}, []byte(desc)...)
payload = append(payload, 0)
payload = append(payload, []byte(value)...)
return id3v23Frame(id, payload)
}
func id3v23Frame(id string, payload []byte) []byte {
frame := make([]byte, 10+len(payload))
copy(frame[0:4], id)
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
copy(frame[10:], payload)
return frame
}
func buildID3v22Tag(frames ...[]byte) []byte {
body := bytes.Join(frames, nil)
header := []byte{'I', 'D', '3', 2, 0, 0, 0, 0, 0, 0}
copy(header[6:10], syncsafeBytes(len(body)))
return append(header, body...)
}
func id3v22TextFrame(id, value string) []byte {
return id3v22Frame(id, append([]byte{3}, []byte(value)...))
}
func id3v22CommentFrame(id, value string) []byte {
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
return id3v22Frame(id, payload)
}
func id3v22Frame(id string, payload []byte) []byte {
frame := make([]byte, 6+len(payload))
copy(frame[0:3], id)
size := len(payload)
frame[3] = byte(size >> 16)
frame[4] = byte(size >> 8)
frame[5] = byte(size)
copy(frame[6:], payload)
return frame
}
func syncsafeBytes(size int) []byte {
return []byte{
byte((size >> 21) & 0x7f),
byte((size >> 14) & 0x7f),
byte((size >> 7) & 0x7f),
byte(size & 0x7f),
}
}
func buildID3v1Tag(title, artist, album, year string, track, genre byte) []byte {
tag := make([]byte, 128)
copy(tag[0:3], "TAG")
copyPadded(tag[3:33], title)
copyPadded(tag[33:63], artist)
copyPadded(tag[63:93], album)
copyPadded(tag[93:97], year)
tag[125] = 0
tag[126] = track
tag[127] = genre
return tag
}
func copyPadded(dst []byte, value string) {
for i := range dst {
dst[i] = ' '
}
copy(dst, value)
}
func writeExportCueFixture(t *testing.T, dir string) (string, string) {
t.Helper()
audioPath := filepath.Join(dir, "exports.wav")
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
t.Fatalf("write export audio: %v", err)
}
cuePath := filepath.Join(dir, "exports.cue")
cue := "PERFORMER \"Artist\"\nTITLE \"Album\"\nFILE \"exports.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"Song\"\n INDEX 01 00:00:00\n"
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
t.Fatalf("write export cue: %v", err)
}
return cuePath, audioPath
}
func escapeJSONPath(path string) string {
data, _ := json.Marshal(path)
return strings.Trim(string(data), `"`)
}
func fakeDeezerResponse(path, rawQuery string) string {
switch {
case path == "/2.0/search/track":
if strings.Contains(rawQuery, "MISSING") {
return `{"data":[]}`
}
return `{"data":[` + fakeDeezerTrackJSON(101, true) + `]}`
case path == "/2.0/search/artist":
return `{"data":[{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123}]}`
case path == "/2.0/search/album":
return `{"data":[{"id":201,"title":"Album","cover_xl":"album-xl","nb_tracks":2,"release_date":"2026-05-04","record_type":"compile","artist":{"id":301,"name":"Artist"}}]}`
case path == "/2.0/search/playlist":
return `{"data":[{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"user":{"name":"Owner"}}]}`
case path == "/2.0/track/101", path == "/2.0/track/isrc:USRC17607839":
return fakeDeezerTrackJSON(101, true)
case path == "/2.0/track/102":
return fakeDeezerTrackJSON(102, true)
case path == "/2.0/track/isrc:MISSING":
return `{"id":0}`
case path == "/2.0/album/201":
return `{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","nb_tracks":2,"record_type":"compile","label":"Label","copyright":"Copyright","genres":{"data":[{"name":"Pop"},{"name":"Dance"}]},"artist":{"id":301,"name":"Album Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
case path == "/2.0/artist/301":
return `{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123,"nb_album":1}`
case path == "/2.0/artist/301/albums":
return `{"data":[{"id":201,"title":"Album","release_date":"2026-05-04","nb_tracks":0,"cover_xl":"album-xl","record_type":"compile"}]}`
case path == "/2.0/artist/301/related":
return `{"data":[{"id":302,"name":"Related","picture_xl":"related-xl","nb_fan":10}]}`
case path == "/2.0/playlist/401":
return `{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"creator":{"name":"Owner"},"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
default:
return ""
}
}
func fakeDeezerTrackJSON(id int, withISRC bool) string {
isrc := ""
if withISRC {
isrc = `,"isrc":"USRC17607839"`
if id == 102 {
isrc = `,"isrc":"USRC17607840"`
}
}
return fmt.Sprintf(`{"id":%d,"title":"Track %d","duration":180,"track_position":%d,"disk_number":1%s,"link":"https://deezer.test/track/%d","release_date":"2026-05-04","artist":{"id":301,"name":"Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"album":{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","record_type":"album"}}`, id, id, id-100, isrc, id)
}
func createTestExtensionPackage(t *testing.T, path, name, version, js string, extraFiles map[string]string) {
t.Helper()
out, err := os.Create(path)
if err != nil {
t.Fatalf("create extension package: %v", err)
}
defer out.Close()
zw := zip.NewWriter(out)
defer zw.Close()
manifest := fmt.Sprintf(`{
"name": %q,
"displayName": %q,
"version": %q,
"description": "Packaged test extension",
"type": ["metadata_provider", "download_provider", "lyrics_provider"],
"permissions": {"network": ["example.test"], "storage": true, "file": true},
"icon": "icon.png",
"settings": [{"key":"quality","type":"string","label":"Quality"}],
"qualityOptions": [{"id":"lossless","label":"Lossless","description":"Lossless"}],
"searchBehavior": {"enabled": true, "placeholder": "Search", "primary": true},
"urlHandler": {"enabled": true, "patterns": ["https://example.test/"]},
"trackMatching": {"customMatching": true},
"postProcessing": {"enabled": true, "hooks": [{"id":"hook","name":"Hook"}]},
"serviceHealth": [{"id":"main","url":"https://example.test/health"}],
"capabilities": {"homeFeed": true}
}`, name, name, version)
for fileName, content := range map[string]string{
"manifest.json": manifest,
"index.js": js,
"icon.png": "png",
} {
writer, err := zw.Create(fileName)
if err != nil {
t.Fatalf("zip create %s: %v", fileName, err)
}
if _, err := writer.Write([]byte(content)); err != nil {
t.Fatalf("zip write %s: %v", fileName, err)
}
}
for fileName, content := range extraFiles {
writer, err := zw.Create(fileName)
if err != nil {
t.Fatalf("zip create extra %s: %v", fileName, err)
}
if _, err := writer.Write([]byte(content)); err != nil {
t.Fatalf("zip write extra %s: %v", fileName, err)
}
}
}
+442
View File
@@ -0,0 +1,442 @@
package gobackend
import (
"encoding/json"
"sort"
"strings"
"sync"
)
type CrossExtensionShareResult struct {
ExtensionID string `json:"extension_id"`
DisplayName string `json:"display_name"`
Found bool `json:"found"`
URL string `json:"url,omitempty"`
ItemName string `json:"item_name,omitempty"`
ItemArtists string `json:"item_artists,omitempty"`
Error string `json:"error,omitempty"`
}
var crossExtensionShareResultCache = struct {
sync.RWMutex
entries map[string]string
order []string
}{
entries: make(map[string]string),
}
const crossExtensionShareResultCacheLimit = 128
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
var req struct {
Name string `json:"name"`
Artists string `json:"artists"`
Type string `json:"type"`
SourceExtensionID string `json:"source_extension_id"`
}
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", err
}
req.Name = strings.TrimSpace(req.Name)
req.Artists = strings.TrimSpace(req.Artists)
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
if req.Name == "" {
return "[]", nil
}
if req.Type == "" {
req.Type = "album"
}
providers := getExtensionManager().GetMetadataProviders()
work := make([]*extensionProviderWrapper, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
if provider.extension.ID == req.SourceExtensionID {
continue
}
work = append(work, provider)
}
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
return cached, nil
}
query := req.Name
if req.Artists != "" {
query += " " + req.Artists
}
results := make([]CrossExtensionShareResult, len(work))
var wg sync.WaitGroup
for i, provider := range work {
wg.Add(1)
go func(index int, p *extensionProviderWrapper) {
defer wg.Done()
results[index] = findCollectionForExtension(
p,
req.Type,
req.Name,
req.Artists,
query,
)
}(i, provider)
}
wg.Wait()
data, err := json.Marshal(results)
if err != nil {
return "[]", err
}
response := string(data)
if crossExtensionShareResultsCacheable(results) {
setCrossExtensionShareCache(cacheKey, response)
}
return response, nil
}
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
providerKeys := make([]string, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
ext := provider.extension
displayName := ""
if ext.Manifest != nil {
displayName = ext.Manifest.DisplayName
}
providerKeys = append(providerKeys, strings.Join([]string{
strings.TrimSpace(ext.ID),
strings.TrimSpace(displayName),
strings.TrimSpace(ext.SourceDir),
}, "\x1f"))
}
sort.Strings(providerKeys)
return strings.Join([]string{
normalizeLooseTitle(itemType),
normalizeLooseTitle(name),
normalizeLooseArtistName(artists),
strings.TrimSpace(sourceExtensionID),
strings.Join(providerKeys, "\x1e"),
}, "\x1d")
}
func getCrossExtensionShareCache(key string) string {
if key == "" {
return ""
}
crossExtensionShareResultCache.RLock()
defer crossExtensionShareResultCache.RUnlock()
return crossExtensionShareResultCache.entries[key]
}
func setCrossExtensionShareCache(key string, value string) {
if key == "" || value == "" {
return
}
crossExtensionShareResultCache.Lock()
defer crossExtensionShareResultCache.Unlock()
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
}
crossExtensionShareResultCache.entries[key] = value
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
oldest := crossExtensionShareResultCache.order[0]
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
delete(crossExtensionShareResultCache.entries, oldest)
}
}
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
for _, result := range results {
if result.Found {
continue
}
errText := strings.ToLower(strings.TrimSpace(result.Error))
if errText == "" ||
errText == "no results" ||
errText == "unsupported collection type" ||
strings.HasSuffix(errText, " not found") ||
strings.Contains(errText, "found without shareable link") {
continue
}
return false
}
return true
}
func findCollectionForExtension(
provider *extensionProviderWrapper,
itemType string,
name string,
artists string,
query string,
) CrossExtensionShareResult {
result := CrossExtensionShareResult{
ExtensionID: provider.extension.ID,
}
if provider.extension.Manifest != nil {
result.DisplayName = provider.extension.Manifest.DisplayName
}
if result.DisplayName == "" {
result.DisplayName = provider.extension.ID
}
searchResult, err := searchCollectionCandidates(provider, itemType, query)
if err != nil {
result.Error = err.Error()
return result
}
if searchResult == nil || len(searchResult.Tracks) == 0 {
result.Error = "no results"
return result
}
var best *ExtTrackMetadata
switch itemType {
case "artist":
best = bestArtistTrack(searchResult.Tracks, name)
case "album":
best = bestAlbumTrack(searchResult.Tracks, name, artists)
default:
result.Error = "unsupported collection type"
return result
}
if best == nil {
result.Error = itemType + " not found"
return result
}
url := resolveCollectionShareURL(provider.extension, itemType, best)
if url == "" {
result.Error = itemType + " found without shareable link"
return result
}
result.Found = true
result.URL = url
if itemType == "artist" {
result.ItemName = collectionArtistName(*best)
} else {
result.ItemName = collectionAlbumName(*best)
result.ItemArtists = best.Artists
}
return result
}
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
filter := ""
switch itemType {
case "album":
filter = "albums"
case "artist":
filter = "artists"
}
if filter != "" {
tracks, err := provider.CustomSearch(query, map[string]interface{}{
"filter": filter,
"limit": 10,
})
if err == nil && len(tracks) > 0 {
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
}
}
return provider.SearchTracks(query, 10)
}
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
targetAlbum := normalizeLooseTitle(albumName)
targetArtists := normalizeLooseArtistName(artists)
bestScore := 0
bestIndex := -1
for i := range tracks {
track := tracks[i]
album := normalizeLooseTitle(collectionAlbumName(track))
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
score := 0
if isCollectionItemType(track, "album") {
score += 25
}
if album == targetAlbum {
score += 100
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
score += 50
}
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
score += 30
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 50 {
return nil
}
return &tracks[bestIndex]
}
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
targetArtist := normalizeLooseArtistName(artistName)
bestScore := 0
bestIndex := -1
for i := range tracks {
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
score := 0
if isCollectionItemType(tracks[i], "artist") {
score += 25
}
if artist == targetArtist {
score += 100
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
score += 60
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 60 {
return nil
}
return &tracks[bestIndex]
}
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
if track == nil {
return ""
}
if itemType == "album" {
if isCollectionItemType(*track, "album") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.AlbumURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
return url
}
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
return url
}
return ""
}
if isCollectionItemType(*track, "artist") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.ArtistURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
return url
}
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
return url
}
return ""
}
func collectionAlbumName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "album") {
return track.Name
}
return track.AlbumName
}
func collectionArtistName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "artist") {
return track.Name
}
return track.Artists
}
func collectionID(track ExtTrackMetadata, itemType string) string {
if isCollectionItemType(track, itemType) {
return track.ID
}
return ""
}
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
}
func normalizeShareURL(value string) string {
trimmed := strings.TrimSpace(value)
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
return trimmed
}
return ""
}
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
for key, value := range links {
if strings.Contains(strings.ToLower(key), preferredKey) {
if url := normalizeShareURL(value); url != "" {
return url
}
}
}
return ""
}
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return ""
}
id = stripProviderPrefix(strings.TrimSpace(id))
if id == "" {
return ""
}
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
if !ok {
return ""
}
rawTemplate, ok := templates[itemType].(string)
if !ok {
return ""
}
rawTemplate = strings.TrimSpace(rawTemplate)
if rawTemplate == "" {
return ""
}
return strings.ReplaceAll(rawTemplate, "{id}", id)
}
func stripProviderPrefix(id string) string {
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
return id[index+1:]
}
return id
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
+100
View File
@@ -0,0 +1,100 @@
package gobackend
import "testing"
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
"album": "https://music.apple.com/us/album/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "1440783617",
Name: "Nevermind",
Artists: "Nirvana",
ItemType: "album",
},
}
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
if best == nil {
t.Fatal("expected album collection item to match")
}
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
t.Fatalf("album share URL = %q", url)
}
}
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
"artist": "https://music.youtube.com/browse/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "UCrPe3hLA51968GwxHSZ1llw",
Name: "Nirvana",
ItemType: "artist",
},
}
best := bestArtistTrack(tracks, "Nirvana")
if best == nil {
t.Fatal("expected artist collection item to match")
}
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
t.Fatalf("artist share URL = %q", url)
}
}
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
apple := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "apple",
SourceDir: "/extensions/apple",
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
},
}
qobuz := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "qobuz",
SourceDir: "/extensions/qobuz",
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
},
}
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
if first != second {
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
}
}
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
cacheable := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "album not found"},
{ExtensionID: "tidal", Error: "no results"},
}
if !crossExtensionShareResultsCacheable(cacheable) {
t.Fatal("expected found and deterministic not-found results to be cacheable")
}
transient := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "request failed: timeout"},
}
if crossExtensionShareResultsCacheable(transient) {
t.Fatal("expected transient extension errors to skip cache")
}
}
+171
View File
@@ -0,0 +1,171 @@
package gobackend
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestCueParserEndToEnd(t *testing.T) {
dir := t.TempDir()
audioPath := filepath.Join(dir, "album.wav")
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
t.Fatalf("write audio: %v", err)
}
cuePath := filepath.Join(dir, "album.cue")
cue := "\ufeffREM GENRE \"Pop\"\n" +
"REM DATE 2026\n" +
"REM COMMENT \"comment\"\n" +
"REM COMPOSER \"Album Composer\"\n" +
"PERFORMER \"Album Artist\"\n" +
"TITLE \"Album Title\"\n" +
"FILE \"album.wav\" WAVE\n" +
" TRACK 01 AUDIO\n" +
" TITLE \"First\"\n" +
" PERFORMER \"Track Artist\"\n" +
" ISRC USRC17607839\n" +
" INDEX 01 00:00:00\n" +
" TRACK 02 AUDIO\n" +
" TITLE \"Second\"\n" +
" SONGWRITER \"Track Composer\"\n" +
" INDEX 00 03:00:00\n" +
" INDEX 01 03:05:00\n"
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
t.Fatalf("write cue: %v", err)
}
sheet, err := ParseCueFile(cuePath)
if err != nil {
t.Fatalf("ParseCueFile: %v", err)
}
if sheet.Performer != "Album Artist" || sheet.Title != "Album Title" || len(sheet.Tracks) != 2 {
t.Fatalf("sheet = %#v", sheet)
}
if got := parseCueTimestamp("01:02:37"); got <= 62 || got >= 63 {
t.Fatalf("timestamp = %f", got)
}
if got := formatCueTimestamp(3723.5); got != "01:02:03.500" {
t.Fatalf("format timestamp = %q", got)
}
if got := unquoteCue(" \"quoted\" "); got != "quoted" {
t.Fatalf("unquote = %q", got)
}
fileName, fileType := parseCueFileLine("unquoted album.flac FLAC")
if fileName != "unquoted album.flac" || fileType != "FLAC" {
t.Fatalf("file line = %q/%q", fileName, fileType)
}
if resolved := ResolveCueAudioPath(cuePath, "album.flac"); resolved != audioPath {
t.Fatalf("resolved = %q want %q", resolved, audioPath)
}
info, err := BuildCueSplitInfo(cuePath, sheet, "")
if err != nil {
t.Fatalf("BuildCueSplitInfo: %v", err)
}
if info.Tracks[0].EndSec != 180 || info.Tracks[1].Composer != "Track Composer" {
t.Fatalf("split info = %#v", info.Tracks)
}
jsonText, err := ParseCueFileJSON(cuePath, "")
if err != nil {
t.Fatalf("ParseCueFileJSON: %v", err)
}
var decoded CueSplitInfo
if err := json.Unmarshal([]byte(jsonText), &decoded); err != nil {
t.Fatalf("decode cue json: %v", err)
}
if decoded.AudioPath != audioPath {
t.Fatalf("decoded audio path = %q", decoded.AudioPath)
}
results, err := ScanCueFileForLibraryExt(cuePath, "", "virtual/album.cue", 1234, "scan-time")
if err != nil {
t.Fatalf("ScanCueFileForLibraryExt: %v", err)
}
if len(results) != 2 || results[0].TrackName != "First" || results[0].Duration != 180 {
t.Fatalf("scan results = %#v", results)
}
if results[0].FilePath != "virtual/album.cue#track01" || results[0].Format != "cue+wav" {
t.Fatalf("scan path/format = %q/%q", results[0].FilePath, results[0].Format)
}
if _, err := ParseCueFile(filepath.Join(dir, "missing.cue")); err == nil {
t.Fatal("expected missing cue error")
}
emptyCue := filepath.Join(dir, "empty.cue")
if err := os.WriteFile(emptyCue, []byte("TITLE \"No tracks\""), 0600); err != nil {
t.Fatal(err)
}
if _, err := ParseCueFile(emptyCue); err == nil {
t.Fatal("expected no tracks error")
}
missingDir := t.TempDir()
missingCuePath := filepath.Join(missingDir, "missing.cue")
if err := os.WriteFile(missingCuePath, []byte(cue), 0600); err != nil {
t.Fatal(err)
}
if _, err := BuildCueSplitInfo(missingCuePath, &CueSheet{FileName: "missing.wav"}, ""); err == nil {
t.Fatal("expected missing audio error")
}
if _, err := resolveCueAudioPathForLibrary(cuePath, nil, ""); err == nil {
t.Fatal("expected nil sheet error")
}
if _, err := scanCueSheetForLibrary(cuePath, nil, audioPath, "", 0, "", ""); err == nil {
t.Fatal("expected nil scan sheet error")
}
}
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "song.flac")
if err := os.WriteFile(filePath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir, buildTime: time.Now()}
idx.Add("usrc17607839", filePath)
if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath {
t.Fatalf("lookup = %q/%v", got, ok)
}
if got, err := idx.Lookup("usrc17607839"); err != nil || got != filePath {
t.Fatalf("Lookup = %q/%v", got, err)
}
idx.remove("usrc17607839")
if _, ok := idx.lookup("usrc17607839"); ok {
t.Fatal("expected removed ISRC")
}
isrcIndexCacheMu.Lock()
isrcIndexCache[dir] = idx
isrcIndexCacheMu.Unlock()
defer InvalidateISRCCache(dir)
AddToISRCIndex(dir, "USRC17607839", filePath)
if found, err := CheckISRCExists(dir, "USRC17607839"); err != nil || found != filePath {
t.Fatalf("CheckISRCExists = %q/%v", found, err)
}
if !CheckFileExists(filePath) || CheckFileExists(dir) || CheckFileExists(filepath.Join(dir, "missing.flac")) {
t.Fatal("unexpected file existence result")
}
tracksJSON := `[{"isrc":"USRC17607839","track_name":"Song","artist_name":"Artist"},{"isrc":"MISSING","track_name":"Other","artist_name":"Artist"}]`
resultJSON, err := CheckFilesExistParallel(dir, tracksJSON)
if err != nil {
t.Fatalf("CheckFilesExistParallel: %v", err)
}
var results []FileExistenceResult
if err := json.Unmarshal([]byte(resultJSON), &results); err != nil {
t.Fatalf("decode results: %v", err)
}
if !results[0].Exists || results[0].FilePath != filePath || results[1].Exists {
t.Fatalf("results = %#v", results)
}
if _, err := CheckFilesExistParallel(dir, `not-json`); err == nil {
t.Fatal("expected invalid json error")
}
if err := PreBuildISRCIndex(""); err == nil {
t.Fatal("expected empty dir error")
}
}
+565
View File
@@ -0,0 +1,565 @@
package gobackend
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
type CueSheet struct {
Performer string `json:"performer"`
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
Genre string `json:"genre,omitempty"`
Date string `json:"date,omitempty"`
Comment string `json:"comment,omitempty"`
Composer string `json:"composer,omitempty"`
Tracks []CueTrack `json:"tracks"`
}
type CueTrack struct {
Number int `json:"number"`
Title string `json:"title"`
Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
}
type CueSplitInfo struct {
CuePath string `json:"cue_path"`
AudioPath string `json:"audio_path"`
Album string `json:"album"`
Artist string `json:"artist"`
Genre string `json:"genre,omitempty"`
Date string `json:"date,omitempty"`
Tracks []CueSplitTrack `json:"tracks"`
}
type CueSplitTrack struct {
Number int `json:"number"`
Title string `json:"title"`
Artist string `json:"artist"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
StartSec float64 `json:"start_sec"`
EndSec float64 `json:"end_sec"` // -1 means until end of file
}
var (
reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`)
reQuoted = regexp.MustCompile(`"([^"]*)"`)
)
func ParseCueFile(cuePath string) (*CueSheet, error) {
f, err := os.Open(cuePath)
if err != nil {
return nil, fmt.Errorf("failed to open cue file: %w", err)
}
defer f.Close()
sheet := &CueSheet{}
var currentTrack *CueTrack
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "\xef\xbb\xbf") {
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
line = strings.TrimSpace(line)
}
upper := strings.ToUpper(line)
if strings.HasPrefix(upper, "REM ") {
matches := reRemCommand.FindStringSubmatch(line)
if len(matches) == 3 {
key := strings.ToUpper(matches[1])
value := unquoteCue(matches[2])
switch key {
case "GENRE":
sheet.Genre = value
case "DATE":
sheet.Date = value
case "COMMENT":
sheet.Comment = value
case "COMPOSER":
if currentTrack != nil {
currentTrack.Composer = value
} else {
sheet.Composer = value
}
}
}
continue
}
if strings.HasPrefix(upper, "PERFORMER ") {
value := unquoteCue(line[len("PERFORMER "):])
if currentTrack != nil {
currentTrack.Performer = value
} else {
sheet.Performer = value
}
continue
}
if strings.HasPrefix(upper, "TITLE ") {
value := unquoteCue(line[len("TITLE "):])
if currentTrack != nil {
currentTrack.Title = value
} else {
sheet.Title = value
}
continue
}
if strings.HasPrefix(upper, "FILE ") {
rest := line[len("FILE "):]
fname, ftype := parseCueFileLine(rest)
sheet.FileName = fname
sheet.FileType = ftype
continue
}
if strings.HasPrefix(upper, "TRACK ") {
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
parts := strings.Fields(line)
trackNum := 0
if len(parts) >= 2 {
trackNum, _ = strconv.Atoi(parts[1])
}
currentTrack = &CueTrack{
Number: trackNum,
PreGap: -1,
}
continue
}
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
parts := strings.Fields(line)
if len(parts) >= 3 {
indexNum, _ := strconv.Atoi(parts[1])
timeSec := parseCueTimestamp(parts[2])
switch indexNum {
case 0:
currentTrack.PreGap = timeSec
case 1:
currentTrack.StartTime = timeSec
}
}
continue
}
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
continue
}
if strings.HasPrefix(upper, "SONGWRITER ") {
value := unquoteCue(line[len("SONGWRITER "):])
if currentTrack != nil {
currentTrack.Composer = value
} else {
sheet.Composer = value
}
continue
}
}
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading cue file: %w", err)
}
if len(sheet.Tracks) == 0 {
return nil, fmt.Errorf("no tracks found in cue file")
}
return sheet, nil
}
func parseCueTimestamp(ts string) float64 {
parts := strings.Split(ts, ":")
if len(parts) != 3 {
return 0
}
minutes, _ := strconv.Atoi(parts[0])
seconds, _ := strconv.Atoi(parts[1])
frames, _ := strconv.Atoi(parts[2])
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
}
func formatCueTimestamp(seconds float64) string {
if seconds < 0 {
return "0"
}
hours := int(seconds) / 3600
mins := (int(seconds) % 3600) / 60
secs := seconds - float64(hours*3600) - float64(mins*60)
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
}
func unquoteCue(s string) string {
s = strings.TrimSpace(s)
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
return matches[1]
}
return s
}
func parseCueFileLine(rest string) (string, string) {
rest = strings.TrimSpace(rest)
var filename, ftype string
if strings.HasPrefix(rest, "\"") {
endQuote := strings.Index(rest[1:], "\"")
if endQuote >= 0 {
filename = rest[1 : endQuote+1]
remaining := strings.TrimSpace(rest[endQuote+2:])
ftype = remaining
} else {
filename = rest
}
} else {
parts := strings.Fields(rest)
if len(parts) >= 2 {
ftype = parts[len(parts)-1]
filename = strings.Join(parts[:len(parts)-1], " ")
} else if len(parts) == 1 {
filename = parts[0]
}
}
return filename, strings.TrimSpace(ftype)
}
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
cueDir := filepath.Dir(cuePath)
candidate := filepath.Join(cueDir, cueFileName)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, baseName+ext)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, cueBase+ext)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
entries, err := os.ReadDir(cueDir)
if err == nil {
audioExts := map[string]bool{
".flac": true, ".wav": true, ".ape": true, ".mp3": true,
".ogg": true, ".wv": true, ".m4a": true, ".aiff": true,
}
var audioFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if audioExts[ext] {
audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name()))
}
}
if len(audioFiles) == 1 {
return audioFiles[0]
}
}
return ""
}
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
resolveDir := cuePath
if audioDir != "" {
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
}
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
if audioPath == "" {
return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName)
}
info := &CueSplitInfo{
CuePath: cuePath,
AudioPath: audioPath,
Album: sheet.Title,
Artist: sheet.Performer,
Genre: sheet.Genre,
Date: sheet.Date,
}
for i, track := range sheet.Tracks {
performer := track.Performer
if performer == "" {
performer = sheet.Performer
}
composer := track.Composer
if composer == "" {
composer = sheet.Composer
}
endSec := float64(-1)
if i+1 < len(sheet.Tracks) {
nextTrack := sheet.Tracks[i+1]
if nextTrack.PreGap >= 0 {
endSec = nextTrack.PreGap
} else {
endSec = nextTrack.StartTime
}
}
info.Tracks = append(info.Tracks, CueSplitTrack{
Number: track.Number,
Title: track.Title,
Artist: performer,
ISRC: track.ISRC,
Composer: composer,
StartSec: track.StartTime,
EndSec: endSec,
})
}
return info, nil
}
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
return "", fmt.Errorf("failed to parse cue file: %w", err)
}
info, err := BuildCueSplitInfo(cuePath, sheet, audioDir)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(info)
if err != nil {
return "", fmt.Errorf("failed to marshal cue split info: %w", err)
}
return string(jsonBytes), nil
}
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
return nil, err
}
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "")
if err != nil {
return nil, err
}
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
}
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
return ScanCueFileForLibraryExtWithCoverCacheKey(
cuePath,
audioDir,
virtualPathPrefix,
fileModTime,
"",
scanTime,
)
}
func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
return nil, err
}
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir)
if err != nil {
return nil, err
}
return scanCueSheetForLibrary(
cuePath,
sheet,
audioPath,
virtualPathPrefix,
fileModTime,
coverCacheKey,
scanTime,
)
}
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
if sheet == nil {
return "", fmt.Errorf("cue sheet is nil for %s", cuePath)
}
resolveBase := cuePath
if audioDir != "" {
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
}
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
if audioPath == "" {
return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
}
return audioPath, nil
}
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
if sheet == nil {
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
}
var bitDepth, sampleRate int
var totalDurationSec float64
audioExt := strings.ToLower(filepath.Ext(audioPath))
switch audioExt {
case ".flac":
quality, qErr := GetAudioQuality(audioPath)
if qErr == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate)
}
}
case ".mp3":
quality, qErr := GetMP3Quality(audioPath)
if qErr == nil {
sampleRate = quality.SampleRate
totalDurationSec = float64(quality.Duration)
}
}
var coverPath string
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" {
cp, err := SaveCoverToCacheWithHintAndKey(
audioPath,
"",
coverCacheDir,
coverCacheKey,
)
if err == nil && cp != "" {
coverPath = cp
}
}
pathBase := cuePath
if virtualPathPrefix != "" {
pathBase = virtualPathPrefix
}
modTime := fileModTime
if modTime <= 0 {
if info, err := os.Stat(cuePath); err == nil {
modTime = info.ModTime().UnixMilli()
}
}
var results []LibraryScanResult
for i, track := range sheet.Tracks {
performer := track.Performer
if performer == "" {
performer = sheet.Performer
}
if performer == "" {
performer = "Unknown Artist"
}
title := track.Title
if title == "" {
title = fmt.Sprintf("Track %02d", track.Number)
}
album := sheet.Title
if album == "" {
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
if sheet.Tracks[i+1].PreGap >= 0 {
nextStart = sheet.Tracks[i+1].PreGap
}
duration = int(nextStart - track.StartTime)
} else if totalDurationSec > 0 {
duration = int(totalDurationSec - track.StartTime)
}
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
result := LibraryScanResult{
ID: id,
TrackName: title,
ArtistName: performer,
AlbumName: album,
AlbumArtist: sheet.Performer,
FilePath: virtualFilePath,
CoverPath: coverPath,
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, "."),
}
result.FileModTime = modTime
results = append(results, result)
}
return results, nil
}
+326 -31
View File
@@ -13,25 +13,39 @@ import (
)
const (
deezerBaseURL = "https://api.deezer.com/2.0"
deezerSearchURL = deezerBaseURL + "/search"
deezerTrackURL = deezerBaseURL + "/track/%s"
deezerAlbumURL = deezerBaseURL + "/album/%s"
deezerArtistURL = deezerBaseURL + "/artist/%s"
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerBaseURL = "https://api.deezer.com/2.0"
deezerSearchURL = deezerBaseURL + "/search"
deezerTrackURL = deezerBaseURL + "/track/%s"
deezerAlbumURL = deezerBaseURL + "/album/%s"
deezerArtistURL = deezerBaseURL + "/artist/%s"
deezerArtistRelatedURL = deezerBaseURL + "/artist/%s/related"
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerCacheTTL = 10 * time.Minute
deezerMaxParallelISRC = 10
// Deezer API timeout and retry configuration for mobile networks
deezerAPITimeoutMobile = 25 * time.Second
deezerMaxRetries = 2
deezerRetryDelay = 500 * time.Millisecond
deezerMaxSearchCacheEntries = 300
deezerMaxAlbumCacheEntries = 200
deezerMaxArtistCacheEntries = 200
deezerMaxISRCCacheEntries = 4000
deezerCacheCleanupInterval = 5 * time.Minute
)
type DeezerClient struct {
httpClient *http.Client
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry
isrcCache map[string]string
cacheMu sync.RWMutex
httpClient *http.Client
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry
isrcCache map[string]string
cacheMu sync.RWMutex
lastCacheCleanup time.Time
cacheCleanupInterval time.Duration
}
var (
@@ -42,16 +56,111 @@ var (
func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry),
isrcCache: make(map[string]string),
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry),
isrcCache: make(map[string]string),
cacheCleanupInterval: deezerCacheCleanupInterval,
}
})
return deezerClient
}
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
cache map[string]*cacheEntry,
now time.Time,
) {
for key, entry := range cache {
if entry == nil || now.After(entry.expiresAt) {
delete(cache, key)
}
}
}
func (c *DeezerClient) trimCacheEntriesLocked(
cache map[string]*cacheEntry,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
for len(cache) > maxEntries {
var oldestKey string
var oldestExpiry time.Time
first := true
for key, entry := range cache {
expiry := time.Time{}
if entry != nil {
expiry = entry.expiresAt
}
if first || expiry.Before(oldestExpiry) {
first = false
oldestKey = key
oldestExpiry = expiry
}
}
if oldestKey == "" {
return
}
delete(cache, oldestKey)
}
}
func (c *DeezerClient) trimStringCacheEntriesLocked(
cache map[string]string,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
toRemove := len(cache) - maxEntries
for key := range cache {
delete(cache, key)
toRemove--
if toRemove <= 0 {
return
}
}
}
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
(c.lastCacheCleanup.IsZero() ||
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
if periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
c.lastCacheCleanup = now
}
if len(c.searchCache) > deezerMaxSearchCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
}
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
}
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
}
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
}
if len(c.artistCache) > deezerMaxArtistCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
}
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
}
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
}
}
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -87,15 +196,22 @@ type deezerAlbumSimple struct {
RecordType string `json:"record_type"`
}
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
artistName := track.Artist.Name
// deezerTrackArtistDisplay returns the display artist string for a track,
// preferring the Contributors list (comma-joined) when available, falling
// back to the primary Artist.Name.
func deezerTrackArtistDisplay(track deezerTrack) string {
if len(track.Contributors) > 0 {
names := make([]string, len(track.Contributors))
for i, a := range track.Contributors {
names[i] = a.Name
}
artistName = strings.Join(names, ", ")
return strings.Join(names, ", ")
}
return track.Artist.Name
}
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
artistName := deezerTrackArtistDisplay(track)
albumImage := track.Album.CoverXL
if albumImage == "" {
@@ -126,6 +242,8 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
ISRC: track.ISRC,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
}
}
@@ -145,6 +263,7 @@ type deezerAlbumFull struct {
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
@@ -409,10 +528,12 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
c.cacheMu.Lock()
now := time.Now()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
@@ -509,6 +630,12 @@ 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
@@ -527,7 +654,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name,
Artists: deezerTrackArtistDisplay(track),
Name: track.Title,
AlbumName: album.Title,
AlbumArtist: artistName,
@@ -537,6 +664,7 @@ 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),
@@ -550,10 +678,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
}
c.cacheMu.Lock()
now := time.Now()
c.albumCache[albumID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
@@ -625,6 +755,10 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
Artists: artist.Name,
})
}
// The Deezer /artist/{id}/albums endpoint does not return nb_tracks.
// Fetch track counts in parallel from individual /album/{id} endpoints.
c.fetchAlbumTrackCounts(ctx, albums)
}
result := &ArtistResponsePayload{
@@ -633,15 +767,134 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
}
c.cacheMu.Lock()
now := time.Now()
c.artistCache[artistID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
}
// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using
// individual /album/{id} calls, since the /artist/{id}/albums endpoint does
// not include this field. Albums whose track count is already known (non-zero)
// are skipped.
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
// Find albums that need track counts
type indexedID struct {
idx int
albumID string
}
var toFetch []indexedID
for i, a := range albums {
if a.TotalTracks == 0 {
rawID := strings.TrimPrefix(a.ID, "deezer:")
if rawID != "" {
toFetch = append(toFetch, indexedID{idx: i, albumID: rawID})
}
}
}
if len(toFetch) == 0 {
return
}
const maxParallel = 10
sem := make(chan struct{}, maxParallel)
var mu sync.Mutex
var wg sync.WaitGroup
for _, item := range toFetch {
wg.Add(1)
go func(it indexedID) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID)
var resp struct {
NbTracks int `json:"nb_tracks"`
}
if err := c.getJSON(ctx, albumURL, &resp); err != nil {
return
}
mu.Lock()
albums[it.idx].TotalTracks = resp.NbTracks
mu.Unlock()
}(item)
}
wg.Wait()
}
func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:"))
if normalizedArtistID == "" {
return nil, fmt.Errorf("invalid Deezer artist ID")
}
effectiveLimit := limit
if effectiveLimit <= 0 {
effectiveLimit = 12
}
relatedURL := fmt.Sprintf("%s?limit=%d", fmt.Sprintf(deezerArtistRelatedURL, normalizedArtistID), effectiveLimit)
var relatedResp struct {
Data []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbFan int `json:"nb_fan"`
} `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error,omitempty"`
}
if err := c.getJSON(ctx, relatedURL, &relatedResp); err != nil {
return nil, err
}
if relatedResp.Error != nil {
return nil, fmt.Errorf("deezer related artists error: %s", relatedResp.Error.Message)
}
result := make([]SearchArtistResult, 0, len(relatedResp.Data))
for _, artist := range relatedResp.Data {
imageURL := artist.PictureXL
if imageURL == "" {
imageURL = artist.PictureBig
}
if imageURL == "" {
imageURL = artist.PictureMedium
}
if imageURL == "" {
imageURL = artist.Picture
}
result = append(result, SearchArtistResult{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: imageURL,
Followers: artist.NbFan,
Popularity: 0,
})
}
return result, nil
}
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
@@ -714,7 +967,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name,
Artists: deezerTrackArtistDisplay(track),
Name: track.Title,
AlbumName: track.Album.Title,
AlbumArtist: track.Artist.Name,
@@ -802,6 +1055,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
for trackIDStr, isrc := range directISRCs {
c.isrcCache[trackIDStr] = isrc
}
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock()
}
@@ -836,6 +1090,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
c.cacheMu.Lock()
c.isrcCache[trackIDStr] = fullTrack.ISRC
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock()
}(track)
}
@@ -859,6 +1114,7 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
c.cacheMu.Lock()
c.isrcCache[trackID] = fullTrack.ISRC
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock()
return fullTrack.ISRC, nil
@@ -904,8 +1160,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
}
type AlbumExtendedMetadata struct {
Genre string
Label string
Genre string
Label string
Copyright string
}
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
@@ -936,18 +1193,21 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
}
result := &AlbumExtendedMetadata{
Genre: strings.Join(genres, ", "),
Label: album.Label,
Genre: strings.Join(genres, ", "),
Label: album.Label,
Copyright: album.Copyright,
}
c.cacheMu.Lock()
now := time.Now()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
return result, nil
}
@@ -992,6 +1252,41 @@ func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc strin
}
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))
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay)
}
err := c.doGetJSON(ctx, endpoint, dst)
if err == nil {
return nil
}
lastErr = err
errStr := err.Error()
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429")
if !isRetryable {
return err
}
GoLog("[Deezer] Attempt %d failed (retryable): %v\n", attempt+1, err)
}
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
}
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
+153
View File
@@ -0,0 +1,153 @@
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()
}
-4
View File
@@ -25,7 +25,6 @@ var (
)
func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first
isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
@@ -34,14 +33,11 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
return idx
}
// Slow path: need to build index
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
mu := buildLock.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
// Double-check cache after acquiring lock (another goroutine may have built it)
isrcIndexCacheMu.RLock()
idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
+2737 -907
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
package gobackend
import "testing"
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
result := buildDeezerExtendedMetadataResult(nil)
if result["genre"] != "" {
t.Fatalf("expected empty genre, got %q", result["genre"])
}
if result["label"] != "" {
t.Fatalf("expected empty label, got %q", result["label"])
}
if result["copyright"] != "" {
t.Fatalf("expected empty copyright, got %q", result["copyright"])
}
}
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
Genre: "Rock",
Label: "EMI",
Copyright: "(C) Queen",
})
if result["genre"] != "Rock" {
t.Fatalf("unexpected genre: %q", result["genre"])
}
if result["label"] != "EMI" {
t.Fatalf("unexpected label: %q", result["label"])
}
if result["copyright"] != "(C) Queen" {
t.Fatalf("unexpected copyright: %q", result["copyright"])
}
}
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
result := buildDeezerISRCSearchResult(&TrackMetadata{
SpotifyID: "deezer:3135556",
Name: "Love Of My Life",
Artists: "Queen",
AlbumName: "A Night at the Opera",
ISRC: "GBUM71029604",
ReleaseDate: "1975-11-21",
})
if result["spotify_id"] != "deezer:3135556" {
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
}
if result["id"] != "3135556" {
t.Fatalf("unexpected id: %v", result["id"])
}
if result["track_id"] != "3135556" {
t.Fatalf("unexpected track_id: %v", result["track_id"])
}
if result["success"] != true {
t.Fatalf("expected success=true, got %v", result["success"])
}
}
@@ -0,0 +1,83 @@
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)
}
@@ -0,0 +1,158 @@
package gobackend
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
dir := t.TempDir()
audioPath := filepath.Join(dir, "sidecar.mp3")
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte("[00:00.00]Sidecar lyric"), 0600); err != nil {
t.Fatal(err)
}
if jsonText, err := FetchLyrics("spotify-1", "Song Instrumental", "Artist", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
t.Fatalf("FetchLyrics instrumental = %q/%v", jsonText, err)
}
if lrc, err := GetLyricsLRC("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || lrc != "[instrumental:true]" {
t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err)
}
if jsonText, err := GetLyricsLRCWithSource("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
t.Fatalf("GetLyricsLRCWithSource instrumental = %q/%v", jsonText, err)
}
if lrc, err := GetLyricsLRC("", "", "", audioPath, 0); err != nil || !strings.Contains(lrc, "Sidecar lyric") {
t.Fatalf("GetLyricsLRC sidecar = %q/%v", lrc, err)
}
if jsonText, err := GetLyricsLRCWithSource("", "", "", audioPath, 0); err != nil || !strings.Contains(jsonText, "Sidecar lyric") {
t.Fatalf("GetLyricsLRCWithSource sidecar = %q/%v", jsonText, err)
}
outPath := filepath.Join(dir, "lyrics.lrc")
if err := FetchAndSaveLyrics("Song", "Artist", "", 0, outPath, audioPath); err != nil {
t.Fatalf("FetchAndSaveLyrics sidecar: %v", err)
}
if data := string(mustReadFile(t, outPath)); !strings.Contains(data, "Sidecar lyric") {
t.Fatalf("saved lyrics = %q", data)
}
if response, err := EmbedLyricsToFile(filepath.Join(dir, "not-flac.mp3"), "lyrics"); err != nil || !strings.Contains(response, `"success":false`) {
t.Fatalf("EmbedLyricsToFile error = %q/%v", response, err)
}
if response, err := RewriteSplitArtistTagsExport(filepath.Join(dir, "not-flac.mp3"), "A;B", "A"); err != nil || !strings.Contains(response, `"success":false`) {
t.Fatalf("RewriteSplitArtistTagsExport error = %q/%v", response, err)
}
}
func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
origClient := globalSongLinkClient
origRetryConfig := songLinkRetryConfig
origSearchByISRC := songLinkSearchByISRC
origCheckFromDeezer := songLinkCheckAvailabilityFromDeezer
defer func() {
globalSongLinkClient = origClient
songLinkRetryConfig = origRetryConfig
songLinkSearchByISRC = origSearchByISRC
songLinkCheckAvailabilityFromDeezer = origCheckFromDeezer
SetSongLinkNetworkOptions(false, false)
}()
songLinkRetryConfig = func() RetryConfig {
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
}
globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
var body string
if req.URL.Host == "api.zarz.moe" {
body = `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/spotify-1","Deezer":"https://www.deezer.com/track/101","Tidal":"https://listen.tidal.com/track/202","YouTube":"https://youtu.be/yt1","AmazonMusic":"https://music.amazon.com/tracks/amz1","Qobuz":"https://open.qobuz.com/track/303"}}`
} else if req.URL.Host == "api.song.link" {
body = `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}`
} else {
t.Fatalf("unexpected SongLink request: %s", req.URL.String())
}
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
})}}
songLinkClientOnce.Do(func() {})
SetSongLinkNetworkOptions(true, true)
if availabilityJSON, err := CheckAvailability("spotify-1", ""); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
t.Fatalf("CheckAvailability = %q/%v", availabilityJSON, err)
}
if availabilityJSON, err := CheckAvailabilityFromDeezerID("101"); err != nil || !strings.Contains(availabilityJSON, `"spotify_id":"spotify-1"`) {
t.Fatalf("CheckAvailabilityFromDeezerID = %q/%v", availabilityJSON, err)
}
if availabilityJSON, err := CheckAvailabilityByPlatformID("deezer", "song", "101"); err != nil || !strings.Contains(availabilityJSON, `"tidal_url"`) {
t.Fatalf("CheckAvailabilityByPlatformID = %q/%v", availabilityJSON, err)
}
if spotifyID, err := GetSpotifyIDFromDeezerTrack("101"); err != nil || spotifyID != "spotify-1" {
t.Fatalf("GetSpotifyIDFromDeezerTrack = %q/%v", spotifyID, err)
}
if tidalURL, err := GetTidalURLFromDeezerTrack("101"); err != nil || !strings.Contains(tidalURL, "tidal") {
t.Fatalf("GetTidalURLFromDeezerTrack = %q/%v", tidalURL, err)
}
if urls, err := NewSongLinkClient().GetStreamingURLs("spotify-1"); err != nil || urls["tidal"] == "" || urls["amazon"] == "" {
t.Fatalf("GetStreamingURLs = %#v/%v", urls, err)
}
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromSpotify("spotify-1"); err != nil || !strings.Contains(youtubeURL, "youtu") {
t.Fatalf("GetYouTubeURLFromSpotify = %q/%v", youtubeURL, err)
}
if amazonURL, err := NewSongLinkClient().GetAmazonURLFromDeezer("101"); err != nil || !strings.Contains(amazonURL, "amazon") {
t.Fatalf("GetAmazonURLFromDeezer = %q/%v", amazonURL, err)
}
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromDeezer("101"); err != nil || !strings.Contains(youtubeURL, "youtube") {
t.Fatalf("GetYouTubeURLFromDeezer = %q/%v", youtubeURL, err)
}
if deezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify("spotify-1"); err != nil || deezerID != "101" {
t.Fatalf("GetDeezerIDFromSpotify = %q/%v", deezerID, err)
}
if album, err := NewSongLinkClient().CheckAlbumAvailability("album-1"); err != nil || !album.Deezer || album.DeezerID == "" {
t.Fatalf("CheckAlbumAvailability = %#v/%v", album, err)
}
if albumID, err := NewSongLinkClient().GetDeezerAlbumIDFromSpotify("album-1"); err != nil || albumID == "" {
t.Fatalf("GetDeezerAlbumIDFromSpotify = %q/%v", albumID, err)
}
if availability, err := NewSongLinkClient().CheckAvailabilityFromURL("https://www.deezer.com/track/101"); err != nil || !availability.Deezer {
t.Fatalf("CheckAvailabilityFromURL = %#v/%v", availability, err)
}
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
return &TrackMetadata{SpotifyID: "deezer:101", ExternalURL: "https://www.deezer.com/track/101"}, nil
}
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
return &TrackAvailability{SpotifyID: "spotify-1", Deezer: true, DeezerID: deezerTrackID}, nil
}
if availabilityJSON, err := CheckAvailability("", "USRC17607839"); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
t.Fatalf("CheckAvailability by ISRC = %q/%v", availabilityJSON, err)
}
if songLinkExtractDeezerTrackID(nil) != "" || songLinkExtractDeezerTrackID(&TrackMetadata{ExternalURL: "https://www.deezer.com/track/202"}) != "202" {
t.Fatal("songLinkExtractDeezerTrackID mismatch")
}
deezerClient = &DeezerClient{
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
if body == "" {
body = `{"error":"missing"}`
}
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
})},
searchCache: map[string]*cacheEntry{},
albumCache: map[string]*cacheEntry{},
artistCache: map[string]*cacheEntry{},
isrcCache: map[string]string{},
cacheCleanupInterval: time.Hour,
}
deezerClientOnce.Do(func() {})
if jsonText, err := ConvertSpotifyToDeezer("track", "spotify-1"); err != nil || !strings.Contains(jsonText, `"spotify_id":"deezer:101"`) {
t.Fatalf("ConvertSpotifyToDeezer track = %q/%v", jsonText, err)
}
if jsonText, err := ConvertSpotifyToDeezer("album", "album-1"); err != nil || jsonText == "" {
t.Fatalf("ConvertSpotifyToDeezer album = %q/%v", jsonText, err)
}
}
+440
View File
@@ -0,0 +1,440 @@
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 TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
extensionsDir := filepath.Join(dir, "extensions")
if err := InitExtensionSystem(extensionsDir, dataDir); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
manager := getExtensionManager()
manager.mu.Lock()
if manager.extensions == nil {
manager.extensions = map[string]*loadedExtension{}
}
manager.extensions[ext.ID] = ext
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
delete(manager.extensions, ext.ID)
manager.mu.Unlock()
}()
if response, err := DownloadTrack(`{}`); err != nil || !strings.Contains(response, "retired") {
t.Fatalf("DownloadTrack = %q/%v", response, err)
}
if response, err := DownloadByStrategy(`not-json`); err != nil || !strings.Contains(response, "Invalid request") {
t.Fatalf("DownloadByStrategy invalid = %q/%v", response, err)
}
if response, err := DownloadByStrategy(`{"use_extensions":false}`); err != nil || !strings.Contains(response, "disabled") {
t.Fatalf("DownloadByStrategy disabled = %q/%v", response, err)
}
if response, err := DownloadWithFallback(`{}`); err != nil || !strings.Contains(response, "retired") {
t.Fatalf("DownloadWithFallback = %q/%v", response, err)
}
InitItemProgress("item-1")
FinishItemProgress("item-1")
ClearItemProgress("item-1")
CancelDownload("item-1")
if GetDownloadProgress() == "" || GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
t.Fatal("expected progress JSON")
}
CleanupConnections()
cuePath, audioPath := writeExportCueFixture(t, dir)
if jsonText, err := ParseCueSheet(cuePath, ""); err != nil {
t.Fatalf("ParseCueSheet = %q/%v", jsonText, err)
} else {
var parsed CueSplitInfo
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
t.Fatalf("decode ParseCueSheet: %v", err)
}
if parsed.AudioPath != audioPath {
t.Fatalf("ParseCueSheet audio path = %q want %q", parsed.AudioPath, audioPath)
}
}
if jsonText, err := ScanCueSheetForLibrary(cuePath, "", "virtual.cue", 111); err != nil || !strings.Contains(jsonText, "cue+wav") {
t.Fatalf("ScanCueSheetForLibrary = %q/%v", jsonText, err)
}
if jsonText, err := ScanCueSheetForLibraryWithCoverCacheKey(cuePath, "", "virtual.cue", 111, "cover-key"); err != nil || !strings.Contains(jsonText, "cue+wav") {
t.Fatalf("ScanCueSheetForLibraryWithCoverCacheKey = %q/%v", jsonText, err)
}
apePath := filepath.Join(dir, "edit.ape")
if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
editJSON := `{"title":"Edited","artist":"Artist","track_number":"1","track_total":"2","disc_number":"1","disc_total":"1"}`
if response, err := EditFileMetadata(apePath, editJSON); err != nil || !strings.Contains(response, "native_ape") {
t.Fatalf("EditFileMetadata ape = %q/%v", response, err)
}
if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") {
t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err)
}
misnamedM4APath := filepath.Join(dir, "misnamed.flac")
if err := os.WriteFile(misnamedM4APath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "Misnamed"), true), 0600); err != nil {
t.Fatal(err)
}
replayGainJSON := `{"replaygain_track_gain":"-1 dB","replaygain_track_peak":"0.9"}`
if response, err := EditFileMetadata(misnamedM4APath, replayGainJSON); err != nil || !strings.Contains(response, "native_m4a_replaygain") {
t.Fatalf("EditFileMetadata misnamed m4a replaygain = %q/%v", response, err)
}
if _, err := EditFileMetadata(apePath, `not-json`); err == nil {
t.Fatal("expected invalid metadata JSON")
}
if !hasOnlyM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-1 dB"}) {
t.Fatal("expected replaygain-only fields")
}
if hasOnlyM4AReplayGainFields(map[string]string{"title": "Song"}) {
t.Fatal("expected non-replaygain field rejection")
}
AllowDownloadDir(dir)
if err := SetDownloadDirectory(dir); err != nil {
t.Fatalf("SetDownloadDirectory: %v", err)
}
if duplicateJSON, err := CheckDuplicate(dir, ""); err != nil || !strings.Contains(duplicateJSON, "exists") {
t.Fatalf("CheckDuplicate = %q/%v", duplicateJSON, err)
}
if batchJSON, err := CheckDuplicatesBatch(dir, `[{"isrc":"","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(batchJSON, "Song") {
t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err)
}
_ = PreBuildDuplicateIndex(dir)
InvalidateDuplicateIndex(dir)
if filename, err := BuildFilename("{artist} - {title}", `{"artist":"A/B","title":"Song?"}`); err != nil || filename == "" {
t.Fatalf("BuildFilename = %q/%v", filename, err)
}
if _, err := BuildFilename("{title}", `not-json`); err == nil {
t.Fatal("expected BuildFilename JSON error")
}
if got := SanitizeFilename(`A/B:C*D?`); strings.ContainsAny(got, `/:*?`) {
t.Fatalf("SanitizeFilename = %q", got)
}
if response, err := PreWarmTrackCacheJSON(`not-json`); err != nil || !strings.Contains(response, "Invalid JSON") {
t.Fatalf("PreWarmTrackCacheJSON invalid = %q/%v", response, err)
}
if response, err := PreWarmTrackCacheJSON(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(response, "success") {
t.Fatalf("PreWarmTrackCacheJSON = %q/%v", response, err)
}
if GetTrackCacheSize() != 0 {
t.Fatal("expected empty track cache")
}
ClearTrackIDCache()
if err := SetLyricsProvidersJSON(`["lrclib","apple_music"]`); err != nil {
t.Fatalf("SetLyricsProvidersJSON: %v", err)
}
if providers, err := GetLyricsProvidersJSON(); err != nil || !strings.Contains(providers, "lrclib") {
t.Fatalf("GetLyricsProvidersJSON = %q/%v", providers, err)
}
if available, err := GetAvailableLyricsProvidersJSON(); err != nil || available == "" {
t.Fatalf("GetAvailableLyricsProvidersJSON = %q/%v", available, err)
}
if err := SetLyricsFetchOptionsJSON(`{"include_translation_netease":true}`); err != nil {
t.Fatalf("SetLyricsFetchOptionsJSON: %v", err)
}
if opts, err := GetLyricsFetchOptionsJSON(); err != nil || opts == "" {
t.Fatalf("GetLyricsFetchOptionsJSON = %q/%v", opts, err)
}
if err := SetProviderPriorityJSON(`["coverage-ext"]`); err != nil {
t.Fatalf("SetProviderPriorityJSON: %v", err)
}
if jsonText, err := GetProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
t.Fatalf("GetProviderPriorityJSON = %q/%v", jsonText, err)
}
if err := SetExtensionFallbackProviderIDsJSON(`["coverage-ext"]`); err != nil {
t.Fatalf("SetExtensionFallbackProviderIDsJSON: %v", err)
}
if jsonText, err := GetExtensionFallbackProviderIDsJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
t.Fatalf("GetExtensionFallbackProviderIDsJSON = %q/%v", jsonText, err)
}
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
t.Fatalf("reset extension fallback IDs: %v", err)
}
if err := SetMetadataProviderPriorityJSON(`["coverage-ext"]`); err != nil {
t.Fatalf("SetMetadataProviderPriorityJSON: %v", err)
}
if jsonText, err := GetMetadataProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
t.Fatalf("GetMetadataProviderPriorityJSON = %q/%v", jsonText, err)
}
if err := SetExtensionSettingsJSON(ext.ID, `{"quality":"lossless","_secret":"hidden"}`); err != nil {
t.Fatalf("SetExtensionSettingsJSON: %v", err)
}
if settingsJSON, err := GetExtensionSettingsJSON(ext.ID); err != nil || !strings.Contains(settingsJSON, "quality") {
t.Fatalf("GetExtensionSettingsJSON = %q/%v", settingsJSON, err)
}
if err := SetExtensionSettingsJSON(ext.ID, `not-json`); err == nil {
t.Fatal("expected settings JSON error")
}
if jsonText, err := SearchTracksWithExtensionsJSON("song", 5); err != nil || !strings.Contains(jsonText, "search-1") {
t.Fatalf("SearchTracksWithExtensionsJSON = %q/%v", jsonText, err)
}
if jsonText, err := SearchTracksWithMetadataProvidersJSON("song", 5, true); err != nil || !strings.Contains(jsonText, "search-1") {
t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err)
}
if jsonText, err := GetProviderMetadataJSON(ext.ID, "track", "track-1"); err != nil || !strings.Contains(jsonText, "Track track-1") {
t.Fatalf("GetProviderMetadataJSON track = %q/%v", jsonText, err)
}
for _, resourceType := range []string{"album", "playlist", "artist"} {
if jsonText, err := GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1"); err != nil || jsonText == "" {
t.Fatalf("GetProviderMetadataJSON %s = %q/%v", resourceType, jsonText, err)
}
}
if _, err := GetProviderMetadataJSON("", "track", "id"); err == nil {
t.Fatal("expected empty provider ID error")
}
if _, err := GetProviderMetadataJSON(ext.ID, "unsupported", "id"); err == nil {
t.Fatal("expected unsupported provider type")
}
if firstNonEmptyTrimmed(" ", " value ") != "value" {
t.Fatal("expected first trimmed value")
}
requestJSON := `{"use_extensions":true,"use_fallback":false,"service":"coverage-ext","source":"coverage-ext","track_name":"Song","artist_name":"Artist","album_name":"Album","output_dir":"` + escapeJSONPath(dir) + `","output_ext":".flac","quality":"LOSSLESS"}`
if jsonText, err := DownloadWithExtensionsJSON(requestJSON); err != nil || !strings.Contains(jsonText, "coverage-ext") {
t.Fatalf("DownloadWithExtensionsJSON = %q/%v", jsonText, err)
}
if _, err := DownloadWithExtensionsJSON(`not-json`); err == nil {
t.Fatal("expected DownloadWithExtensionsJSON JSON error")
}
SetExtensionAuthCodeByID(ext.ID, "code")
SetExtensionTokensByID(ext.ID, "access", "refresh", 60)
if !IsExtensionAuthenticatedByID(ext.ID) {
t.Fatal("expected authenticated extension")
}
if pending, err := GetExtensionPendingAuthJSON(ext.ID); err != nil || pending != "" {
t.Fatalf("GetExtensionPendingAuthJSON = %q/%v", pending, err)
}
ClearExtensionPendingAuthByID(ext.ID)
if all, err := GetAllPendingAuthRequestsJSON(); err != nil || all == "" {
t.Fatalf("GetAllPendingAuthRequestsJSON = %q/%v", all, err)
}
ffmpegCommandsMu.Lock()
ffmpegCommands["cmd-1"] = &FFmpegCommand{ExtensionID: ext.ID, Command: "ffmpeg -version", InputPath: "in", OutputPath: "out"}
ffmpegCommandsMu.Unlock()
if cmdJSON, err := GetPendingFFmpegCommandJSON("cmd-1"); err != nil || !strings.Contains(cmdJSON, "cmd-1") {
t.Fatalf("GetPendingFFmpegCommandJSON = %q/%v", cmdJSON, err)
}
if all, err := GetAllPendingFFmpegCommandsJSON(); err != nil || !strings.Contains(all, "cmd-1") {
t.Fatalf("GetAllPendingFFmpegCommandsJSON = %q/%v", all, err)
}
SetFFmpegCommandResultByID("cmd-1", true, "ok", "")
ClearFFmpegCommand("cmd-1")
if empty, err := GetPendingFFmpegCommandJSON("missing"); err != nil || empty != "" {
t.Fatalf("missing ffmpeg = %q/%v", empty, err)
}
enrichedJSON, err := EnrichTrackWithExtensionJSON(ext.ID, `{"id":"track-1","name":"Old","artists":"Artist"}`)
if err != nil || !strings.Contains(enrichedJSON, "Enriched") {
t.Fatalf("EnrichTrackWithExtensionJSON = %q/%v", enrichedJSON, err)
}
if sameJSON, err := EnrichTrackWithExtensionJSON("missing", `{"name":"Old"}`); err != nil || !strings.Contains(sameJSON, "Old") {
t.Fatalf("missing EnrichTrackWithExtensionJSON = %q/%v", sameJSON, err)
}
deezerClient = &DeezerClient{
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
status := http.StatusOK
if body == "" {
status = http.StatusNotFound
body = `{"error":"missing"}`
}
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
})},
searchCache: map[string]*cacheEntry{},
albumCache: map[string]*cacheEntry{},
artistCache: map[string]*cacheEntry{},
isrcCache: map[string]string{},
cacheCleanupInterval: time.Hour,
}
deezerClientOnce.Do(func() {})
for _, item := range []struct {
typ string
id string
}{
{"track", "101"},
{"album", "201"},
{"artist", "301"},
{"playlist", "401"},
} {
if jsonText, err := GetDeezerMetadata(item.typ, item.id); err != nil || jsonText == "" {
t.Fatalf("GetDeezerMetadata %s = %q/%v", item.typ, jsonText, err)
}
}
if _, err := GetDeezerMetadata("bad", "1"); err == nil {
t.Fatal("expected unsupported Deezer metadata type")
}
if jsonText, err := GetDeezerRelatedArtists("301", 2); err != nil || !strings.Contains(jsonText, "Related") {
t.Fatalf("GetDeezerRelatedArtists = %q/%v", jsonText, err)
}
if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") {
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
}
if _, err := GetDeezerExtendedMetadata(""); err == nil {
t.Fatal("expected empty Deezer metadata ID error")
}
if jsonText, err := SearchDeezerByISRC("USRC17607839"); err != nil || !strings.Contains(jsonText, "deezer:101") {
t.Fatalf("SearchDeezerByISRC = %q/%v", jsonText, err)
}
if jsonText, err := SearchDeezerByISRCForItemID("USRC17607839", "item-isrc"); err != nil || !strings.Contains(jsonText, "deezer:101") {
t.Fatalf("SearchDeezerByISRCForItemID = %q/%v", jsonText, err)
}
customJSON, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`)
if err != nil || !strings.Contains(customJSON, "Custom needle") {
t.Fatalf("CustomSearchWithExtensionJSON = %q/%v", customJSON, err)
}
if customJSON, err := CustomSearchWithExtensionJSONWithRequestID(ext.ID, "needle", `not-json`, "req-custom"); err != nil || !strings.Contains(customJSON, "custom-1") {
t.Fatalf("CustomSearchWithExtensionJSONWithRequestID = %q/%v", customJSON, err)
}
if providersJSON, err := GetSearchProvidersJSON(); err != nil || !strings.Contains(providersJSON, "coverage-ext") {
t.Fatalf("GetSearchProvidersJSON = %q/%v", providersJSON, err)
}
if found := FindURLHandlerJSON("https://example.test/track/1"); found != ext.ID {
t.Fatalf("FindURLHandlerJSON = %q", found)
}
if handlersJSON, err := GetURLHandlersJSON(); err != nil || !strings.Contains(handlersJSON, "coverage-ext") {
t.Fatalf("GetURLHandlersJSON = %q/%v", handlersJSON, err)
}
if handledJSON, err := HandleURLWithExtensionJSON("https://example.test/track/1"); err != nil || !strings.Contains(handledJSON, "url-track") {
t.Fatalf("HandleURLWithExtensionJSON = %q/%v", handledJSON, err)
}
if postJSON, err := RunPostProcessingJSON(filepath.Join(dir, "song.flac"), `{"title":"Song"}`); err != nil || !strings.Contains(postJSON, "success") {
t.Fatalf("RunPostProcessingJSON = %q/%v", postJSON, err)
}
v2Input := `{"path":"` + escapeJSONPath(filepath.Join(dir, "song.flac")) + `","uri":"content://song","name":"song.flac","mime_type":"audio/flac","size":10}`
if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") {
t.Fatalf("RunPostProcessingV2JSON = %q/%v", postJSON, err)
}
if postProviders, err := GetPostProcessingProvidersJSON(); err != nil || !strings.Contains(postProviders, "hook") {
t.Fatalf("GetPostProcessingProvidersJSON = %q/%v", postProviders, err)
}
if feedJSON, err := GetExtensionHomeFeedJSON(ext.ID); err != nil || !strings.Contains(feedJSON, "home-1") {
t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err)
}
if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") {
t.Fatalf("GetExtensionHomeFeedJSONWithRequestID = %q/%v", feedJSON, err)
}
if categoriesJSON, err := GetExtensionBrowseCategoriesJSON(ext.ID); err != nil || !strings.Contains(categoriesJSON, "cat-1") {
t.Fatalf("GetExtensionBrowseCategoriesJSON = %q/%v", categoriesJSON, err)
}
CancelExtensionRequestJSON("req-home")
storeDir := filepath.Join(dir, "store")
if err := InitExtensionStoreJSON(storeDir); err != nil {
t.Fatalf("InitExtensionStoreJSON: %v", err)
}
if err := SetStoreRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
t.Fatalf("SetStoreRegistryURLJSON: %v", err)
}
store := getExtensionStore()
store.cache = &storeRegistry{Extensions: []storeExtension{{
ID: "coverage-ext",
Name: "coverage-ext",
Version: "1.0.0",
Description: "Coverage",
Category: CategoryMetadata,
Tags: []string{"metadata"},
DownloadURL: "https://registry.example.com/coverage.spotiflac-ext",
}}}
store.cacheTime = time.Now()
if registryURL, err := GetStoreRegistryURLJSON(); err != nil || registryURL == "" {
t.Fatalf("GetStoreRegistryURLJSON = %q/%v", registryURL, err)
}
if storeJSON, err := GetStoreExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("GetStoreExtensionsJSON = %q/%v", storeJSON, err)
}
if storeJSON, err := SearchStoreExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
t.Fatalf("SearchStoreExtensionsJSON = %q/%v", storeJSON, err)
}
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
}
if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
}
if _, err := buildStoreExtensionDestPath(dir, " "); err == nil {
t.Fatal("expected invalid extension id")
}
if err := ClearStoreCacheJSON(); err != nil {
t.Fatalf("ClearStoreCacheJSON: %v", err)
}
if err := ClearStoreRegistryURLJSON(); err != nil {
t.Fatalf("ClearStoreRegistryURLJSON: %v", err)
}
SetLibraryCoverCacheDirJSON(filepath.Join(dir, "covers"))
libraryDir := filepath.Join(dir, "library")
if err := os.MkdirAll(libraryDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(libraryDir, "Artist - Song.mp3"), []byte("not mp3"), 0600); err != nil {
t.Fatal(err)
}
if scanJSON, err := ScanLibraryFolderJSON(libraryDir); err != nil || !strings.Contains(scanJSON, "Song") {
t.Fatalf("ScanLibraryFolderJSON = %q/%v", scanJSON, err)
}
if scanJSON, err := ScanLibraryFolderIncrementalJSON(libraryDir, `[]`); err != nil || !strings.Contains(scanJSON, "Song") {
t.Fatalf("ScanLibraryFolderIncrementalJSON = %q/%v", scanJSON, err)
}
snapshotPath := filepath.Join(dir, "snapshot.json")
if err := os.WriteFile(snapshotPath, []byte(`[]`), 0600); err != nil {
t.Fatal(err)
}
if scanJSON, err := ScanLibraryFolderIncrementalFromSnapshotJSON(libraryDir, snapshotPath); err != nil || !strings.Contains(scanJSON, "Song") {
t.Fatalf("ScanLibraryFolderIncrementalFromSnapshotJSON = %q/%v", scanJSON, err)
}
if GetLibraryScanProgressJSON() == "" {
t.Fatal("expected scan progress JSON")
}
CancelLibraryScanJSON()
if metadataJSON, err := ReadAudioMetadataJSON(filepath.Join(libraryDir, "missing.mp3")); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataJSON = %q/%v", metadataJSON, err)
}
if metadataJSON, err := ReadAudioMetadataWithHintJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing"); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataWithHintJSON = %q/%v", metadataJSON, err)
}
if metadataJSON, err := ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing", "key"); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataWithHintAndCoverCacheKeyJSON = %q/%v", metadataJSON, err)
}
}
+609
View File
@@ -0,0 +1,609 @@
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)
}
}
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{
TrackName: "Bonus Track",
ArtistName: "Artist",
AlbumName: "Album (Deluxe)",
AlbumArtist: "Artist",
ReleaseDate: "2024-01-01",
TrackNumber: 14,
DiscNumber: 1,
ISRC: "REQ123",
CoverURL: "https://example.com/cover.jpg",
Genre: "Pop",
Label: "Label",
Copyright: "Copyright",
}
result := DownloadResult{
Title: "Bonus Track",
Artist: "Artist",
Album: "Album",
ReleaseDate: "2023-12-01",
TrackNumber: 2,
DiscNumber: 9,
ISRC: "RES456",
}
resp := buildDownloadSuccessResponse(
req,
result,
"tidal",
"ok",
"/tmp/test.flac",
false,
)
if resp.Album != req.AlbumName {
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
}
if resp.ReleaseDate != req.ReleaseDate {
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
}
if resp.TrackNumber != req.TrackNumber {
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
}
if resp.DiscNumber != req.DiscNumber {
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
}
if resp.Artist != result.Artist {
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
}
if resp.ISRC != result.ISRC {
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
}
}
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
DownloadRequest{
AlbumName: "Album (Deluxe Edition)",
ReleaseDate: "2024-01-01",
TrackNumber: 13,
DiscNumber: 2,
},
"Album",
"2023-01-01",
3,
1,
)
if album != "Album (Deluxe Edition)" {
t.Fatalf("album = %q", album)
}
if releaseDate != "2024-01-01" {
t.Fatalf("release date = %q", releaseDate)
}
if trackNumber != 13 {
t.Fatalf("track number = %d", trackNumber)
}
if discNumber != 2 {
t.Fatalf("disc number = %d", discNumber)
}
}
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
req := DownloadRequest{
TrackName: "Track",
ArtistName: "Artist",
AlbumName: "Album",
AlbumArtist: "Artist",
}
result := DownloadResult{
Title: "Track",
Artist: "Artist",
Album: "Album",
CoverURL: "https://cdn.qobuz.test/cover.jpg",
}
resp := buildDownloadSuccessResponse(
req,
result,
"qobuz",
"ok",
"/tmp/test.flac",
false,
)
if resp.CoverURL != result.CoverURL {
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
}
}
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",
AlbumName: "Original Album",
ReleaseDate: "2024-01-01",
ISRC: "REQ123",
}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
AlbumName: "Resolved Album",
ReleaseDate: "",
ISRC: "",
})
if req.ReleaseDate != "2024-01-01" {
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
}
if req.AlbumName != "Resolved Album" {
t.Fatalf("album = %q, want updated album", req.AlbumName)
}
if req.ISRC != "REQ123" {
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
}
}
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
AlbumName: "Album Name",
ReleaseDate: "",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "first",
Name: "Song Title",
Artists: "Artist Name",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "",
ProviderID: "spotify",
},
{
ID: "second",
Name: "Song Title",
Artists: "Artist Name",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "2024-03-09",
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected a selected track")
}
if best.ID != "second" {
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
}
}
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",
ArtistName: "Artist",
AlbumName: "Album",
AlbumArtist: "",
ReleaseDate: "",
TrackNumber: 0,
DiscNumber: 0,
ISRC: "",
Genre: "",
Label: "",
Copyright: "",
}
metadata := buildReEnrichFFmpegMetadata(&req, "")
if metadata["TITLE"] != "Song" {
t.Fatalf("title = %q", metadata["TITLE"])
}
if metadata["ARTIST"] != "Artist" {
t.Fatalf("artist = %q", metadata["ARTIST"])
}
if metadata["ALBUM"] != "Album" {
t.Fatalf("album = %q", metadata["ALBUM"])
}
for _, key := range []string{
"ALBUMARTIST",
"DATE",
"TRACKNUMBER",
"DISCNUMBER",
"ISRC",
"GENRE",
"ORGANIZATION",
"COPYRIGHT",
"LYRICS",
"UNSYNCEDLYRICS",
} {
if _, exists := metadata[key]; exists {
t.Fatalf("did not expect key %s in metadata: %#v", key, metadata)
}
}
}
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"])
}
}
+390
View File
@@ -0,0 +1,390 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
extensionHealthDefaultCache = 60 * time.Second
)
type ExtensionHealthResult struct {
ExtensionID string `json:"extension_id"`
Status string `json:"status"`
CheckedAt string `json:"checked_at"`
Checks []ExtensionHealthCheckResult `json:"checks"`
}
type ExtensionHealthCheckResult struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
URL string `json:"url"`
Method string `json:"method"`
ServiceKey string `json:"service_key,omitempty"`
Required bool `json:"required"`
Status string `json:"status"`
HTTPStatus int `json:"http_status,omitempty"`
LatencyMs int64 `json:"latency_ms"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
CheckedAt string `json:"checked_at"`
}
type cachedExtensionHealthResult struct {
result ExtensionHealthResult
expiresAt time.Time
}
var (
extensionHealthCacheMu sync.Mutex
extensionHealthCache = map[string]cachedExtensionHealthResult{}
)
func CheckExtensionHealthJSON(extensionID string) (string, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
result := CheckExtensionHealth(ext)
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)
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
extensionHealthCacheMu.Lock()
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
result: result,
expiresAt: now.Add(ttl),
}
extensionHealthCacheMu.Unlock()
return result
}
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 < 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 {
result.Status = "offline"
result.Error = err.Error()
return result
}
defer resp.Body.Close()
result.HTTPStatus = resp.StatusCode
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
result.Status = "offline"
result.Message = resp.Status
return result
}
if method == http.MethodHead {
result.Status = "online"
result.Message = resp.Status
return result
}
body, err := io.ReadAll(io.LimitReader(resp.Body, extensionHealthMaxBodyBytes))
if err != nil {
result.Status = "degraded"
result.Error = err.Error()
return result
}
status, message := classifyExtensionHealthBody(body, check.ServiceKey)
result.Status = status
if message == "" {
result.Message = resp.Status
} else {
result.Message = message
}
return result
}
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
if len(strings.TrimSpace(string(body))) == 0 {
return "online", ""
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return "online", ""
}
serviceKey = strings.TrimSpace(serviceKey)
if serviceKey != "" {
if status, message, ok := classifyExtensionHealthService(payload, serviceKey); ok {
return status, message
}
}
rawStatus, _ := payload["status"].(string)
normalized := strings.ToLower(strings.TrimSpace(rawStatus))
switch normalized {
case "", "ok", "up", "online", "healthy", "operational", "pass", "passing":
return "online", rawStatus
case "degraded", "partial", "warning", "warn":
return "degraded", rawStatus
case "down", "offline", "error", "failed", "fail", "unhealthy":
return "offline", rawStatus
default:
return "online", rawStatus
}
}
func classifyExtensionHealthService(payload map[string]interface{}, serviceKey string) (string, string, bool) {
rawServices, ok := payload["services"]
if !ok {
return "", "", false
}
services, ok := rawServices.(map[string]interface{})
if !ok {
return "", "", false
}
rawService, ok := services[serviceKey]
if !ok {
return "unknown", fmt.Sprintf("service '%s' not found", serviceKey), true
}
service, ok := rawService.(map[string]interface{})
if !ok {
return "unknown", fmt.Sprintf("service '%s' has invalid health payload", serviceKey), true
}
label, _ := service["label"].(string)
detail, _ := service["detail"].(string)
errText, _ := service["error"].(string)
messageParts := []string{}
if strings.TrimSpace(label) != "" {
messageParts = append(messageParts, strings.TrimSpace(label))
}
if strings.TrimSpace(detail) != "" {
messageParts = append(messageParts, strings.TrimSpace(detail))
}
if strings.TrimSpace(errText) != "" {
messageParts = append(messageParts, strings.TrimSpace(errText))
}
rawStatus, hasStatus := service["status"]
okValue, hasOK := service["ok"].(bool)
if statusCode, ok := healthNumber(rawStatus); ok {
if statusCode >= 200 && statusCode < 300 {
return "online", strings.Join(messageParts, ": "), true
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return "degraded", strings.Join(messageParts, ": "), true
}
if statusCode == http.StatusInternalServerError && hasOK && okValue {
return "online", strings.Join(messageParts, ": "), true
}
return "offline", strings.Join(messageParts, ": "), true
}
if isExtensionHealthAuthRequired(detail) {
return "degraded", strings.Join(messageParts, ": "), true
}
if hasOK {
if okValue {
return "online", strings.Join(messageParts, ": "), true
}
return "offline", strings.Join(messageParts, ": "), true
}
if !hasStatus {
return "unknown", strings.Join(messageParts, ": "), true
}
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
switch statusString {
case "ok", "up", "online", "healthy", "operational":
return "online", strings.Join(messageParts, ": "), true
case "degraded", "partial", "warning", "warn":
return "degraded", strings.Join(messageParts, ": "), true
case "down", "offline", "error", "failed", "fail", "unhealthy":
return "offline", strings.Join(messageParts, ": "), true
default:
return "unknown", strings.Join(messageParts, ": "), true
}
}
func isExtensionHealthAuthRequired(detail string) bool {
switch strings.ToLower(strings.TrimSpace(detail)) {
case "auth_required", "authorization_required", "login_required", "unauthorized":
return true
default:
return false
}
}
func healthNumber(value interface{}) (int, bool) {
switch v := value.(type) {
case float64:
return int(v), true
case int:
return v, true
case json.Number:
n, err := v.Int64()
return int(n), err == nil
default:
return 0, false
}
}
@@ -0,0 +1,143 @@
package gobackend
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
func TestExtensionHealthClassificationAndValidation(t *testing.T) {
if status, msg := classifyExtensionHealthBody([]byte(`{"status":"degraded"}`), ""); status != "degraded" || msg != "degraded" {
t.Fatalf("status/message = %q/%q", status, msg)
}
if status, _ := classifyExtensionHealthBody([]byte(`not-json`), ""); status != "online" {
t.Fatalf("invalid JSON status = %q", status)
}
if status, msg := classifyExtensionHealthBody([]byte(`{"services":{"tidal":{"status":401,"label":"Tidal","detail":"auth_required"}}}`), "tidal"); status != "degraded" || !strings.Contains(msg, "Tidal") {
t.Fatalf("service status/message = %q/%q", status, msg)
}
if status, msg, ok := classifyExtensionHealthService(map[string]interface{}{"services": map[string]interface{}{}}, "missing"); !ok || status != "unknown" || !strings.Contains(msg, "missing") {
t.Fatalf("missing service = %q/%q/%v", status, msg, ok)
}
if n, ok := healthNumber(json.Number("503")); !ok || n != 503 {
t.Fatalf("health number = %d/%v", n, ok)
}
if !isExtensionHealthAuthRequired(" unauthorized ") {
t.Fatal("expected auth required")
}
if result := CheckExtensionHealth(nil); result.Status != "offline" {
t.Fatalf("nil health = %#v", result)
}
manifest := &ExtensionManifest{Permissions: ExtensionPermissions{Network: []string{"status.example.com"}}}
invalidURL := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "bad", URL: "://bad"})
if invalidURL.Status != "offline" {
t.Fatalf("invalid URL = %#v", invalidURL)
}
insecure := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "http", URL: "http://status.example.com"})
if insecure.Status != "offline" || !strings.Contains(insecure.Error, "https") {
t.Fatalf("insecure = %#v", insecure)
}
disallowedHost := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "host", URL: "https://other.example.com"})
if disallowedHost.Status != "offline" || !strings.Contains(disallowedHost.Error, "permissions") {
t.Fatalf("host = %#v", disallowedHost)
}
badMethod := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "method", URL: "https://status.example.com", Method: "POST"})
if badMethod.Status != "offline" || !strings.Contains(badMethod.Error, "method") {
t.Fatalf("method = %#v", badMethod)
}
ext := &loadedExtension{
ID: "health-ext",
Manifest: &ExtensionManifest{
ServiceHealth: []ExtensionHealthCheck{
{ID: "required", URL: "http://status.example.com", Required: true},
{ID: "optional", URL: "http://status.example.com", Required: false},
},
},
}
if result := CheckExtensionHealth(ext); result.Status != "offline" || len(result.Checks) != 2 {
t.Fatalf("extension health = %#v", result)
}
}
func TestCoverRomajiParallelAndIDHSHelpers(t *testing.T) {
spotify := "https://i.scdn.co/image/ab67616d00001e02abcdef"
if got := GetCoverFromSpotify(spotify, true); !strings.Contains(got, spotifySizeMax) {
t.Fatalf("spotify cover = %q", got)
}
if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1800x1800") {
t.Fatalf("deezer cover = %q", got)
}
if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") {
t.Fatalf("tidal cover = %q", got)
}
if got := upgradeToMaxQuality("https://static.qobuz.com/images/covers/ab/cd/foo_600.jpg"); !strings.Contains(got, "_max.jpg") {
t.Fatalf("qobuz cover = %q", got)
}
if data, err := downloadCoverToMemory("", false); err == nil || data != nil {
t.Fatalf("expected empty cover error")
}
if !ContainsJapanese("カタカナ") || ContainsJapanese("abc") {
t.Fatal("unexpected Japanese detection")
}
if got := JapaneseToRomaji("きゃット"); got != "kyatto" {
t.Fatalf("romaji = %q", got)
}
if got := BuildSearchQuery("きゃ! song", "アーティスト"); got != "atisuto kya song" {
t.Fatalf("query = %q", got)
}
if got := CleanToASCII("A, B. C!"); got != "A B C" {
t.Fatalf("ascii = %q", got)
}
if err := PreWarmCache(`not-json`); err == nil {
t.Fatal("expected prewarm JSON error")
}
if err := PreWarmCache(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist","spotify_id":"sp","service":"tidal"}]`); err != nil {
t.Fatalf("PreWarmCache: %v", err)
}
if result := FetchCoverAndLyricsParallel("", false, "", "", "", false, 0); result == nil || result.CoverErr != nil || result.LyricsErr != nil {
t.Fatalf("parallel result = %#v", result)
}
if ClearTrackCache(); GetCacheSize() != 0 {
t.Fatal("expected empty cache size")
}
client := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
t.Fatalf("method = %s", req.Method)
}
body := `{"id":"1","type":"song","title":"Song","links":[{"type":"tidal","url":"https://tidal.com/browse/track/7"},{"type":"deezer","url":"https://www.deezer.com/track/9"},{"type":"spotify","url":"https://open.spotify.com/track/abc"}]}`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})}}
availability, err := client.GetAvailabilityFromSpotify("spotify-track")
if err != nil {
t.Fatalf("GetAvailabilityFromSpotify: %v", err)
}
if !availability.Tidal || !availability.Deezer || availability.DeezerID != "9" {
t.Fatalf("spotify availability = %#v", availability)
}
deezerAvailability, err := client.GetAvailabilityFromDeezer("9")
if err != nil {
t.Fatalf("GetAvailabilityFromDeezer: %v", err)
}
if deezerAvailability.SpotifyID != "abc" || !deezerAvailability.Tidal {
t.Fatalf("deezer availability = %#v", deezerAvailability)
}
errorClient := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
})}}
if _, err := errorClient.Search("bad", nil); err == nil {
t.Fatal("expected rate limit error")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
package gobackend
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestExtensionManagerPackageLifecycle(t *testing.T) {
dir := t.TempDir()
extensionsDir := filepath.Join(dir, "extensions")
dataDir := filepath.Join(dir, "data")
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
t.Fatalf("SetDirectories: %v", err)
}
if err := GetExtensionSettingsStore().SetDataDir(dataDir); err != nil {
t.Fatalf("settings data dir: %v", err)
}
js := `
var cleaned = false;
registerExtension({
initialize: function(settings) { this.settings = settings || {}; },
cleanup: function() { cleaned = true; },
doAction: function() { return { message: "done", setting_updates: { quality: "lossless" } }; },
getHomeFeed: function() { return [{ id: "home", title: "Home" }]; },
getBrowseCategories: function() { return [{ id: "cat", title: "Category" }]; },
searchTracks: function() { return { tracks: [], total: 0 }; },
fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; },
getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; }
});
`
pkgV1 := filepath.Join(dir, "manager-ext-v1.spotiflac-ext")
createTestExtensionPackage(t, pkgV1, "manager-ext", "1.0.0", js, map[string]string{"../unsafe.txt": "skip"})
pkgV2 := filepath.Join(dir, "manager-ext-v2.spotiflac-ext")
createTestExtensionPackage(t, pkgV2, "manager-ext", "1.1.0", js, nil)
if compareVersions("v1.2.0", "1.1.9") <= 0 || compareVersions("1.0.0", "1.0") != 0 || compareVersions("1.0.0", "1.0.1") >= 0 {
t.Fatal("compareVersions mismatch")
}
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "bad.txt")); err == nil {
t.Fatal("expected bad extension suffix error")
}
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "missing.spotiflac-ext")); err == nil {
t.Fatal("expected invalid package error")
}
ext, err := manager.LoadExtensionFromFile(pkgV1)
if err != nil {
t.Fatalf("LoadExtensionFromFile: %v", err)
}
if ext.ID != "manager-ext" || ext.Enabled || ext.SourceDir == "" {
t.Fatalf("loaded extension = %#v", ext)
}
if _, err := os.Stat(filepath.Join(ext.SourceDir, "unsafe.txt")); err == nil {
t.Fatal("unsafe archive path should not be extracted")
}
if _, err := manager.LoadExtensionFromFile(pkgV1); err == nil {
t.Fatal("expected duplicate version error")
}
installedJSON, err := manager.GetInstalledExtensionsJSON()
if err != nil || !strings.Contains(installedJSON, "manager-ext") || !strings.Contains(installedJSON, "icon_path") {
t.Fatalf("GetInstalledExtensionsJSON = %q/%v", installedJSON, err)
}
var installed []map[string]interface{}
if err := json.Unmarshal([]byte(installedJSON), &installed); err != nil || len(installed) != 1 {
t.Fatalf("decode installed = %#v/%v", installed, err)
}
if err := GetExtensionSettingsStore().Set("manager-ext", "quality", "lossless"); err != nil {
t.Fatalf("settings Set: %v", err)
}
if err := manager.SetExtensionEnabled("manager-ext", true); err != nil {
t.Fatalf("enable extension: %v", err)
}
if !ext.Enabled || ext.VM == nil || !ext.initialized {
t.Fatalf("enabled extension = %#v", ext)
}
if err := manager.InitializeExtension("manager-ext", map[string]interface{}{"quality": "hires"}); err != nil {
t.Fatalf("InitializeExtension: %v", err)
}
action, err := manager.InvokeAction("manager-ext", "doAction")
if err != nil || action["success"] != true || action["message"] != "done" {
t.Fatalf("InvokeAction = %#v/%v", action, err)
}
if err := manager.CleanupExtension("manager-ext"); err != nil {
t.Fatalf("CleanupExtension: %v", err)
}
if err := manager.SetExtensionEnabled("manager-ext", false); err != nil {
t.Fatalf("disable extension: %v", err)
}
if ext.VM != nil || ext.initialized {
t.Fatalf("expected VM teardown, got %#v", ext)
}
if _, err := manager.InvokeAction("manager-ext", "doAction"); err == nil {
t.Fatal("expected disabled action error")
}
upgradeJSON, err := manager.CheckExtensionUpgradeJSON(pkgV2)
if err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) {
t.Fatalf("CheckExtensionUpgradeJSON = %q/%v", upgradeJSON, err)
}
upgraded, err := manager.UpgradeExtension(pkgV2)
if err != nil {
t.Fatalf("UpgradeExtension: %v", err)
}
if upgraded.Manifest.Version != "1.1.0" {
t.Fatalf("upgraded = %#v", upgraded.Manifest)
}
if _, err := manager.UpgradeExtension(pkgV1); err == nil {
t.Fatal("expected downgrade error")
}
if err := manager.RemoveExtension("manager-ext"); err != nil {
t.Fatalf("RemoveExtension: %v", err)
}
if _, err := manager.GetExtension("manager-ext"); err == nil {
t.Fatal("expected removed extension missing")
}
dirExt := filepath.Join(extensionsDir, "dir-ext")
if err := os.MkdirAll(dirExt, 0755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"dir-ext","displayName":"dir-ext","version":"1.0.0","description":"Directory extension","type":["metadata_provider"],"permissions":{}}`
if err := os.WriteFile(filepath.Join(dirExt, "manifest.json"), []byte(manifest), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dirExt, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600); err != nil {
t.Fatal(err)
}
loaded, loadErrs := manager.LoadExtensionsFromDirectory(extensionsDir)
if len(loadErrs) != 0 || len(loaded) != 1 || loaded[0] != "dir-ext" {
t.Fatalf("LoadExtensionsFromDirectory = %#v/%#v", loaded, loadErrs)
}
manager.UnloadAllExtensions()
if len(manager.GetAllExtensions()) != 0 {
t.Fatal("expected all extensions unloaded")
}
}
+54 -11
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension manifest parsing and validation
package gobackend
import (
@@ -12,6 +11,7 @@ type ExtensionType string
const (
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
)
type SettingType string
@@ -25,9 +25,10 @@ const (
)
type ExtensionPermissions struct {
Network []string `json:"network"`
Storage bool `json:"storage"`
File bool `json:"file"`
Network []string `json:"network"`
Storage bool `json:"storage"`
File bool `json:"file"`
AllowHTTP bool `json:"allowHttp,omitempty"`
}
type ExtensionSetting struct {
@@ -101,11 +102,21 @@ type PostProcessingConfig struct {
Hooks []PostProcessingHook `json:"hooks,omitempty"`
}
type ExtensionHealthCheck struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
URL string `json:"url"`
Method string `json:"method,omitempty"`
ServiceKey string `json:"serviceKey,omitempty"`
TimeoutMs int `json:"timeoutMs,omitempty"`
CacheTTLSeconds int `json:"cacheTtlSeconds,omitempty"`
Required bool `json:"required,omitempty"`
}
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
@@ -115,11 +126,14 @@ type ExtensionManifest struct {
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
@@ -154,10 +168,6 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "version", Message: "version is required"}
}
if strings.TrimSpace(m.Author) == "" {
return &ManifestValidationError{Field: "author", Message: "author is required"}
}
if strings.TrimSpace(m.Description) == "" {
return &ManifestValidationError{Field: "description", Message: "description is required"}
}
@@ -167,10 +177,10 @@ func (m *ExtensionManifest) Validate() error {
}
for _, t := range m.Types {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
return &ManifestValidationError{
Field: "type",
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t),
}
}
}
@@ -206,6 +216,28 @@ func (m *ExtensionManifest) Validate() error {
}
}
for i, check := range m.ServiceHealth {
if strings.TrimSpace(check.ID) == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("serviceHealth[%d].id", i),
Message: "health check id is required",
}
}
if strings.TrimSpace(check.URL) == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("serviceHealth[%d].url", i),
Message: "health check url is required",
}
}
method := strings.ToUpper(strings.TrimSpace(check.Method))
if method != "" && method != "GET" && method != "HEAD" {
return &ManifestValidationError{
Field: fmt.Sprintf("serviceHealth[%d].method", i),
Message: "health check method must be GET or HEAD",
}
}
}
return nil
}
@@ -226,6 +258,17 @@ func (m *ExtensionManifest) IsDownloadProvider() bool {
return m.HasType(ExtensionTypeDownloadProvider)
}
func (m *ExtensionManifest) IsLyricsProvider() bool {
return m.HasType(ExtensionTypeLyricsProvider)
}
func (m *ExtensionManifest) StopsProviderFallback() bool {
if m == nil {
return false
}
return m.StopProviderFallback || m.SkipBuiltInFallback
}
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain))
for _, allowed := range m.Permissions.Network {
+119
View File
@@ -0,0 +1,119 @@
package gobackend
import (
"encoding/json"
"time"
"github.com/dop251/goja"
)
type extensionCallPerf struct {
extensionID string
operation string
startedAt time.Time
initMs float64
jsMs float64
parseMs float64
items int
payloadBytes int
}
func newExtensionCallPerf(extensionID, operation string) *extensionCallPerf {
if !GetLogBuffer().IsLoggingEnabled() {
return nil
}
return &extensionCallPerf{
extensionID: extensionID,
operation: operation,
startedAt: time.Now(),
}
}
func extensionDurationMs(duration time.Duration) float64 {
return float64(duration.Microseconds()) / 1000.0
}
func (p *extensionCallPerf) recordInit(duration time.Duration) {
if p == nil {
return
}
p.initMs += extensionDurationMs(duration)
}
func (p *extensionCallPerf) recordJS(duration time.Duration) {
if p == nil {
return
}
p.jsMs += extensionDurationMs(duration)
}
func (p *extensionCallPerf) recordParse(duration time.Duration) {
if p == nil {
return
}
p.parseMs += extensionDurationMs(duration)
}
func (p *extensionCallPerf) recordPayload(value goja.Value) {
if p == nil || gojaValueIsEmpty(value) {
return
}
if payload, err := json.Marshal(value); err == nil {
p.payloadBytes = len(payload)
}
}
func (p *extensionCallPerf) setPayloadBytes(payloadBytes int) {
if p == nil {
return
}
p.payloadBytes = payloadBytes
}
func (p *extensionCallPerf) setItems(items int) {
if p == nil {
return
}
p.items = items
}
func (p *extensionCallPerf) finish() {
if p == nil {
return
}
LogDebug(
"ExtensionPerf",
"extension=%s op=%s totalMs=%.1f initMs=%.1f jsMs=%.1f parseMs=%.1f items=%d payloadBytes=%d",
p.extensionID,
p.operation,
extensionDurationMs(time.Since(p.startedAt)),
p.initMs,
p.jsMs,
p.parseMs,
p.items,
p.payloadBytes,
)
}
func countExtensionTopLevelItems(vm *goja.Runtime, value goja.Value) int {
if gojaValueIsEmpty(value) {
return 0
}
if length, err := gojaArrayLength(value, vm); err == nil && length > 0 {
return length
}
obj := value.ToObject(vm)
for _, key := range []string{"items", "tracks", "sections", "albums", "artists", "playlists", "results"} {
child := obj.Get(key)
if gojaValueIsEmpty(child) {
continue
}
if length, err := gojaArrayLength(child, vm); err == nil && length > 0 {
return length
}
}
return 1
}
@@ -0,0 +1,164 @@
package gobackend
import (
"path/filepath"
"testing"
)
func TestExtensionProviderWrapperFullSurface(t *testing.T) {
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
provider := newExtensionProviderWrapper(ext)
search, err := provider.SearchTracks("query", 5)
if err != nil {
t.Fatalf("SearchTracks: %v", err)
}
if search.Total != 1 || search.Tracks[0].ProviderID != ext.ID || search.Tracks[0].ExternalLinks["tidal"] == "" {
t.Fatalf("search = %#v", search)
}
track, err := provider.GetTrack("track-1")
if err != nil {
t.Fatalf("GetTrack: %v", err)
}
if track.Name != "Track track-1" || track.ProviderID != ext.ID || track.AudioQuality == "" {
t.Fatalf("track = %#v", track)
}
album, err := provider.GetAlbum("album-1")
if err != nil {
t.Fatalf("GetAlbum: %v", err)
}
if album.ProviderID != ext.ID || len(album.Tracks) != 1 || album.Tracks[0].ProviderID != ext.ID {
t.Fatalf("album = %#v", album)
}
playlist, err := provider.GetPlaylist("playlist-1")
if err != nil {
t.Fatalf("GetPlaylist: %v", err)
}
if playlist.Name != "Playlist playlist-1" || playlist.ProviderID != ext.ID {
t.Fatalf("playlist = %#v", playlist)
}
artist, err := provider.GetArtist("artist-1")
if err != nil {
t.Fatalf("GetArtist: %v", err)
}
if artist.ProviderID != ext.ID || len(artist.Releases) != 1 || artist.Releases[0].ProviderID != ext.ID {
t.Fatalf("artist = %#v", artist)
}
enriched, err := provider.EnrichTrack(&ExtTrackMetadata{ID: "track-1", Name: "Old", ProviderID: ext.ID})
if err != nil {
t.Fatalf("EnrichTrack: %v", err)
}
if enriched.Name != "Enriched" || enriched.ProviderID != ext.ID {
t.Fatalf("enriched = %#v", enriched)
}
availability, err := provider.CheckAvailability("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz")
if err != nil {
t.Fatalf("CheckAvailability: %v", err)
}
if !availability.Available || availability.TrackID != "download-track" || !availability.SkipFallback {
t.Fatalf("availability = %#v", availability)
}
downloadURL, err := provider.GetDownloadURL("track-1", "LOSSLESS")
if err != nil {
t.Fatalf("GetDownloadURL: %v", err)
}
if downloadURL.Format != "flac" || downloadURL.BitDepth != 24 || downloadURL.SampleRate != 96000 {
t.Fatalf("download URL = %#v", downloadURL)
}
progress := []int{}
download, err := provider.Download("track-1", "LOSSLESS", filepath.Join(t.TempDir(), "song.flac"), "", func(percent int) {
progress = append(progress, percent)
})
if err != nil {
t.Fatalf("Download: %v", err)
}
if !download.Success || download.Decryption == nil || download.DecryptionKey != "001122" || len(progress) != 1 || progress[0] != 100 {
t.Fatalf("download = %#v progress=%v", download, progress)
}
lyrics, err := provider.FetchLyrics("Song", "Artist", "Album", 180)
if err != nil {
t.Fatalf("GetLyrics: %v", err)
}
if lyrics.Provider != ext.ID || len(lyrics.Lines) != 1 || lyrics.Lines[0].Words != "Hello" {
t.Fatalf("lyrics = %#v", lyrics)
}
urlResult, err := provider.HandleURL("https://example.test/track/1")
if err != nil {
t.Fatalf("HandleURL: %v", err)
}
if urlResult.Track == nil || urlResult.Track.Name == "" || len(urlResult.Tracks) != 1 || urlResult.Album == nil || urlResult.Artist == nil {
t.Fatalf("url result = %#v", urlResult)
}
match, err := provider.MatchTrack(
map[string]interface{}{"name": "Song", "artists": "Artist"},
[]map[string]interface{}{{"id": "download-track", "name": "Song"}},
)
if err != nil {
t.Fatalf("MatchTrack: %v", err)
}
if !match.Matched || match.TrackID != "download-track" {
t.Fatalf("match = %#v", match)
}
post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]interface{}{"title": "Song"}, "hook")
if err != nil {
t.Fatalf("PostProcess: %v", err)
}
if !post.Success || post.BitDepth != 24 || post.SampleRate != 96000 {
t.Fatalf("post = %#v", post)
}
}
func TestExtensionProviderAndManagerSelectionHelpers(t *testing.T) {
manifest := &ExtensionManifest{Capabilities: map[string]interface{}{
"replacesBuiltInProviders": []interface{}{" Deezer ", 7, ""},
}}
if values := manifestCapabilityStringList(manifest, "replacesBuiltInProviders"); len(values) != 1 || values[0] != "deezer" {
t.Fatalf("capability list = %#v", values)
}
if !extensionReplacesBuiltInProvider(&loadedExtension{Manifest: manifest}, "deezer") || extensionReplacesBuiltInProvider(nil, "deezer") {
t.Fatal("extension replacement mismatch")
}
if trimKnownProviderPrefix("Deezer:101", "deezer") != "101" || trimKnownProviderPrefix("101", "deezer") != "101" {
t.Fatal("trimKnownProviderPrefix mismatch")
}
if metadataTrackDedupKey(ExtTrackMetadata{ISRC: "usrc"}) != "isrc:USRC" ||
metadataTrackDedupKey(ExtTrackMetadata{SpotifyID: "sp"}) != "spotify:sp" ||
metadataTrackDedupKey(ExtTrackMetadata{ProviderID: "p", ID: "1"}) != "p:1" {
t.Fatal("metadata dedup key mismatch")
}
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider, ExtensionTypeMetadataProvider)
manager.extensions[downloadExt.ID] = downloadExt
if providers := manager.GetDownloadProviders(); len(providers) != 1 {
t.Fatalf("download providers = %#v", providers)
}
SetProviderPriority([]string{"deezer", "coverage-ext", "coverage-ext", " "})
if priority := GetProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
t.Fatalf("provider priority = %#v", priority)
}
SetExtensionFallbackProviderIDs([]string{"a", "a", " ", "b"})
if ids := GetExtensionFallbackProviderIDs(); len(ids) != 2 || !isExtensionFallbackAllowed("a") || isExtensionFallbackAllowed("z") {
t.Fatalf("fallback ids = %#v", ids)
}
SetExtensionFallbackProviderIDs(nil)
if !isExtensionFallbackAllowed("z") {
t.Fatal("nil fallback list should allow all")
}
SetMetadataProviderPriority([]string{"spotify", "deezer", "coverage-ext", "coverage-ext"})
if priority := GetMetadataProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
t.Fatalf("metadata priority = %#v", priority)
}
}
File diff suppressed because it is too large Load Diff
+788
View File
@@ -0,0 +1,788 @@
package gobackend
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/dop251/goja"
)
func TestSetMetadataProviderPriorityStripsRetiredBuiltIns(t *testing.T) {
original := GetMetadataProviderPriority()
defer SetMetadataProviderPriority(original)
SetMetadataProviderPriority([]string{"qobuz"})
got := GetMetadataProviderPriority()
if len(got) != 0 {
t.Fatalf("expected retired built-in qobuz to be stripped, got %v", got)
}
}
func TestSetExtensionFallbackProviderIDsDedupesExtensions(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"ext-a", "ext-a", " ext-b "})
got := GetExtensionFallbackProviderIDs()
want := []string{"ext-a", "ext-b"}
if len(got) != len(want) {
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
}
}
}
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs(nil)
if !isExtensionFallbackAllowed("custom-ext") {
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
}
}
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
if !isExtensionFallbackAllowed("allowed-ext") {
t.Fatal("expected explicitly allowed extension to be permitted")
}
if isExtensionFallbackAllowed("blocked-ext") {
t.Fatal("expected extension outside allowlist to be blocked")
}
if isExtensionFallbackAllowed("deezer") {
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
}
}
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
original := GetProviderPriority()
defer SetProviderPriority(original)
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
got := GetProviderPriority()
want := []string{"custom-ext"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
}
}
}
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
original := GetProviderPriority()
defer SetProviderPriority(original)
manager := getExtensionManager()
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
ext.ID = "deezer"
ext.Manifest.Name = "deezer"
manager.mu.Lock()
previous, hadPrevious := manager.extensions[ext.ID]
manager.extensions[ext.ID] = ext
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadPrevious {
manager.extensions[ext.ID] = previous
} else {
delete(manager.extensions, ext.ID)
}
manager.mu.Unlock()
}()
SetProviderPriority([]string{"deezer", "custom-ext"})
got := GetProviderPriority()
want := []string{"deezer", "custom-ext"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
}
}
}
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
manager := getExtensionManager()
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
amazon.ID = "amazon"
amazon.Manifest.Name = "amazon"
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "://bad",
Required: true,
}}
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
plain.ID = "plain"
plain.Manifest.Name = "plain"
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
deezer.ID = "deezer"
deezer.Manifest.Name = "deezer"
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "https://example.test/health",
}}
manager.mu.Lock()
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
previousPlain, hadPlain := manager.extensions[plain.ID]
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
manager.extensions[amazon.ID] = amazon
manager.extensions[plain.ID] = plain
manager.extensions[deezer.ID] = deezer
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadAmazon {
manager.extensions[amazon.ID] = previousAmazon
} else {
delete(manager.extensions, amazon.ID)
}
if hadPlain {
manager.extensions[plain.ID] = previousPlain
} else {
delete(manager.extensions, plain.ID)
}
if hadDeezer {
manager.extensions[deezer.ID] = previousDeezer
} else {
delete(manager.extensions, deezer.ID)
}
manager.mu.Unlock()
extensionHealthCacheMu.Lock()
delete(extensionHealthCache, deezer.ID)
extensionHealthCacheMu.Unlock()
}()
extensionHealthCacheMu.Lock()
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
result: ExtensionHealthResult{
ExtensionID: deezer.ID,
Status: "online",
CheckedAt: time.Now().UTC().Format(time.RFC3339),
},
expiresAt: time.Now().Add(time.Minute),
}
extensionHealthCacheMu.Unlock()
got := prioritizeFallbackProvidersByHealth(
[]string{"amazon", "plain", "deezer"},
manager,
"",
)
want := []string{"deezer", "plain"}
if len(got) != len(want) {
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
}
}
}
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
if normalized == nil {
t.Fatal("expected legacy decryption key to produce normalized descriptor")
}
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
t.Fatalf("strategy = %q", normalized.Strategy)
}
if normalized.Key != "001122" {
t.Fatalf("key = %q", normalized.Key)
}
if normalized.InputFormat != "mov" {
t.Fatalf("input format = %q", normalized.InputFormat)
}
}
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
Strategy: "mp4_decryption_key",
Key: "abcd",
InputFormat: "",
}, "")
if normalized == nil {
t.Fatal("expected descriptor to remain available")
}
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
t.Fatalf("strategy = %q", normalized.Strategy)
}
if normalized.InputFormat != "mov" {
t.Fatalf("input format = %q", normalized.InputFormat)
}
}
func TestExtensionDownloadUsesIsolatedRuntimeForConcurrentCalls(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(500 * time.Millisecond)
_, _ = w.Write([]byte("ok"))
}))
defer server.Close()
setPrivateIPCache("download.test", false, time.Minute)
originalTransport := sharedTransport
testTransport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
},
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
sharedTransport = testTransport
defer func() {
testTransport.CloseIdleConnections()
sharedTransport = originalTransport
}()
extDir := t.TempDir()
if err := os.WriteFile(filepath.Join(extDir, "index.js"), []byte(`
registerExtension({
download: function(trackID, quality, outputPath, onProgress) {
var result = file.download('https://download.test/' + trackID, outputPath, {
onProgress: function(written, total) {
if (onProgress) onProgress(50);
}
});
if (!result || !result.success) {
return {
success: false,
error_message: result && result.error ? result.error : 'download failed',
error_type: 'download_error'
};
}
if (onProgress) onProgress(100);
return { success: true, file_path: result.path };
}
});
`), 0600); err != nil {
t.Fatalf("write extension index: %v", err)
}
outputDir := t.TempDir()
SetAllowedDownloadDirs([]string{outputDir})
defer SetAllowedDownloadDirs(nil)
ext := &loadedExtension{
ID: "concurrent-download",
Manifest: &ExtensionManifest{
Name: "concurrent-download",
Description: "Concurrent download test",
Version: "1.0.0",
Types: []ExtensionType{ExtensionTypeDownloadProvider},
Permissions: ExtensionPermissions{
Network: []string{"download.test"},
File: true,
},
},
Enabled: true,
SourceDir: extDir,
DataDir: t.TempDir(),
}
provider := newExtensionProviderWrapper(ext)
start := time.Now()
var wg sync.WaitGroup
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
result, err := provider.Download(
fmt.Sprintf("track-%d", i),
"LOSSLESS",
filepath.Join(outputDir, fmt.Sprintf("track-%d.flac", i)),
"",
nil,
)
if err != nil {
errs <- err
return
}
if result == nil || !result.Success {
errs <- fmt.Errorf("download failed: %#v", result)
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
if elapsed := time.Since(start); elapsed >= 850*time.Millisecond {
t.Fatalf("expected same-extension downloads to overlap, elapsed %s", elapsed)
}
}
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := buildOutputPath(DownloadRequest{
TrackName: "Song",
ArtistName: "Artist",
OutputDir: outputDir,
OutputExt: ".flac",
FilenameFormat: "",
})
if !isPathInAllowedDirs(outputPath) {
t.Fatalf("expected output path %q to be allowed", outputPath)
}
}
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := filepath.Join(outputDir, "custom.flac")
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
OutputPath: outputPath,
}, ext)
if resolved != outputPath {
t.Fatalf("resolved output path = %q", resolved)
}
if !isPathInAllowedDirs(outputPath) {
t.Fatalf("expected output path %q to be allowed", outputPath)
}
}
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
SetAllowedDownloadDirs(nil)
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
TrackName: "Song",
ArtistName: "Artist",
OutputDir: filepath.Join("Artist", "Album"),
OutputFD: 123,
OutputExt: ".flac",
}, ext)
expectedBase := filepath.Join(ext.DataDir, "downloads")
if !isPathWithinBase(expectedBase, resolved) {
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
}
if !isPathInAllowedDirs(resolved) {
t.Fatalf("expected resolved output path %q to be allowed", resolved)
}
}
func TestBuildOutputPathSanitizesTemplateFilename(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := buildOutputPath(DownloadRequest{
TrackName: `Gehra Hua (From "Dhurandhar")`,
ArtistName: "Artist",
OutputDir: outputDir,
OutputExt: ".flac",
FilenameFormat: "{artist} - {title}",
})
base := filepath.Base(outputPath)
if strings.ContainsAny(base, `<>:"/\|?*`) {
t.Fatalf("output filename still contains illegal characters: %q", base)
}
if strings.Contains(base, `"`) {
t.Fatalf("output filename still contains straight double quote: %q", base)
}
}
func TestBuildOutputPathForExtensionSanitizesTemplateFilename(t *testing.T) {
SetAllowedDownloadDirs(nil)
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
TrackName: `Gehra Hua (From "Dhurandhar")`,
ArtistName: "Artist",
OutputFD: 123,
OutputExt: ".flac",
FilenameFormat: "{artist} - {title}",
}, ext)
base := filepath.Base(resolved)
if strings.ContainsAny(base, `<>:"/\|?*`) {
t.Fatalf("extension output filename still contains illegal characters: %q", base)
}
}
func TestShouldStopProviderFallback(t *testing.T) {
if shouldStopProviderFallback(nil) {
t.Fatal("nil availability should not stop fallback")
}
if shouldStopProviderFallback(&ExtAvailabilityResult{Available: false}) {
t.Fatal("availability without skip_fallback should not stop fallback")
}
if !shouldStopProviderFallback(&ExtAvailabilityResult{Available: false, SkipFallback: true}) {
t.Fatal("skip_fallback availability should stop fallback")
}
}
func TestBuildExtensionFallbackStoppedResponsePrefersAvailabilityReason(t *testing.T) {
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
Reason: "direct SoundCloud track ID",
SkipFallback: true,
}, errors.New("ignored"))
if resp.Service != "soundcloud" {
t.Fatalf("service = %q", resp.Service)
}
if resp.Error != "Fallback stopped by soundcloud: direct SoundCloud track ID" {
t.Fatalf("unexpected error message: %q", resp.Error)
}
if resp.ErrorType != "extension_error" {
t.Fatalf("error type = %q", resp.ErrorType)
}
}
func TestBuildExtensionFallbackStoppedResponseFallsBackToError(t *testing.T) {
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
SkipFallback: true,
}, errors.New("lookup failed"))
if resp.Error != "Fallback stopped by soundcloud: lookup failed" {
t.Fatalf("unexpected error message: %q", resp.Error)
}
}
func TestShouldAbortCancelledFallbackWithCancelledError(t *testing.T) {
if !shouldAbortCancelledFallback("", ErrDownloadCancelled) {
t.Fatal("expected cancelled error to abort fallback")
}
}
func TestShouldAbortCancelledFallbackWithCancelledItemState(t *testing.T) {
const itemID = "cancelled-item"
initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
cancelDownload(itemID)
if !shouldAbortCancelledFallback(itemID, errors.New("generic failure")) {
t.Fatal("expected cancelled item state to abort fallback even for generic errors")
}
}
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "track.flac")
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
tempM4A := filepath.Join(t.TempDir(), "track.m4a")
if err := os.WriteFile(tempM4A, []byte("not-flac"), 0644); err != nil {
t.Fatalf("failed to create temp m4a file: %v", err)
}
if canEmbedGenreLabel("relative.flac") {
t.Fatal("expected relative path to be rejected")
}
if canEmbedGenreLabel("content://example") {
t.Fatal("expected content URI to be rejected")
}
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
t.Fatal("expected missing file to be rejected")
}
if canEmbedGenreLabel(tempM4A) {
t.Fatalf("expected non-FLAC file %q to be rejected", tempM4A)
}
if !canEmbedGenreLabel(tempFile) {
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
}
}
func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) {
originalPriority := GetMetadataProviderPriority()
defer func() {
SetMetadataProviderPriority(originalPriority)
}()
SetMetadataProviderPriority([]string{"qobuz"})
manager := getExtensionManager()
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
if err != nil {
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
}
if len(tracks) != 0 {
t.Fatalf("expected no tracks from retired built-in provider, got %+v", tracks)
}
}
func TestParseExtensionSearchResultAcceptsObjectAndArrayShapes(t *testing.T) {
vm := goja.New()
value, err := vm.RunString(`({
tracks: [{
id: "track-1",
name: "Song",
artists: "Artist",
album_name: "Album",
duration_ms: 123000,
cover_url: "https://img.test/cover.jpg",
external_links: { spotify: "spotify:track:1" },
audio_quality: "LOSSLESS"
}],
total: 9
})`)
if err != nil {
t.Fatalf("build object search result: %v", err)
}
result, err := parseExtensionSearchResult(vm, value)
if err != nil {
t.Fatalf("parse object search result: %v", err)
}
if result.Total != 9 || len(result.Tracks) != 1 {
t.Fatalf("unexpected object result: %+v", result)
}
track := result.Tracks[0]
if track.ID != "track-1" ||
track.AlbumName != "Album" ||
track.DurationMS != 123000 ||
track.CoverURL != "https://img.test/cover.jpg" ||
track.ExternalLinks["spotify"] != "spotify:track:1" ||
track.AudioQuality != "LOSSLESS" {
t.Fatalf("unexpected parsed track: %+v", track)
}
arrayValue, err := vm.RunString(`[
{id: "track-2", name: "Other Song", artists: "Other Artist", albumName: "Other Album", durationMs: 456000}
]`)
if err != nil {
t.Fatalf("build array search result: %v", err)
}
arrayResult, err := parseExtensionSearchResult(vm, arrayValue)
if err != nil {
t.Fatalf("parse array search result: %v", err)
}
if arrayResult.Total != 1 ||
len(arrayResult.Tracks) != 1 ||
arrayResult.Tracks[0].AlbumName != "Other Album" ||
arrayResult.Tracks[0].DurationMS != 456000 {
t.Fatalf("unexpected array result: %+v", arrayResult)
}
}
func TestParseExtensionMetadataAndDownloadResults(t *testing.T) {
vm := goja.New()
value, err := vm.RunString(`({
id: "album-1",
name: "Album",
artists: "Artist",
artistId: "artist-1",
coverUrl: "https://img.test/album.jpg",
releaseDate: "2024-02-03",
totalTracks: 2,
albumType: "album",
tracks: [
{id: "track-1", name: "Song 1", artists: "Artist", durationMs: 180000},
{id: "track-2", name: "Song 2", artists: "Artist", duration_ms: 181000}
]
})`)
if err != nil {
t.Fatalf("build album value: %v", err)
}
album, err := parseExtensionAlbumValue(vm, value)
if err != nil {
t.Fatalf("parse album: %v", err)
}
if album.ID != "album-1" ||
album.ArtistID != "artist-1" ||
album.CoverURL != "https://img.test/album.jpg" ||
album.TotalTracks != 2 ||
len(album.Tracks) != 2 ||
album.Tracks[0].DurationMS != 180000 ||
album.Tracks[1].DurationMS != 181000 {
t.Fatalf("unexpected album: %+v", album)
}
artistValue, err := vm.RunString(`({
id: "artist-1",
name: "Artist",
imageUrl: "https://img.test/artist.jpg",
headerImage: "https://img.test/header.jpg",
listeners: 1234,
albums: [{id: "album-1", name: "Album", tracks: [{id: "track-1", name: "Song"}]}],
releases: [{id: "single-1", name: "Single"}],
topTracks: [{id: "top-1", name: "Top Song"}]
})`)
if err != nil {
t.Fatalf("build artist value: %v", err)
}
artist, err := parseExtensionArtistValue(vm, artistValue)
if err != nil {
t.Fatalf("parse artist: %v", err)
}
if artist.ID != "artist-1" ||
artist.ImageURL != "https://img.test/artist.jpg" ||
artist.HeaderImage != "https://img.test/header.jpg" ||
artist.Listeners != 1234 ||
len(artist.Albums) != 1 ||
len(artist.Albums[0].Tracks) != 1 ||
len(artist.Releases) != 1 ||
len(artist.TopTracks) != 1 {
t.Fatalf("unexpected artist: %+v", artist)
}
downloadValue, err := vm.RunString(`({
success: true,
filePath: "/tmp/song.flac",
alreadyExists: true,
bitDepth: 24,
sampleRate: 96000,
title: "Song",
albumArtist: "Album Artist",
lyricsLrc: "[00:00.00]Line",
decryptionKey: "001122",
decryption: {
strategy: "mp4_decryption_key",
key: "001122",
inputFormat: "m4a",
options: { map: "0:a" }
}
})`)
if err != nil {
t.Fatalf("build download value: %v", err)
}
download := parseExtensionDownloadResultValue(vm, downloadValue)
if !download.Success ||
download.FilePath != "/tmp/song.flac" ||
!download.AlreadyExists ||
download.BitDepth != 24 ||
download.SampleRate != 96000 ||
download.AlbumArtist != "Album Artist" ||
download.LyricsLRC != "[00:00.00]Line" ||
download.Decryption == nil ||
download.Decryption.InputFormat != "m4a" ||
download.Decryption.Options["map"] != "0:a" {
t.Fatalf("unexpected download result: %+v", download)
}
availabilityValue, err := vm.RunString(`({ available: true, trackId: "track-1", skipFallback: true, reason: "direct" })`)
if err != nil {
t.Fatalf("build availability value: %v", err)
}
availability := parseExtensionAvailabilityValue(vm, availabilityValue)
if !availability.Available || availability.TrackID != "track-1" || !availability.SkipFallback || availability.Reason != "direct" {
t.Fatalf("unexpected availability: %+v", availability)
}
}
func TestParseExtensionURLHandleResult(t *testing.T) {
vm := goja.New()
value, err := vm.RunString(`({
type: "album",
name: "Shared Album",
coverUrl: "https://img.test/shared.jpg",
track: { id: "track-1", name: "Song" },
tracks: [{ id: "track-2", name: "Song 2" }],
album: { id: "album-1", name: "Album", tracks: [{ id: "track-3", name: "Song 3" }] },
artist: { id: "artist-1", name: "Artist", topTracks: [{ id: "track-4", name: "Song 4" }] }
})`)
if err != nil {
t.Fatalf("build URL handle value: %v", err)
}
result, err := parseExtensionURLHandleValue(vm, value)
if err != nil {
t.Fatalf("parse URL handle: %v", err)
}
if result.Type != "album" ||
result.CoverURL != "https://img.test/shared.jpg" ||
result.Track == nil ||
result.Track.ID != "track-1" ||
len(result.Tracks) != 1 ||
result.Album == nil ||
len(result.Album.Tracks) != 1 ||
result.Artist == nil ||
len(result.Artist.TopTracks) != 1 {
t.Fatalf("unexpected URL handle result: %+v", result)
}
}
func TestParseExtensionAuxiliaryResults(t *testing.T) {
vm := goja.New()
matchValue, err := vm.RunString(`({ matched: true, trackId: "track-1", confidence: 0.92, reason: "isrc" })`)
if err != nil {
t.Fatalf("build match value: %v", err)
}
match := parseExtensionMatchTrackValue(vm, matchValue)
if !match.Matched || match.TrackID != "track-1" || match.Confidence != 0.92 || match.Reason != "isrc" {
t.Fatalf("unexpected match result: %+v", match)
}
postValue, err := vm.RunString(`({ success: true, newFilePath: "/tmp/new.flac", newFileUri: "content://new", bitDepth: 24, sampleRate: 96000 })`)
if err != nil {
t.Fatalf("build post-process value: %v", err)
}
post := parseExtensionPostProcessValue(vm, postValue)
if !post.Success || post.NewFilePath != "/tmp/new.flac" || post.NewFileURI != "content://new" || post.BitDepth != 24 || post.SampleRate != 96000 {
t.Fatalf("unexpected post-process result: %+v", post)
}
lyricsValue, err := vm.RunString(`({
syncType: "LINE_SYNCED",
instrumental: false,
plainLyrics: "Line",
provider: "Lyrics Provider",
lines: [{ startTimeMs: 1000, words: "Line", endTimeMs: 2000 }]
})`)
if err != nil {
t.Fatalf("build lyrics value: %v", err)
}
lyrics, err := parseExtensionLyricsValue(vm, lyricsValue)
if err != nil {
t.Fatalf("parse lyrics: %v", err)
}
if lyrics.SyncType != "LINE_SYNCED" ||
lyrics.PlainLyrics != "Line" ||
lyrics.Provider != "Lyrics Provider" ||
len(lyrics.Lines) != 1 ||
lyrics.Lines[0].StartTimeMs != 1000 ||
lyrics.Lines[0].EndTimeMs != 2000 {
t.Fatalf("unexpected lyrics result: %+v", lyrics)
}
}
+265 -50
View File
@@ -5,6 +5,7 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -80,61 +81,215 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
state.IsAuthenticated = accessToken != ""
}
type ExtensionRuntime struct {
extensionID string
manifest *ExtensionManifest
settings map[string]interface{}
httpClient *http.Client
cookieJar http.CookieJar
dataDir string
vm *goja.Runtime
type extensionRuntime struct {
extensionID string
manifest *ExtensionManifest
settings map[string]interface{}
httpClient *http.Client
downloadClient *http.Client
cookieJar http.CookieJar
dataDir string
vm *goja.Runtime
activeDownloadMu sync.RWMutex
activeDownloadItemID string
activeRequestMu sync.RWMutex
activeRequestID string
storageMu sync.RWMutex
storageCache map[string]interface{}
storageLoaded bool
storageDirty bool
storageClosed bool
storageTimer *time.Timer
storageWriteMu sync.Mutex
credentialsMu sync.RWMutex
credentialsCache map[string]interface{}
credentialsLoaded bool
storageFlushDelay time.Duration
}
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
type privateIPCacheEntry struct {
isPrivate bool
expiresAt time.Time
}
const (
privateIPCacheTTL = 5 * time.Minute
privateIPErrorCacheTTL = 30 * time.Second
maxPrivateIPCacheSize = 1024
)
var (
privateIPCache = make(map[string]privateIPCacheEntry)
privateIPCacheMu sync.RWMutex
)
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
jar, _ := newSimpleCookieJar()
runtime := &ExtensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
cookieJar: jar,
dataDir: ext.DataDir,
vm: ext.VM,
runtime := &extensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
cookieJar: jar,
dataDir: ext.DataDir,
vm: ext.VM,
storageFlushDelay: defaultStorageFlushDelay,
}
client := &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
return fmt.Errorf("redirect blocked: only https is allowed")
}
domain := req.URL.Hostname()
if domain == "" {
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
return fmt.Errorf("redirect blocked: hostname is required")
}
if !ext.Manifest.IsDomainAllowed(domain) {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
}
if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
}
if len(via) >= 10 {
return http.ErrUseLastResponse
}
return nil
},
}
runtime.httpClient = client
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false)
return runtime
}
func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Duration {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return fallback
}
raw, ok := ext.Manifest.Capabilities["networkTimeoutSeconds"]
if !ok {
return fallback
}
seconds := parseExtensionTimeoutSeconds(raw)
if seconds <= 0 {
return fallback
}
if seconds < 5 {
seconds = 5
}
if seconds > 300 {
seconds = 300
}
return time.Duration(seconds) * time.Second
}
func parseExtensionTimeoutSeconds(raw interface{}) int {
switch v := raw.(type) {
case int:
return v
case int32:
return int(v)
case int64:
return int(v)
case float32:
return int(v)
case float64:
return int(v)
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return parsed
default:
return 0
}
}
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = strings.TrimSpace(itemID)
}
func (r *extensionRuntime) clearActiveDownloadItemID() {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = ""
}
func (r *extensionRuntime) getActiveDownloadItemID() string {
r.activeDownloadMu.RLock()
defer r.activeDownloadMu.RUnlock()
return r.activeDownloadItemID
}
func (r *extensionRuntime) setActiveRequestID(requestID string) {
r.activeRequestMu.Lock()
defer r.activeRequestMu.Unlock()
r.activeRequestID = strings.TrimSpace(requestID)
}
func (r *extensionRuntime) clearActiveRequestID() {
r.activeRequestMu.Lock()
defer r.activeRequestMu.Unlock()
r.activeRequestID = ""
}
func (r *extensionRuntime) getActiveRequestID() string {
r.activeRequestMu.RLock()
defer r.activeRequestMu.RUnlock()
return r.activeRequestID
}
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
if req == nil {
return nil
}
itemID := r.getActiveDownloadItemID()
if itemID == "" {
requestID := r.getActiveRequestID()
if requestID == "" {
return req
}
return req.WithContext(initExtensionRequestCancel(requestID))
}
return req.WithContext(initDownloadCancel(itemID))
}
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops.
// API calls can use response compression for faster metadata/search loads,
// while media downloads keep identity transfer semantics for progress/streaming.
transport := sharedTransport
if compressResponses {
transport = extensionAPITransport
}
client := &http.Client{
Transport: transport,
Timeout: timeout,
Jar: jar,
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" &&
!(req.URL.Scheme == "http" && ext.Manifest.Permissions.AllowHTTP) {
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
return fmt.Errorf("redirect blocked: only https is allowed")
}
domain := req.URL.Hostname()
if domain == "" {
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
return fmt.Errorf("redirect blocked: hostname is required")
}
if !ext.Manifest.IsDomainAllowed(domain) {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
}
if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
}
if len(via) >= 10 {
return http.ErrUseLastResponse
}
return nil
}
return client
}
type RedirectBlockedError struct {
Domain string
IsPrivate bool
@@ -147,7 +302,6 @@ func (e *RedirectBlockedError) Error() string {
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
}
// isPrivateIP checks if a hostname resolves to a private/local IP address
func isPrivateIP(host string) bool {
hostLower := strings.ToLower(strings.TrimSpace(host))
if hostLower == "" {
@@ -162,18 +316,68 @@ func isPrivateIP(host string) bool {
return isPrivateIPAddr(ip)
}
if cached, ok := getPrivateIPCache(hostLower); ok {
return cached
}
ips, err := net.LookupIP(hostLower)
if err != nil {
setPrivateIPCache(hostLower, false, privateIPErrorCacheTTL)
return false
}
isPrivate := false
for _, ip := range ips {
if isPrivateIPAddr(ip) {
return true
isPrivate = true
break
}
}
return false
setPrivateIPCache(hostLower, isPrivate, privateIPCacheTTL)
return isPrivate
}
func getPrivateIPCache(host string) (bool, bool) {
now := time.Now()
privateIPCacheMu.RLock()
entry, exists := privateIPCache[host]
privateIPCacheMu.RUnlock()
if !exists {
return false, false
}
if now.Before(entry.expiresAt) {
return entry.isPrivate, true
}
privateIPCacheMu.Lock()
delete(privateIPCache, host)
privateIPCacheMu.Unlock()
return false, false
}
func setPrivateIPCache(host string, isPrivate bool, ttl time.Duration) {
expiresAt := time.Now().Add(ttl)
privateIPCacheMu.Lock()
if len(privateIPCache) >= maxPrivateIPCacheSize {
now := time.Now()
for key, entry := range privateIPCache {
if now.After(entry.expiresAt) {
delete(privateIPCache, key)
}
}
if len(privateIPCache) >= maxPrivateIPCacheSize {
privateIPCache = make(map[string]privateIPCacheEntry)
}
}
privateIPCache[host] = privateIPCacheEntry{
isPrivate: isPrivate,
expiresAt: expiresAt,
}
privateIPCacheMu.Unlock()
}
func isPrivateIPAddr(ip net.IP) bool {
@@ -218,11 +422,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
return j.cookies[u.Host]
}
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings
}
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
r.vm = vm
httpObj := vm.NewObject()
@@ -266,7 +470,9 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
fileObj.Set("exists", r.fileExists)
fileObj.Set("delete", r.fileDelete)
fileObj.Set("read", r.fileRead)
fileObj.Set("readBytes", r.fileReadBytes)
fileObj.Set("write", r.fileWrite)
fileObj.Set("writeBytes", r.fileWriteBytes)
fileObj.Set("copy", r.fileCopy)
fileObj.Set("move", r.fileMove)
fileObj.Set("getSize", r.fileGetSize)
@@ -296,8 +502,17 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("stringifyJSON", r.stringifyJSON)
utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
utilsObj.Set("decryptCTRSegments", r.decryptCTRSegments)
utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
utilsObj.Set("appVersion", r.appVersion)
utilsObj.Set("appUserAgent", r.appUserAgent)
utilsObj.Set("sleep", r.sleep)
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
utilsObj.Set("isRequestCancelled", r.isRequestCancelled)
utilsObj.Set("setDownloadStatus", r.setDownloadStatus)
vm.Set("utils", utilsObj)
logObj := vm.NewObject()
+69 -27
View File
@@ -1,4 +1,3 @@
// Package gobackend provides Auth API and PKCE support for extension runtime
package gobackend
import (
@@ -16,9 +15,44 @@ import (
"github.com/dop251/goja"
)
// ==================== Auth API (OAuth Support) ====================
func validateExtensionAuthURL(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid auth URL: %w", err)
}
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if parsed.Scheme != "https" {
return fmt.Errorf("invalid auth URL: only https is allowed")
}
host := parsed.Hostname()
if host == "" {
return fmt.Errorf("invalid auth URL: hostname is required")
}
if parsed.User != nil {
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
}
if isPrivateIP(host) {
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
}
return nil
}
func summarizeURLForLog(urlStr string) string {
parsed, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
if parsed.Host == "" {
return parsed.Scheme + "://"
}
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
}
func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -32,6 +66,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
callbackURL = call.Arguments[1].String()
}
if err := validateExtensionAuthURL(authURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
@@ -50,7 +91,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
state.AuthCode = ""
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
return r.vm.ToValue(map[string]interface{}{
"success": true,
@@ -58,7 +99,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
})
}
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -70,7 +111,7 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(state.AuthCode)
}
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
@@ -108,7 +149,7 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true)
}
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.Lock()
delete(extensionAuthState, r.extensionID)
extensionAuthStateMu.Unlock()
@@ -121,7 +162,7 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true)
}
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -137,7 +178,7 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(state.IsAuthenticated)
}
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -160,10 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result)
}
// ==================== PKCE Support ====================
// generatePKCEVerifier generates a cryptographically random code verifier
// Length should be between 43-128 characters (RFC 7636)
func generatePKCEVerifier(length int) (string, error) {
if length < 43 {
length = 43
@@ -188,11 +225,10 @@ func generatePKCEVerifier(length int) (string, error) {
func generatePKCEChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
// Base64url encode without padding (RFC 7636)
return base64.RawURLEncoding.EncodeToString(hash[:])
}
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
length := 64
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
@@ -229,7 +265,7 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
})
}
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -245,8 +281,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
})
}
// config: { authUrl, clientId, redirectUri, scope, extraParams }
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -273,6 +308,12 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
"error": "authUrl, clientId, and redirectUri are required",
})
}
if err := validateExtensionAuthURL(authURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
scope, _ := config["scope"].(string)
extraParams, _ := config["extraParams"].(map[string]interface{})
@@ -331,7 +372,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
}
pendingAuthRequestsMu.Unlock()
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
return r.vm.ToValue(map[string]interface{}{
"success": true,
@@ -344,10 +385,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
})
}
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
// Uses the stored PKCE verifier automatically
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -364,7 +402,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Required fields
tokenURL, _ := config["tokenUrl"].(string)
clientID, _ := config["clientId"].(string)
redirectURI, _ := config["redirectUri"].(string)
@@ -421,9 +458,10 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
req.Header.Set("User-Agent", appUserAgent())
resp, err := r.httpClient.Do(req)
if err != nil {
@@ -441,13 +479,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(),
})
}
bodyPreview := sanitizeSensitiveLogText(string(body))
if len(bodyPreview) > 1000 {
bodyPreview = bodyPreview[:1000] + "...[truncated]"
}
var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to parse token response: %v", err),
"body": string(body),
"body": bodyPreview,
})
}
@@ -468,7 +510,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "no access_token in response",
"body": string(body),
"body": bodyPreview,
})
}
+535
View File
@@ -0,0 +1,535 @@
package gobackend
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"github.com/dop251/goja"
//lint:ignore SA1019 Blowfish is required for legacy extension crypto compatibility.
"golang.org/x/crypto/blowfish"
)
type runtimeBlockCipherOptions struct {
Algorithm string
Mode string
Key []byte
IV []byte
InputEncoding string
OutputEncoding string
Padding string
}
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
if len(call.Arguments) <= index {
return nil
}
value := call.Arguments[index]
if goja.IsUndefined(value) || goja.IsNull(value) {
return nil
}
exported := value.Export()
if options, ok := exported.(map[string]interface{}); ok {
return options
}
return nil
}
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case string:
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
case []byte:
if len(value) > 0 {
return string(value)
}
}
return defaultValue
}
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case bool:
return value
case int:
return value != 0
case int64:
return value != 0
case float64:
return value != 0
case string:
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return defaultValue
}
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case int:
return int64(value)
case int32:
return int64(value)
case int64:
return value
case float32:
return int64(value)
case float64:
return int64(value)
case string:
value = strings.TrimSpace(value)
if value == "" {
return defaultValue
}
var parsed int64
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
return parsed
}
}
return defaultValue
}
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
if options == nil {
return false
}
_, exists := options[key]
return exists
}
func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "utf8", "utf-8", "text":
return []byte(input), nil
case "base64":
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input))
if err != nil {
return nil, fmt.Errorf("invalid base64 data: %w", err)
}
return decoded, nil
case "hex":
decoded, err := hex.DecodeString(strings.TrimSpace(input))
if err != nil {
return nil, fmt.Errorf("invalid hex data: %w", err)
}
return decoded, nil
default:
return nil, fmt.Errorf("unsupported byte encoding: %s", encoding)
}
}
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
switch value := raw.(type) {
case string:
return decodeRuntimeBytesString(value, encoding)
case []byte:
cloned := make([]byte, len(value))
copy(cloned, value)
return cloned, nil
case goja.ArrayBuffer:
src := value.Bytes()
cloned := make([]byte, len(src))
copy(cloned, src)
return cloned, nil
case []interface{}:
decoded := make([]byte, len(value))
for i, item := range value {
switch num := item.(type) {
case int:
decoded[i] = byte(num)
case int64:
decoded[i] = byte(num)
case float64:
decoded[i] = byte(int(num))
default:
return nil, fmt.Errorf("unsupported byte array item at index %d", i)
}
}
return decoded, nil
default:
return nil, fmt.Errorf("unsupported byte payload type")
}
}
func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "base64":
return base64.StdEncoding.EncodeToString(data), nil
case "hex":
return hex.EncodeToString(data), nil
case "utf8", "utf-8", "text":
return string(data), nil
default:
return "", fmt.Errorf("unsupported byte encoding: %s", encoding)
}
}
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
parsed := &runtimeBlockCipherOptions{
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")),
OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")),
Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")),
}
if parsed.Algorithm == "" {
return nil, fmt.Errorf("algorithm is required")
}
if parsed.Mode == "" {
return nil, fmt.Errorf("mode is required")
}
key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8"))
if err != nil {
return nil, fmt.Errorf("invalid key: %w", err)
}
if len(key) == 0 {
return nil, fmt.Errorf("key is required")
}
parsed.Key = key
iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8"))
if err != nil {
return nil, fmt.Errorf("invalid iv: %w", err)
}
parsed.IV = iv
return parsed, nil
}
func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) {
switch options.Algorithm {
case "blowfish":
return blowfish.NewCipher(options.Key)
case "aes":
return aes.NewCipher(options.Key)
default:
return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm)
}
}
func applyPKCS7Padding(data []byte, blockSize int) []byte {
padding := blockSize - (len(data) % blockSize)
if padding == 0 {
padding = blockSize
}
out := make([]byte, len(data)+padding)
copy(out, data)
for i := len(data); i < len(out); i++ {
out[i] = byte(padding)
}
return out
}
func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, fmt.Errorf("invalid padded payload length")
}
padding := int(data[len(data)-1])
if padding <= 0 || padding > blockSize || padding > len(data) {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
for i := len(data) - padding; i < len(data); i++ {
if int(data[i]) != padding {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
}
return data[:len(data)-padding], nil
}
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "data and options are required",
})
}
options := parseRuntimeOptionsArgument(call, 1)
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
switch parsedOptions.Mode {
case "cbc", "ctr":
// supported
default:
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
})
}
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
block, err := newRuntimeBlockCipher(parsedOptions)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
if len(parsedOptions.IV) != block.BlockSize() {
ivLabel := "iv"
if parsedOptions.Mode == "ctr" {
ivLabel = "iv (counter)"
}
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm),
})
}
var output []byte
if parsedOptions.Mode == "ctr" {
// CTR is a stream mode: encryption and decryption are identical,
// require no padding, and accept arbitrary input lengths.
output = make([]byte, len(inputData))
cipher.NewCTR(block, parsedOptions.IV).XORKeyStream(output, inputData)
} else {
data := inputData
if !decrypt && parsedOptions.Padding == "pkcs7" {
data = applyPKCS7Padding(data, block.BlockSize())
}
if len(data)%block.BlockSize() != 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
})
}
output = make([]byte, len(data))
if decrypt {
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
if parsedOptions.Padding == "pkcs7" {
output, err = removePKCS7Padding(output, block.BlockSize())
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
}
} else {
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
}
}
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": encoded,
"block_size": block.BlockSize(),
})
}
func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value {
return r.transformBlockCipher(call, false)
}
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
return r.transformBlockCipher(call, true)
}
// decryptCTRSegments decrypts many independently-IV'd AES-CTR segments inside a
// single buffer in one host call. This exists to avoid thousands of JS->Go
// bridge crossings when an extension decrypts per-sample CENC media (each
// sample has its own IV/counter and cannot be merged into one stream).
//
// It is a generic primitive: any extension can use it for "one buffer, many
// CTR segments" workloads, not just Apple CENC.
//
// For best performance, pass the buffer as an ArrayBuffer/Uint8Array and set
// outputEncoding:"bytes" to get an ArrayBuffer back. This avoids base64
// encode/decode of the (potentially multi-MB) payload entirely, which is the
// dominant cost under the goja interpreter.
//
// JS signature:
// utils.decryptCTRSegments(data, {
// algorithm: "aes", // optional, default "aes"
// key: "<hex>", keyEncoding: "hex",
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
// ivEncoding: "base64", // encoding of each segment.iv, default base64
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
// })
// Returns { success, data, segments_processed } or { success:false, error }.
func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value {
fail := func(msg string) goja.Value {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": msg,
})
}
if len(call.Arguments) < 2 {
return fail("data and options are required")
}
options := parseRuntimeOptionsArgument(call, 1)
if options == nil {
return fail("options object is required")
}
algorithm := strings.ToLower(runtimeOptionString(options, "algorithm", "aes"))
inputEncoding := strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64"))
outputEncoding := strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64"))
ivEncoding := strings.ToLower(runtimeOptionString(options, "ivEncoding", "base64"))
key, err := decodeRuntimeBytesString(
runtimeOptionString(options, "key", ""),
runtimeOptionString(options, "keyEncoding", "hex"),
)
if err != nil {
return fail(fmt.Sprintf("invalid key: %v", err))
}
if len(key) == 0 {
return fail("key is required")
}
var block cipher.Block
switch algorithm {
case "aes":
block, err = aes.NewCipher(key)
case "blowfish":
block, err = blowfish.NewCipher(key)
default:
return fail("unsupported algorithm: " + algorithm)
}
if err != nil {
return fail(err.Error())
}
blockSize := block.BlockSize()
// Decode the payload. For "bytes" input we operate on the raw []byte
// (ArrayBuffer/Uint8Array) without any base64 round-trip.
var data []byte
if inputEncoding == "bytes" || inputEncoding == "raw" {
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), "")
if err != nil {
return fail("invalid byte payload: " + err.Error())
}
} else {
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), inputEncoding)
if err != nil {
return fail(err.Error())
}
}
rawSegments, ok := options["segments"]
if !ok || rawSegments == nil {
return fail("segments array is required")
}
segments, ok := rawSegments.([]interface{})
if !ok {
return fail("segments must be an array")
}
processed := 0
for i, rawSeg := range segments {
seg, ok := rawSeg.(map[string]interface{})
if !ok {
return fail(fmt.Sprintf("segment %d is not an object", i))
}
offset := int(runtimeOptionInt64(seg, "offset", -1))
size := int(runtimeOptionInt64(seg, "size", -1))
if offset < 0 || size < 0 {
return fail(fmt.Sprintf("segment %d has invalid offset/size", i))
}
if size == 0 {
continue
}
if offset+size > len(data) {
return fail(fmt.Sprintf("segment %d out of bounds (offset=%d size=%d len=%d)", i, offset, size, len(data)))
}
iv, err := decodeRuntimeBytesString(runtimeOptionString(seg, "iv", ""), ivEncoding)
if err != nil {
return fail(fmt.Sprintf("segment %d has invalid iv: %v", i, err))
}
if len(iv) != blockSize {
// Accept short IVs by left-aligning into a block-sized counter
// (CENC commonly uses 8-byte IVs for a 16-byte AES counter).
if len(iv) > blockSize {
return fail(fmt.Sprintf("segment %d iv longer than block size (%d > %d)", i, len(iv), blockSize))
}
padded := make([]byte, blockSize)
copy(padded, iv)
iv = padded
}
segData := data[offset : offset+size]
cipher.NewCTR(block, iv).XORKeyStream(segData, segData)
processed++
}
// Return raw bytes as an ArrayBuffer when requested (zero-copy-ish, no
// base64). Otherwise fall back to an encoded string.
if outputEncoding == "bytes" || outputEncoding == "raw" {
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": r.vm.NewArrayBuffer(data),
"segments_processed": processed,
})
}
encoded, err := encodeRuntimeBytes(data, outputEncoding)
if err != nil {
return fail(err.Error())
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": encoded,
"segments_processed": processed,
})
}
+485
View File
@@ -0,0 +1,485 @@
package gobackend
import (
"encoding/json"
"testing"
"github.com/dop251/goja"
)
func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime {
t.Helper()
ext := &loadedExtension{
ID: "binary-test-ext",
Manifest: &ExtensionManifest{
Name: "binary-test-ext",
Permissions: ExtensionPermissions{
File: withFilePermission,
},
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
vm := goja.New()
runtime.RegisterAPIs(vm)
return vm
}
func decodeJSONResult[T any](t *testing.T, value goja.Value) T {
t.Helper()
var decoded T
if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil {
t.Fatalf("failed to decode JSON result: %v", err)
}
return decoded
}
func TestExtensionRuntime_FileByteAPIs(t *testing.T) {
vm := newBinaryTestRuntime(t, true)
result, err := vm.RunString(`
(function() {
var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true});
if (!first.success) throw new Error(first.error);
var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true});
if (!second.success) throw new Error(second.error);
var all = file.readBytes("bytes.bin", {encoding: "hex"});
if (!all.success) throw new Error(all.error);
var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"});
if (!slice.success) throw new Error(slice.error);
var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"});
if (!tail.success) throw new Error(tail.error);
return JSON.stringify({
all: all.data,
slice: slice.data,
size: all.size,
sliceBytes: slice.bytes_read,
sliceEof: slice.eof,
tailBytes: tail.bytes_read,
tailEof: tail.eof
});
})()
`)
if err != nil {
t.Fatalf("file byte APIs failed: %v", err)
}
decoded := decodeJSONResult[struct {
All string `json:"all"`
Slice string `json:"slice"`
Size int64 `json:"size"`
SliceBytes int `json:"sliceBytes"`
SliceEof bool `json:"sliceEof"`
TailBytes int `json:"tailBytes"`
TailEof bool `json:"tailEof"`
}](t, result)
if decoded.All != "0001020304ff" {
t.Fatalf("all = %q", decoded.All)
}
if decoded.Slice != "0203" {
t.Fatalf("slice = %q", decoded.Slice)
}
if decoded.Size != 6 {
t.Fatalf("size = %d", decoded.Size)
}
if decoded.SliceBytes != 2 {
t.Fatalf("slice bytes = %d", decoded.SliceBytes)
}
if decoded.SliceEof {
t.Fatal("slice should not be EOF")
}
if decoded.TailBytes != 0 || !decoded.TailEof {
t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof)
}
}
func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "blowfish",
mode: "cbc",
key: "0123456789ABCDEFF0E1D2C3B4A59687",
keyEncoding: "hex",
iv: "0001020304050607",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex",
padding: "none"
};
var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, options);
if (!dec.success) throw new Error(dec.error);
return JSON.stringify({enc: enc.data, dec: dec.data});
})()
`)
if err != nil {
t.Fatalf("blowfish block cipher failed: %v", err)
}
decoded := decodeJSONResult[struct {
Enc string `json:"enc"`
Dec string `json:"dec"`
}](t, result)
if decoded.Dec != "00112233445566778899aabbccddeeff" {
t.Fatalf("dec = %q", decoded.Dec)
}
if decoded.Enc == decoded.Dec {
t.Fatal("expected ciphertext to differ from plaintext")
}
}
func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "cbc",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0f0e0d0c0b0a09080706050403020100",
ivEncoding: "hex",
inputEncoding: "utf8",
outputEncoding: "base64",
padding: "pkcs7"
};
var enc = utils.encryptBlockCipher("hello generic cbc", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, {
algorithm: "aes",
mode: "cbc",
key: options.key,
keyEncoding: options.keyEncoding,
iv: options.iv,
ivEncoding: options.ivEncoding,
inputEncoding: "base64",
outputEncoding: "utf8",
padding: "pkcs7"
});
if (!dec.success) throw new Error(dec.error);
return dec.data;
})()
`)
if err != nil {
t.Fatalf("aes block cipher failed: %v", err)
}
if result.String() != "hello generic cbc" {
t.Fatalf("unexpected decrypted value: %q", result.String())
}
}
func TestExtensionRuntime_BlockCipherCTRSupportsAES(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// NIST SP 800-38A, F.5.1 CTR-AES128.Encrypt test vector.
// Key: 2b7e151628aed2a6abf7158809cf4f3c
// Counter: f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff
// Plaintext: 6bc1bee22e409f96e93d7e117393172a (block 1)
// Ciphertext: 874d6191b620e3261bef6864990db6ce (block 1)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "ctr",
key: "2b7e151628aed2a6abf7158809cf4f3c",
keyEncoding: "hex",
iv: "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex"
};
var enc = utils.encryptBlockCipher("6bc1bee22e409f96e93d7e117393172a", options);
if (!enc.success) throw new Error(enc.error);
// CTR is symmetric: decrypt is the same transform as encrypt.
var dec = utils.decryptBlockCipher(enc.data, options);
if (!dec.success) throw new Error(dec.error);
return JSON.stringify({enc: enc.data, dec: dec.data});
})()
`)
if err != nil {
t.Fatalf("aes ctr block cipher failed: %v", err)
}
decoded := decodeJSONResult[struct {
Enc string `json:"enc"`
Dec string `json:"dec"`
}](t, result)
if decoded.Enc != "874d6191b620e3261bef6864990db6ce" {
t.Fatalf("ctr ciphertext = %q, want NIST vector 874d6191b620e3261bef6864990db6ce", decoded.Enc)
}
if decoded.Dec != "6bc1bee22e409f96e93d7e117393172a" {
t.Fatalf("ctr round-trip dec = %q", decoded.Dec)
}
}
func TestExtensionRuntime_BlockCipherCTRHandlesNonBlockLength(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// CTR is a stream mode, so arbitrary (non-16-byte-aligned) input lengths
// must round-trip without any padding.
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "ctr",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0f0e0d0c0b0a09080706050403020100",
ivEncoding: "hex",
inputEncoding: "utf8",
outputEncoding: "base64"
};
var enc = utils.encryptBlockCipher("stream ctr of odd length", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, {
algorithm: "aes",
mode: "ctr",
key: options.key,
keyEncoding: options.keyEncoding,
iv: options.iv,
ivEncoding: options.ivEncoding,
inputEncoding: "base64",
outputEncoding: "utf8"
});
if (!dec.success) throw new Error(dec.error);
return dec.data;
})()
`)
if err != nil {
t.Fatalf("aes ctr stream length failed: %v", err)
}
if result.String() != "stream ctr of odd length" {
t.Fatalf("unexpected ctr decrypted value: %q", result.String())
}
}
func TestExtensionRuntime_BlockCipherCTRRejectsBadIV(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var res = utils.encryptBlockCipher("00112233", {
algorithm: "aes",
mode: "ctr",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0001",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex"
});
return JSON.stringify({success: res.success, error: res.error || ""});
})()
`)
if err != nil {
t.Fatalf("aes ctr bad iv eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Success bool `json:"success"`
Error string `json:"error"`
}](t, result)
if decoded.Success {
t.Fatal("expected failure for undersized CTR iv")
}
if decoded.Error == "" {
t.Fatal("expected error message for undersized CTR iv")
}
}
func TestExtensionRuntime_DecryptCTRSegmentsMatchesPerSegment(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// Build a buffer of 3 segments encrypted with distinct 8-byte IVs (CENC
// style), then verify the batch primitive decrypts all of them in one call,
// matching what per-segment decryptBlockCipher would produce.
result, err := vm.RunString(`
(function() {
var keyHex = "000102030405060708090a0b0c0d0e0f";
function b64(bytes){return utils.base64Encode(utils.toHex ? bytes : bytes);}
// segment plaintexts (hex) and 8-byte IVs (hex)
var segs = [
{ pt: "11111111111111111111", iv: "0000000000000001" },
{ pt: "2222222222", iv: "0000000000000002" },
{ pt: "333333333333333333333333", iv: "00000000000000ff" }
];
// Encrypt each segment individually using single-shot CTR with a
// 16-byte counter (8-byte iv left-aligned), producing ciphertext hex.
function ivToB64(ivHex){
// pad 8-byte hex iv to 16 bytes then base64
var full = ivHex + "00000000000000000000000000000000".slice(ivHex.length);
return utils.base64Encode(utils.hexToBytes ? utils.hexToBytes(full) : full);
}
var cipherHex = "";
var offsets = [];
var off = 0;
var ivB64s = [];
for (var i=0;i<segs.length;i++){
var ivFullHex = (segs[i].iv + "00000000000000000000000000000000").slice(0,32);
var enc = utils.encryptBlockCipher(segs[i].pt, {
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
iv: ivFullHex, ivEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex"
});
if(!enc.success) throw new Error("enc seg "+i+": "+enc.error);
cipherHex += enc.data;
var sz = segs[i].pt.length/2;
offsets.push({offset: off, size: sz, ivHex: ivFullHex});
off += sz;
}
// Now decrypt the whole concatenated buffer in ONE batch call.
var segments = offsets.map(function(o){
return { offset:o.offset, size:o.size, iv:o.ivHex };
});
var batch = utils.decryptCTRSegments(cipherHex, {
algorithm:"aes", key:keyHex, keyEncoding:"hex",
segments: segments, ivEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex"
});
if(!batch.success) throw new Error("batch: "+batch.error);
var expected = "";
for (var j=0;j<segs.length;j++) expected += segs[j].pt;
return JSON.stringify({
out: batch.data,
expected: expected,
processed: batch.segments_processed
});
})()
`)
if err != nil {
t.Fatalf("batch CTR eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Out string `json:"out"`
Expected string `json:"expected"`
Processed int `json:"processed"`
}](t, result)
if decoded.Out != decoded.Expected {
t.Fatalf("batch decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
}
if decoded.Processed != 3 {
t.Fatalf("segments_processed = %d, want 3", decoded.Processed)
}
}
func TestExtensionRuntime_DecryptCTRSegmentsRejectsOutOfBounds(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var res = utils.decryptCTRSegments("00112233", {
algorithm:"aes", key:"000102030405060708090a0b0c0d0e0f", keyEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex",
ivEncoding:"hex",
segments: [ { offset: 0, size: 99, iv: "00000000000000000000000000000000" } ]
});
return JSON.stringify({ success: res.success, error: res.error || "" });
})()
`)
if err != nil {
t.Fatalf("oob eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Success bool `json:"success"`
Error string `json:"error"`
}](t, result)
if decoded.Success {
t.Fatal("expected out-of-bounds segment to fail")
}
if decoded.Error == "" {
t.Fatal("expected error message for out-of-bounds segment")
}
}
func TestExtensionRuntime_DecryptCTRSegmentsRawBytes(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// Verify the zero-base64 path: pass an ArrayBuffer in, request bytes out,
// and confirm round-trip correctness against single-shot CTR.
result, err := vm.RunString(`
(function() {
var keyHex = "000102030405060708090a0b0c0d0e0f";
var ivFullHex = "0000000000000001" + "00000000000000000000000000000000".slice(16);
// Plaintext as a Uint8Array of 20 bytes.
var pt = new Uint8Array(20);
for (var i = 0; i < pt.length; i++) pt[i] = (i * 7 + 3) & 0xff;
// Encrypt single-shot to get ciphertext (hex output for clarity).
var ptHex = "";
for (var j = 0; j < pt.length; j++) { var h = pt[j].toString(16); ptHex += (h.length === 1 ? "0" : "") + h; }
var enc = utils.encryptBlockCipher(ptHex, {
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
iv: ivFullHex, ivEncoding:"hex", inputEncoding:"hex", outputEncoding:"base64"
});
if (!enc.success) throw new Error("enc: " + enc.error);
// Decode ciphertext base64 into a Uint8Array to feed the raw path.
var cipherBytes = utils.base64Decode ? null : null;
// Build ArrayBuffer from base64 via Uint8Array manually:
var b64 = enc.data;
var bin = (typeof atob === "function") ? null : null;
// Simpler: ask the host to give us bytes by decrypting nothing is hard,
// so just pass the base64 ciphertext through decryptCTRSegments using
// base64 input but bytes output, then re-run with bytes input.
var step1 = utils.decryptCTRSegments(b64, {
algorithm:"aes", key:keyHex, keyEncoding:"hex",
segments: [ { offset:0, size:20, iv: ivFullHex } ],
ivEncoding:"hex", inputEncoding:"base64", outputEncoding:"bytes"
});
if (!step1.success) throw new Error("step1: " + step1.error);
if (typeof step1.data === "string") throw new Error("expected ArrayBuffer output, got string");
var outArr = new Uint8Array(step1.data);
var outHex = "";
for (var k = 0; k < outArr.length; k++) { var hh = outArr[k].toString(16); outHex += (hh.length === 1 ? "0" : "") + hh; }
return JSON.stringify({ out: outHex, expected: ptHex, len: outArr.length });
})()
`)
if err != nil {
t.Fatalf("raw-bytes eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Out string `json:"out"`
Expected string `json:"expected"`
Len int `json:"len"`
}](t, result)
if decoded.Out != decoded.Expected {
t.Fatalf("raw-bytes decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
}
if decoded.Len != 20 {
t.Fatalf("output length = %d, want 20", decoded.Len)
}
}

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