Compare commits

..

445 Commits

Author SHA1 Message Date
Zarz Eleutherius 91c141b7ee New translations app_en.arb (Spanish)
[ci skip]
2026-07-03 04:25:28 +07:00
Zarz Eleutherius 4577ce5ca7 New translations app_en.arb (Spanish)
[ci skip]
2026-07-03 03:26:55 +07:00
Zarz Eleutherius 7d843ca02a New translations app_en.arb (Indonesian)
[ci skip]
2026-07-03 01:58:22 +07:00
Zarz Eleutherius 89de08e08c New translations app_en.arb (Korean)
[ci skip]
2026-07-03 00:04:38 +07:00
Zarz Eleutherius 02213d1edf New translations app_en.arb (Korean)
[ci skip]
2026-07-02 21:58:18 +07:00
Zarz Eleutherius 9dedb66800 New translations app_en.arb (French)
[ci skip]
2026-07-02 14:36:38 +07:00
Zarz Eleutherius 24408e0cba New translations app_en.arb (Korean)
[ci skip]
2026-07-02 12:18:01 +07:00
Zarz Eleutherius 4d251a2a0d New translations app_en.arb (Korean)
[ci skip]
2026-07-02 10:04:54 +07:00
Zarz Eleutherius 52972eefef New translations app_en.arb (Korean)
[ci skip]
2026-07-02 09:09:05 +07:00
Zarz Eleutherius 471b412dc5 New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 07:02:34 +07:00
Zarz Eleutherius 4c85a8f05e New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 05:43:37 +07:00
Zarz Eleutherius 0c441b2c86 New translations app_en.arb (Hindi)
[ci skip]
2026-07-02 02:32:59 +07:00
Zarz Eleutherius f6ddd62b13 New translations app_en.arb (Indonesian)
[ci skip]
2026-07-02 02:32:58 +07:00
Zarz Eleutherius 5fd67e53c4 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-07-02 02:32:56 +07:00
Zarz Eleutherius 1206c57d6c New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-07-02 02:32:54 +07:00
Zarz Eleutherius 38815fa0aa New translations app_en.arb (Ukrainian)
[ci skip]
2026-07-02 02:32:52 +07:00
Zarz Eleutherius 959206b0be New translations app_en.arb (Turkish)
[ci skip]
2026-07-02 02:32:51 +07:00
Zarz Eleutherius 7b7ee523fd New translations app_en.arb (Russian)
[ci skip]
2026-07-02 02:32:49 +07:00
Zarz Eleutherius b1220b4c19 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-02 02:32:47 +07:00
Zarz Eleutherius 62b894a7ba New translations app_en.arb (Dutch)
[ci skip]
2026-07-02 02:32:45 +07:00
Zarz Eleutherius 45188ef87d New translations app_en.arb (Korean)
[ci skip]
2026-07-02 02:32:43 +07:00
Zarz Eleutherius 2185ccf2c9 New translations app_en.arb (Japanese)
[ci skip]
2026-07-02 02:32:42 +07:00
Zarz Eleutherius e8d95178c7 New translations app_en.arb (German)
[ci skip]
2026-07-02 02:32:40 +07:00
Zarz Eleutherius a3ebe27b76 New translations app_en.arb (Arabic)
[ci skip]
2026-07-02 02:32:38 +07:00
Zarz Eleutherius b39d4fa15a New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 02:32:37 +07:00
Zarz Eleutherius f86b2b816b New translations app_en.arb (French)
[ci skip]
2026-07-02 02:32:35 +07:00
Zarz Eleutherius 308a75037e New translations app_en.arb (Korean)
[ci skip]
2026-07-02 00:58:11 +07:00
Zarz Eleutherius bda41a0c1a New translations app_en.arb (Korean)
[ci skip]
2026-07-02 00:01:11 +07:00
Zarz Eleutherius ec5e7ed1cd New translations app_en.arb (Arabic)
[ci skip]
2026-07-02 00:01:10 +07:00
Zarz Eleutherius 6528d445d3 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 22:52:54 +07:00
Zarz Eleutherius aacf1bef9c New translations app_en.arb (Korean)
[ci skip]
2026-07-01 21:38:38 +07:00
Zarz Eleutherius 4262553036 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 20:35:15 +07:00
Zarz Eleutherius a3d6c6f916 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 19:03:56 +07:00
Zarz Eleutherius bce05eb071 New translations app_en.arb (French)
[ci skip]
2026-07-01 19:03:54 +07:00
Zarz Eleutherius e35ea061d5 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 17:52:36 +07:00
Zarz Eleutherius 8c7c168fdf New translations app_en.arb (French)
[ci skip]
2026-07-01 17:52:34 +07:00
Zarz Eleutherius 7a811ad55e New translations app_en.arb (Korean)
[ci skip]
2026-07-01 16:56:33 +07:00
Zarz Eleutherius 8d29be2a6d New translations app_en.arb (Korean)
[ci skip]
2026-07-01 15:56:58 +07:00
Zarz Eleutherius 36567f40a1 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 14:31:17 +07:00
Zarz Eleutherius 76e0de7d20 New translations app_en.arb (French)
[ci skip]
2026-07-01 14:31:15 +07:00
Zarz Eleutherius 74a41a2d49 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 12:53:58 +07:00
Zarz Eleutherius 1ba82df9e3 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 11:36:48 +07:00
Zarz Eleutherius ccf995ec11 New translations app_en.arb (French)
[ci skip]
2026-07-01 05:21:22 +07:00
Zarz Eleutherius 159b7c2ab6 New translations app_en.arb (Hindi)
[ci skip]
2026-07-01 04:22:04 +07:00
Zarz Eleutherius dc60ad1137 New translations app_en.arb (Indonesian)
[ci skip]
2026-07-01 04:22:02 +07:00
Zarz Eleutherius 1c46708303 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-07-01 04:22:01 +07:00
Zarz Eleutherius 532042e39e New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-07-01 04:21:59 +07:00
Zarz Eleutherius 5cb6faa2d6 New translations app_en.arb (Ukrainian)
[ci skip]
2026-07-01 04:21:57 +07:00
Zarz Eleutherius 91b88a40ea New translations app_en.arb (Turkish)
[ci skip]
2026-07-01 04:21:56 +07:00
Zarz Eleutherius 72a9691c55 New translations app_en.arb (Russian)
[ci skip]
2026-07-01 04:21:54 +07:00
Zarz Eleutherius 00e8280d22 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-01 04:21:52 +07:00
Zarz Eleutherius ed2c7606c3 New translations app_en.arb (Dutch)
[ci skip]
2026-07-01 04:21:51 +07:00
Zarz Eleutherius c1a6262d96 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 04:21:49 +07:00
Zarz Eleutherius 59b953d577 New translations app_en.arb (Japanese)
[ci skip]
2026-07-01 04:21:47 +07:00
Zarz Eleutherius 0854174140 New translations app_en.arb (German)
[ci skip]
2026-07-01 04:21:45 +07:00
Zarz Eleutherius 3802df0abd New translations app_en.arb (Arabic)
[ci skip]
2026-07-01 04:21:44 +07:00
Zarz Eleutherius 57f09cc50c New translations app_en.arb (Spanish)
[ci skip]
2026-07-01 04:21:42 +07:00
Zarz Eleutherius 5257a035d7 New translations app_en.arb (French)
[ci skip]
2026-07-01 04:21:40 +07:00
Zarz Eleutherius 0cab036718 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 01:47:32 +07:00
Zarz Eleutherius 4fabd8cfd0 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 23:54:56 +07:00
Zarz Eleutherius 6b7f63d784 New translations app_en.arb (Korean)
[ci skip]
2026-06-30 23:54:55 +07:00
Zarz Eleutherius 95b56c80c5 New translations app_en.arb (Korean)
[ci skip]
2026-06-30 21:51:26 +07:00
Zarz Eleutherius f2020b7653 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-30 17:01:36 +07:00
Zarz Eleutherius 97bb4f6641 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 17:01:34 +07:00
Zarz Eleutherius adeed5ae13 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 14:43:55 +07:00
Zarz Eleutherius 5e9cce4b8c New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 08:55:15 +07:00
Zarz Eleutherius 22a8fab1c7 New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 03:44:33 +07:00
Zarz Eleutherius f6a0cc9fad New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 02:11:11 +07:00
Zarz Eleutherius 765f0d63b6 New translations app_en.arb (Portuguese)
[ci skip]
2026-06-29 23:32:47 +07:00
Zarz Eleutherius c193e88990 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 22:21:01 +07:00
Zarz Eleutherius 6398d8f7eb New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 22:20:59 +07:00
Zarz Eleutherius 030b8c5459 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 20:52:21 +07:00
Zarz Eleutherius 469af1ac5d New translations app_en.arb (Korean)
[ci skip]
2026-06-29 18:37:22 +07:00
Zarz Eleutherius 6c635dfd01 New translations app_en.arb (Spanish)
[ci skip]
2026-06-29 16:35:21 +07:00
Zarz Eleutherius 299e93ffe3 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 15:38:14 +07:00
Zarz Eleutherius 4dc5b2ee30 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 13:39:38 +07:00
Zarz Eleutherius 6613461eea New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 09:49:23 +07:00
Zarz Eleutherius 9de442b0c8 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 08:39:04 +07:00
Zarz Eleutherius 6bc65fe559 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 07:35:09 +07:00
Zarz Eleutherius 7da423ee1b New translations app_en.arb (Korean)
[ci skip]
2026-06-29 06:39:15 +07:00
Zarz Eleutherius c6ccefbd72 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 06:39:13 +07:00
Zarz Eleutherius 72d25f576c New translations app_en.arb (Korean)
[ci skip]
2026-06-29 01:20:27 +07:00
Zarz Eleutherius 7ab9481fab New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 22:07:34 +07:00
Zarz Eleutherius f494e03a6e New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 20:57:58 +07:00
Zarz Eleutherius a90dce95a3 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 19:35:19 +07:00
Zarz Eleutherius a35f6ad939 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 18:37:44 +07:00
Zarz Eleutherius 80e704fd26 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 11:03:47 +07:00
Zarz Eleutherius 88fc958729 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 09:34:15 +07:00
Zarz Eleutherius 12d6940a6e New translations app_en.arb (Korean)
[ci skip]
2026-06-27 22:36:44 +07:00
Zarz Eleutherius 34bf726cc1 New translations app_en.arb (Korean)
[ci skip]
2026-06-27 21:33:08 +07:00
Zarz Eleutherius 68af217b7b New translations app_en.arb (Korean)
[ci skip]
2026-06-27 01:30:31 +07:00
Zarz Eleutherius 5eda1c4cb0 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 22:10:10 +07:00
Zarz Eleutherius f5c934f744 New translations app_en.arb (Turkish)
[ci skip]
2026-06-26 20:46:23 +07:00
Zarz Eleutherius 89c71f6b16 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 20:46:21 +07:00
Zarz Eleutherius 4523bc5532 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-26 19:18:41 +07:00
Zarz Eleutherius b9c66e0c7b New translations app_en.arb (Korean)
[ci skip]
2026-06-26 14:12:22 +07:00
Zarz Eleutherius f26c7cfe02 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 11:13:48 +07:00
Zarz Eleutherius 3bbe29c2e8 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 19:21:07 +07:00
Zarz Eleutherius e0cc1f7cb2 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 07:08:49 +07:00
Zarz Eleutherius 9faa1b7961 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 06:01:55 +07:00
Zarz Eleutherius 142d7e639b New translations app_en.arb (Korean)
[ci skip]
2026-06-25 00:45:57 +07:00
Zarz Eleutherius 9e115902b7 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 23:08:31 +07:00
Zarz Eleutherius d2ec68808c New translations app_en.arb (Korean)
[ci skip]
2026-06-24 21:57:39 +07:00
Zarz Eleutherius 352186eb40 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 11:55:48 +07:00
Zarz Eleutherius 68e742b670 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 10:55:05 +07:00
Zarz Eleutherius d7c4586358 New translations app_en.arb (Korean)
[ci skip]
2026-06-23 19:57:10 +07:00
Zarz Eleutherius 84f784e538 New translations app_en.arb (Korean)
[ci skip]
2026-06-23 16:02:14 +07:00
Zarz Eleutherius 5f9822f726 New translations app_en.arb (Korean)
[ci skip]
2026-06-22 23:06:40 +07:00
Zarz Eleutherius 41a1b94811 New translations app_en.arb (Korean)
[ci skip]
2026-06-22 22:05:04 +07:00
Zarz Eleutherius e4b1d39c4e New translations app_en.arb (Korean)
[ci skip]
2026-06-22 20:58:17 +07:00
Zarz Eleutherius 136de95290 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-22 19:25:42 +07:00
Zarz Eleutherius 1afe66cb66 New translations app_en.arb (French)
[ci skip]
2026-06-21 23:58:16 +07:00
Zarz Eleutherius bdf1626273 New translations app_en.arb (French)
[ci skip]
2026-06-21 22:50:25 +07:00
Zarz Eleutherius 87131ad633 New translations app_en.arb (Hindi)
[ci skip]
2026-06-21 21:33:23 +07:00
Zarz Eleutherius 439418e419 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-21 21:33:21 +07:00
Zarz Eleutherius 0eb4e15a8f New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-06-21 21:33:20 +07:00
Zarz Eleutherius 5f3928399c New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-21 21:33:18 +07:00
Zarz Eleutherius 0c0f2d54ad New translations app_en.arb (Ukrainian)
[ci skip]
2026-06-21 21:33:17 +07:00
Zarz Eleutherius 2f54fe6cf6 New translations app_en.arb (Turkish)
[ci skip]
2026-06-21 21:33:15 +07:00
Zarz Eleutherius e827a26458 New translations app_en.arb (Russian)
[ci skip]
2026-06-21 21:33:14 +07:00
Zarz Eleutherius c886d55317 New translations app_en.arb (Portuguese)
[ci skip]
2026-06-21 21:33:13 +07:00
Zarz Eleutherius 0189f576c7 New translations app_en.arb (Dutch)
[ci skip]
2026-06-21 21:33:11 +07:00
Zarz Eleutherius 13c46d0f5e New translations app_en.arb (Korean)
[ci skip]
2026-06-21 21:33:10 +07:00
Zarz Eleutherius 60175108df New translations app_en.arb (Japanese)
[ci skip]
2026-06-21 21:33:08 +07:00
Zarz Eleutherius 7c2b87f49a New translations app_en.arb (German)
[ci skip]
2026-06-21 21:33:07 +07:00
Zarz Eleutherius f33ee40779 New translations app_en.arb (Arabic)
[ci skip]
2026-06-21 21:33:05 +07:00
Zarz Eleutherius b9142bc40c New translations app_en.arb (Spanish)
[ci skip]
2026-06-21 21:33:04 +07:00
Zarz Eleutherius 680e0e0976 New translations app_en.arb (French)
[ci skip]
2026-06-21 21:33:02 +07:00
github-actions[bot] 7b22bbf25f chore: update AltStore source to v4.6.0 2026-06-13 19:43:33 +00:00
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
278 changed files with 140184 additions and 29167 deletions
+9
View File
@@ -257,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
+5 -1
View File
@@ -44,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
@@ -57,7 +58,6 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
# Extension folder
extension/
@@ -66,8 +66,12 @@ extension/
AGENTS.md
# Temp/misc
.tmp/
nul
NUL
network_requests.txt
*.bak
/AndroidManifest.xml
# Log files
*.log
Binary file not shown.
+1 -1
View File
@@ -86,7 +86,7 @@ Translation files are located in `lib/l10n/arb/`.
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Use FVM (Flutter Version: 3.38.1)**
3. **Use FVM (Flutter Version: 3.41.5)**
```bash
fvm use
```
+14 -18
View File
@@ -1,14 +1,14 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
<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>
<p align="center">
<a href="https://trendshift.io/repositories/17247">
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
<a 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>
@@ -28,10 +28,10 @@
## 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>
---
@@ -59,7 +59,7 @@ Extensions let the community add new music sources and features without waiting
## Related Projects
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
@@ -80,7 +80,7 @@ Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository
<summary><b>Why is my download failing with "Song not found"?</b></summary>
<br>
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
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.
</details>
@@ -88,10 +88,7 @@ The track may not be available on the streaming services. Try enabling more prov
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
<br>
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
- **Tidal** up to 24-bit/192kHz
- **Qobuz** up to 24-bit/192kHz
- **Deezer** up to 16-bit/44.1kHz
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.
</details>
@@ -166,9 +163,8 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
| | | | | |
|---|---|---|---|---|
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | [Monochrome](https://monochrome.tf) |
| [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) | | | | |
---
+7 -7
View File
@@ -9,6 +9,9 @@
# 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/**
@@ -19,9 +22,6 @@ analyzer:
strict-casts: true
strict-inference: true
strict-raw-types: true
plugins:
- custom_lint
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
@@ -36,13 +36,13 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
always_declare_return_types: true
avoid_dynamic_calls: true
avoid_types_as_parameter_names: true
strict_top_level_inference: true
type_annotate_public_apis: true
cancel_subscriptions: true
close_sinks: true
custom_lint:
rules:
- avoid_public_notifier_properties
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
-71
View File
@@ -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'
}
+4 -3
View File
@@ -120,8 +120,9 @@ dependencies {
// Include all AAR and JAR files from libs folder
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.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.12.3")
implementation("androidx.activity:activity-ktx:1.13.0")
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
}
+1 -1
View File
@@ -18,7 +18,7 @@
<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"
@@ -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
@@ -43,6 +43,9 @@ class MainActivity: FlutterFragmentActivity() {
"com.zarz.spotiflac/library_scan_progress_stream"
private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L
private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L
private val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
@@ -51,6 +54,7 @@ class MainActivity: FlutterFragmentActivity() {
private var downloadProgressStreamJob: Job? = null
private var downloadProgressEventSink: EventChannel.EventSink? = null
private var lastDownloadProgressPayload: String? = null
private var lastDownloadProgressSeq = 0L
private var libraryScanProgressStreamJob: Job? = null
private var libraryScanProgressEventSink: EventChannel.EventSink? = null
private var lastLibraryScanProgressPayload: String? = null
@@ -299,30 +303,17 @@ class MainActivity: FlutterFragmentActivity() {
private fun mimeTypeForExt(ext: String?): String {
return when (normalizeExt(ext)) {
".m4a" -> "audio/mp4"
".m4a", ".mp4" -> "audio/mp4"
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".flac" -> "audio/flac"
".wav" -> "audio/wav"
".aiff", ".aif", ".aifc" -> "audio/aiff"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
}
private fun forceFilenameExt(name: String, outputExt: String): String {
val normalizedExt = normalizeExt(outputExt)
if (normalizedExt.isBlank()) return sanitizeFilename(name)
val safeName = sanitizeFilename(name)
val lower = safeName.lowercase(Locale.ROOT)
val knownExts = listOf(".flac", ".m4a", ".mp3", ".opus", ".lrc")
for (knownExt in knownExts) {
if (lower.endsWith(knownExt)) {
return safeName.dropLast(knownExt.length) + normalizedExt
}
}
return safeName + normalizedExt
}
private fun sanitizeFilename(name: String): String {
var sanitized = name
.replace("/", " ")
@@ -341,9 +332,47 @@ class MainActivity: FlutterFragmentActivity() {
.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
@@ -504,17 +533,46 @@ class MainActivity: FlutterFragmentActivity() {
}
}
private fun bridgeJsonResult(payload: String): Any {
if (payload.toByteArray(Charsets.UTF_8).size < LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES) {
return payload
}
return try {
val file = File(cacheDir, "bridge_json_${System.nanoTime()}.json")
file.writeText(payload, Charsets.UTF_8)
mapOf(LARGE_JSON_RESULT_FILE_KEY to file.absolutePath)
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Failed to spill large bridge JSON result to file: ${e.message}",
)
payload
}
}
private fun updateDownloadProgressSeq(payload: String) {
try {
val seq = JSONObject(payload).optLong("seq", lastDownloadProgressSeq)
if (seq > lastDownloadProgressSeq) {
lastDownloadProgressSeq = seq
}
} catch (_: Exception) {}
}
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
stopDownloadProgressStream()
downloadProgressEventSink = sink
lastDownloadProgressPayload = null
lastDownloadProgressSeq = 0L
downloadProgressStreamJob = scope.launch {
while (isActive && downloadProgressEventSink === sink) {
try {
val payload = withContext(Dispatchers.IO) {
Gobackend.getAllDownloadProgress()
Gobackend.getAllDownloadProgressDelta(lastDownloadProgressSeq)
}
if (payload != lastDownloadProgressPayload) {
if (payload.isNotEmpty() && payload != lastDownloadProgressPayload) {
updateDownloadProgressSeq(payload)
lastDownloadProgressPayload = payload
sink.success(parseJsonPayload(payload))
}
@@ -534,6 +592,7 @@ class MainActivity: FlutterFragmentActivity() {
downloadProgressStreamJob = null
downloadProgressEventSink = null
lastDownloadProgressPayload = null
lastDownloadProgressSeq = 0L
}
private fun startLibraryScanProgressStream(sink: EventChannel.EventSink) {
@@ -580,17 +639,17 @@ class MainActivity: FlutterFragmentActivity() {
lastLibraryScanProgressPayload = null
}
private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String {
private fun loadExistingFilesFromSnapshot(snapshotPath: String): MutableMap<String, Long> {
val result = mutableMapOf<String, Long>()
if (snapshotPath.isBlank()) {
return "{}"
return result
}
val snapshotFile = File(snapshotPath)
if (!snapshotFile.exists()) {
return "{}"
return result
}
val result = JSONObject()
snapshotFile.forEachLine { line ->
if (line.isBlank()) return@forEachLine
val separatorIndex = line.indexOf('\t')
@@ -600,10 +659,10 @@ class MainActivity: FlutterFragmentActivity() {
val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L
val filePath = line.substring(separatorIndex + 1)
if (filePath.isNotEmpty()) {
result.put(filePath, modTime)
result[filePath] = modTime
}
}
return result.toString()
return result
}
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
@@ -667,16 +726,6 @@ class MainActivity: FlutterFragmentActivity() {
return obj.toString()
}
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return 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)
@@ -724,6 +773,8 @@ class MainActivity: FlutterFragmentActivity() {
private fun extFromFileName(name: String): String {
return when {
name.endsWith(".m4a") -> ".m4a"
name.endsWith(".mp4") -> ".mp4"
name.endsWith(".aac") -> ".aac"
name.endsWith(".mp3") -> ".mp3"
name.endsWith(".opus") -> ".opus"
name.endsWith(".flac") -> ".flac"
@@ -735,9 +786,15 @@ class MainActivity: FlutterFragmentActivity() {
private fun extFromMimeType(mime: String?): String {
return when (mime) {
"audio/mp4" -> ".m4a"
"audio/aac" -> ".aac"
"audio/eac3" -> ".m4a"
"audio/ac3" -> ".m4a"
"audio/ac4" -> ".m4a"
"audio/mpeg" -> ".mp3"
"audio/ogg" -> ".opus"
"audio/flac" -> ".flac"
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav"
"audio/aiff", "audio/x-aiff" -> ".aiff"
else -> ""
}
}
@@ -957,112 +1014,6 @@ class MainActivity: FlutterFragmentActivity() {
return true
}
private fun handleSafDownload(requestJson: String, downloader: (String) -> String): String {
val req = JSONObject(requestJson)
val storageMode = req.optString("storage_mode", "")
val treeUriStr = req.optString("saf_tree_uri", "")
if (storageMode != "saf" || treeUriStr.isBlank()) {
return downloader(requestJson)
}
val treeUri = Uri.parse(treeUriStr)
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt)
val existingDir = findDocumentDir(treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
val obj = JSONObject()
obj.put("success", true)
obj.put("message", "File already exists")
obj.put("file_path", existing.uri.toString())
obj.put("file_name", existing.name ?: fileName)
obj.put("already_exists", true)
return obj.toString()
}
}
val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory")
var document = createOrReuseDocumentFile(targetDir, mimeType, fileName)
?: return errorJson("Failed to create SAF file")
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
?: return errorJson("Failed to open SAF file")
var detachedFd: Int? = null
try {
// Prefer handing off a detached FD directly to Go.
// Some devices/providers reject re-opening /proc/self/fd/* with permission denied.
detachedFd = pfd.detachFd()
req.put("output_path", "")
req.put("output_fd", detachedFd)
req.put("output_ext", outputExt)
val response = downloader(req.toString())
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
// Extension providers write to a local temp path instead of the SAF FD.
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
!goFilePath.startsWith("/proc/self/fd/")
) {
try {
val srcFile = java.io.File(goFilePath)
if (!srcFile.exists() || srcFile.length() <= 0) {
throw IllegalStateException("extension output missing or empty: $goFilePath")
}
val actualExt = normalizeExt(srcFile.extension)
if (actualExt.isNotBlank() && actualExt != outputExt) {
val actualFileName = buildSafFileName(req, actualExt)
val actualMimeType = mimeTypeForExt(actualExt)
val replacement = createOrReuseDocumentFile(
targetDir,
actualMimeType,
actualFileName,
)
?: throw IllegalStateException("failed to create SAF output with actual extension")
if (replacement.uri != document.uri) {
document.delete()
document = replacement
}
}
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)
} else {
document.delete()
}
return respObj.toString()
} catch (e: Exception) {
document.delete()
return errorJson("SAF download failed: ${e.message}")
} finally {
// If detachFd() failed before handoff, close original ParcelFileDescriptor.
// Otherwise Go owns the detached raw FD and is responsible for closing it.
if (detachedFd == null) {
try {
pfd.close()
} catch (_: Exception) {}
}
}
}
/**
* Get the parent DocumentFile directory for a SAF document URI.
* The child URI must be a tree-based document URI (e.g. from SAF tree scan).
@@ -1090,6 +1041,48 @@ class MainActivity: FlutterFragmentActivity() {
}
}
/**
* Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses
* the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created
* in the same parent directory. Used by re-enrich when the user's lyrics
* mode requests an external/both sidecar. Best-effort: failures are logged
* and swallowed so they never abort the metadata enrichment itself.
*/
private fun writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean {
if (lrcContent.isBlank()) return false
try {
val parent = safParentDir(audioUri) ?: run {
android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir")
return false
}
val audioName = try {
DocumentFile.fromSingleUri(this, audioUri)?.name
} catch (_: Exception) {
null
} ?: return false
val baseName = audioName.substringBeforeLast('.', audioName)
val lrcName = "$baseName.lrc"
val target = createOrReuseDocumentFile(
parent,
"application/octet-stream",
lrcName
) ?: run {
android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName")
return false
}
contentResolver.openOutputStream(target.uri, "wt")?.use { output ->
output.write(lrcContent.toByteArray(Charsets.UTF_8))
} ?: return false
android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName")
return true
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}")
return false
}
}
/**
* Extract the audio filename referenced by a CUE sheet file.
* Reads the FILE "name" TYPE line from the .cue text.
@@ -1121,7 +1114,17 @@ class MainActivity: FlutterFragmentActivity() {
}
private val cueSiblingAudioExtensions = listOf(
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
)
// Audio file extensions that the local library scanner accepts. Must stay in
// sync with supportedAudioFormats in go_backend/library_scan.go so that every
// format the Go engine can read (FLAC, M4A/MP4/AAC, MP3, Opus/OGG, APE/WV/MPC,
// WAV, AIFF) is also enumerated here during the SAF folder walk. (.cue is
// handled separately.)
private val libraryScanAudioExtensions = setOf(
".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg",
".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif"
)
private fun getSafChildFileLookup(
@@ -1193,7 +1196,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val supportedAudioExt = libraryScanAudioExtensions
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
@@ -1451,6 +1454,22 @@ class MainActivity: FlutterFragmentActivity() {
* @return JSON object with new/changed files and removed URIs
*/
private fun scanSafTreeIncremental(treeUriStr: String, existingFilesJson: String): String {
val existingFiles = mutableMapOf<String, Long>()
try {
val obj = JSONObject(existingFilesJson)
val keys = obj.keys()
while (keys.hasNext()) {
val key = keys.next()
existingFiles[key] = obj.optLong(key, 0)
}
} catch (_: Exception) {}
return scanSafTreeIncremental(treeUriStr, existingFiles)
}
private fun scanSafTreeIncremental(
treeUriStr: String,
existingFiles: Map<String, Long>,
): String {
if (treeUriStr.isBlank()) {
val result = JSONObject()
result.put("files", JSONArray())
@@ -1470,16 +1489,6 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
val existingFiles = mutableMapOf<String, Long>()
try {
val obj = JSONObject(existingFilesJson)
val keys = obj.keys()
while (keys.hasNext()) {
val key = keys.next()
existingFiles[key] = obj.optLong(key, 0)
}
} catch (_: Exception) {}
resetSafScanProgress()
safScanCancel = false
safScanActive = true
@@ -1487,7 +1496,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val supportedAudioExt = libraryScanAudioExtensions
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
@@ -2155,7 +2164,7 @@ class MainActivity: FlutterFragmentActivity() {
"downloadByStrategy" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
handleSafDownload(requestJson) { json ->
SafDownloadHandler.handle(this@MainActivity, requestJson) { json ->
Gobackend.downloadByStrategy(json)
}
}
@@ -2651,6 +2660,23 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"writeSafSidecarLrc" -> {
val safUri = call.argument<String>("saf_uri") ?: ""
val lyrics = call.argument<String>("lyrics") ?: ""
val response = withContext(Dispatchers.IO) {
try {
val uri = Uri.parse(safUri)
if (writeSafSidecarLrc(uri, lyrics)) {
"""{"success":true}"""
} else {
"""{"success":false,"error":"Failed to write LRC sidecar"}"""
}
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"downloadCoverToFile" -> {
val coverUrl = call.argument<String>("cover_url") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
@@ -2808,6 +2834,9 @@ class MainActivity: FlutterFragmentActivity() {
if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
}
if (obj.optBoolean("write_external_lrc", false)) {
writeSafSidecarLrc(uri, obj.optString("lyrics", ""))
}
raw
} catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {}
@@ -2836,15 +2865,47 @@ class MainActivity: FlutterFragmentActivity() {
"updateDownloadServiceProgress" -> {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val progress = call.argument<Long>("progress") ?: 0L
val total = call.argument<Long>("total") ?: 0L
val queueCount = call.argument<Int>("queue_count") ?: 0
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount)
val progress = (call.argument<Number>("progress") ?: 0).toLong()
val total = (call.argument<Number>("total") ?: 0).toLong()
val queueCount = (call.argument<Number>("queue_count") ?: 0).toInt()
val status = call.argument<String>("status") ?: "downloading"
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount, status)
result.success(null)
}
"isDownloadServiceRunning" -> {
result.success(DownloadService.isServiceRunning())
}
"startNativeDownloadWorker" -> {
val requestsJson = call.argument<String>("requests_json") ?: "[]"
val settingsJson = call.argument<String>("settings_json") ?: "{}"
val requestsPath = call.argument<String>("requests_path") ?: ""
val settingsPath = call.argument<String>("settings_path") ?: ""
if (requestsPath.isNotBlank()) {
DownloadService.startNativeQueueFromFiles(
this@MainActivity,
requestsPath,
settingsPath
)
} else {
DownloadService.startNativeQueue(this@MainActivity, requestsJson, settingsJson)
}
result.success(null)
}
"pauseNativeDownloadWorker" -> {
DownloadService.pauseNativeQueue(this@MainActivity)
result.success(null)
}
"resumeNativeDownloadWorker" -> {
DownloadService.resumeNativeQueue(this@MainActivity)
result.success(null)
}
"cancelNativeDownloadWorker" -> {
DownloadService.cancelNativeQueue(this@MainActivity)
result.success(null)
}
"getNativeDownloadWorkerSnapshot" -> {
result.success(parseJsonPayload(DownloadService.getNativeWorkerSnapshot(this@MainActivity)))
}
"preWarmTrackCache" -> {
val tracksJson = call.argument<String>("tracks") ?: "[]"
withContext(Dispatchers.IO) {
@@ -2864,26 +2925,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"searchTidalAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"searchQobuzAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"getDeezerRelatedArtists" -> {
val artistId = call.argument<String>("artist_id") ?: ""
val limit = call.argument<Int>("limit") ?: 12
@@ -2892,62 +2933,20 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getDeezerMetadata" -> {
"getProviderMetadata" -> {
val providerId = call.argument<String>("provider_id") ?: ""
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getDeezerMetadata(resourceType, resourceId)
}
result.success(response)
}
"getQobuzMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getQobuzMetadata(resourceType, resourceId)
}
result.success(response)
}
"getTidalMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getTidalMetadata(resourceType, resourceId)
}
result.success(response)
}
"parseDeezerUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseDeezerURLExport(url)
}
result.success(response)
}
"parseQobuzUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseQobuzURLExport(url)
}
result.success(response)
}
"parseTidalUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseTidalURLExport(url)
}
result.success(response)
}
"convertTidalToSpotifyDeezer" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.convertTidalToSpotifyDeezer(url)
Gobackend.getProviderMetadataJSON(providerId, resourceType, resourceId)
}
result.success(response)
}
"searchDeezerByISRC" -> {
val isrc = call.argument<String>("isrc") ?: ""
val itemId = call.argument<String>("item_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerByISRC(isrc)
Gobackend.searchDeezerByISRCForItemID(isrc, itemId)
}
result.success(response)
}
@@ -3132,6 +3131,13 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"checkExtensionHealth" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkExtensionHealthJSON(extensionId)
}
result.success(response)
}
"setExtensionSettings" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val settingsJson = call.argument<String>("settings") ?: "{}"
@@ -3165,6 +3171,17 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"findCollectionAcrossExtensions" -> {
val requestJson = call.arguments as? String ?: "{}"
val response: String = withContext(Dispatchers.IO) {
val method = Gobackend::class.java.getMethod(
"findCollectionAcrossExtensionsJSON",
String::class.java
)
method.invoke(null, requestJson) as? String ?: "[]"
}
result.success(response)
}
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
@@ -3259,11 +3276,19 @@ class MainActivity: FlutterFragmentActivity() {
val extensionId = call.argument<String>("extension_id") ?: ""
val query = call.argument<String>("query") ?: ""
val optionsJson = call.argument<String>("options") ?: ""
val requestId = call.argument<String>("request_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.customSearchWithExtensionJSON(extensionId, query, optionsJson)
Gobackend.customSearchWithExtensionJSONWithRequestID(extensionId, query, optionsJson, requestId)
}
result.success(response)
}
"cancelExtensionRequest" -> {
val requestId = call.argument<String>("request_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.cancelExtensionRequestJSON(requestId)
}
result.success(null)
}
"getSearchProviders" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getSearchProvidersJSON()
@@ -3290,30 +3315,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getAlbumWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val albumId = call.argument<String>("album_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAlbumWithExtensionJSON(extensionId, albumId)
}
result.success(response)
}
"getPlaylistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val playlistId = call.argument<String>("playlist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId)
}
result.success(response)
}
"getArtistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val artistId = call.argument<String>("artist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getArtistWithExtensionJSON(extensionId, artistId)
}
result.success(response)
}
"runPostProcessing" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata") ?: ""
@@ -3420,8 +3421,9 @@ class MainActivity: FlutterFragmentActivity() {
}
"getExtensionHomeFeed" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val requestId = call.argument<String>("request_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionHomeFeedJSON(extensionId)
Gobackend.getExtensionHomeFeedJSONWithRequestID(extensionId, requestId)
}
result.success(response)
}
@@ -3443,7 +3445,7 @@ class MainActivity: FlutterFragmentActivity() {
val folderPath = call.argument<String>("folder_path") ?: ""
val response = withContext(Dispatchers.IO) {
safScanActive = false
Gobackend.scanLibraryFolderJSON(folderPath)
bridgeJsonResult(Gobackend.scanLibraryFolderJSON(folderPath))
}
result.success(response)
}
@@ -3452,7 +3454,9 @@ class MainActivity: FlutterFragmentActivity() {
val existingFiles = call.argument<String>("existing_files") ?: "{}"
val response = withContext(Dispatchers.IO) {
safScanActive = false
Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles)
bridgeJsonResult(
Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles)
)
}
result.success(response)
}
@@ -3461,9 +3465,11 @@ class MainActivity: FlutterFragmentActivity() {
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
val response = withContext(Dispatchers.IO) {
safScanActive = false
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
folderPath,
snapshotPath,
bridgeJsonResult(
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
folderPath,
snapshotPath,
)
)
}
result.success(response)
@@ -3471,7 +3477,7 @@ class MainActivity: FlutterFragmentActivity() {
"scanSafTree" -> {
val treeUri = call.argument<String>("tree_uri") ?: ""
val response = withContext(Dispatchers.IO) {
scanSafTree(treeUri)
bridgeJsonResult(scanSafTree(treeUri))
}
result.success(response)
}
@@ -3479,7 +3485,7 @@ class MainActivity: FlutterFragmentActivity() {
val treeUri = call.argument<String>("tree_uri") ?: ""
val existingFiles = call.argument<String>("existing_files") ?: "{}"
val response = withContext(Dispatchers.IO) {
scanSafTreeIncremental(treeUri, existingFiles)
bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles))
}
result.success(response)
}
@@ -3487,9 +3493,9 @@ class MainActivity: FlutterFragmentActivity() {
val treeUri = call.argument<String>("tree_uri") ?: ""
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
val response = withContext(Dispatchers.IO) {
val existingFilesJson =
loadExistingFilesJsonFromSnapshot(snapshotPath)
scanSafTreeIncremental(treeUri, existingFilesJson)
val existingFiles =
loadExistingFilesFromSnapshot(snapshotPath)
bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles))
}
result.success(response)
}
@@ -3561,7 +3567,7 @@ class MainActivity: FlutterFragmentActivity() {
} catch (_: Exception) { "" }
val cueBaseName = cueName.substringBeforeLast('.')
if (cueBaseName.isNotBlank()) {
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
File diff suppressed because it is too large Load Diff
@@ -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.8 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.8 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.6 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.7 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: 8.1 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: 954 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 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,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>
+4
View File
@@ -1,2 +1,6 @@
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-9.4.1-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" 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")
+7 -7
View File
@@ -1,18 +1,18 @@
{
"name": "SpotiFLAC Source",
"name": "SpotiFLAC Mobile Source",
"identifier": "com.zarzet.spotiflac.source",
"subtitle": "FLAC Downloader for iOS",
"apps": [
{
"name": "SpotiFLAC",
"name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.3.1",
"versionDate": "2026-04-14",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.3.1/SpotiFLAC-v4.3.1-ios-unsigned.ipa",
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"version": "4.6.0",
"versionDate": "2026-06-13",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.6.0/SpotiFLAC-v4.6.0-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": 34773598
"size": 34347687
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Before

Width:  |  Height:  |  Size: 539 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Before

Width:  |  Height:  |  Size: 811 KiB

After

Width:  |  Height:  |  Size: 811 KiB

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+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
-4
View File
@@ -56,7 +56,6 @@ func ReadAPETags(filePath string) (*APETag, error) {
return nil, fmt.Errorf("file too small for APE tag")
}
// Try to find APE tag footer at the end of file.
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
if err == nil {
@@ -255,7 +254,6 @@ func findExistingAPETagSize(filePath string) (int64, error) {
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
// Check if there's also a header (tagSize only covers items + footer)
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
totalSize := tagSize
if hasHeader {
@@ -511,7 +509,6 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
// deletion: the caller sends an empty value which is not serialized into
// newItems, but the old value must still be dropped.
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
// Build a set of keys being updated (upper-case for case-insensitive match)
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
for k := range overrideKeys {
combined[strings.ToUpper(k)] = struct{}{}
@@ -539,7 +536,6 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
return nil, fmt.Errorf("file too small for APE tag")
}
// Try footer at end of file
footer := make([]byte, apeTagHeaderSize)
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
return nil, fmt.Errorf("failed to read APE footer: %w", err)
+121
View File
@@ -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")
}
}
+3
View File
@@ -1624,6 +1624,9 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
}
return data, mimeType, nil
case ".wav", ".aiff", ".aif", ".aifc":
return extractWAVAIFFCover(filePath)
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
}
@@ -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()
}
+91 -1
View File
@@ -9,15 +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 {
@@ -37,6 +45,7 @@ func initDownloadCancel(itemID string) context.Context {
entry.cancel()
}
}
entry.refs++
return entry.ctx
}
@@ -45,6 +54,7 @@ func initDownloadCancel(itemID string) context.Context {
ctx: ctx,
cancel: cancel,
canceled: false,
refs: 1,
}
return ctx
}
@@ -87,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()
}
+3 -1
View File
@@ -19,6 +19,8 @@ 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)
@@ -135,7 +137,7 @@ func upgradeQobuzCover(coverURL string) string {
return coverURL
}
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
upgraded := qobuzSizeRegex.ReplaceAllString(coverURL, "_max.jpg")
if upgraded != coverURL {
GoLog("[Cover] Qobuz: upgraded to max resolution")
}
+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")
}
}
+1 -1
View File
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
}
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
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 {
+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()
}
+842 -881
View File
File diff suppressed because it is too large Load Diff
@@ -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)
}
}
+177
View File
@@ -2,6 +2,7 @@ package gobackend
import (
"context"
"fmt"
"testing"
)
@@ -176,6 +177,98 @@ func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) {
}
}
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
@@ -314,6 +407,90 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
}
}
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
AlbumName: "Album Name",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "wrong-rich-metadata",
Name: "Different Song",
Artists: "Different Artist",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "2024-03-09",
TrackNumber: 4,
DiscNumber: 1,
ISRC: "WRONG1234567",
ProviderID: "deezer",
},
}
if best := selectBestReEnrichTrack(req, tracks); best != nil {
t.Fatalf("selected track = %q, want no match", best.ID)
}
}
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
ISRC: "USRC17607839",
DurationMs: 999999000,
}
tracks := []ExtTrackMetadata{
{
ID: "same-isrc",
Name: "Different Song",
Artists: "Different Artist",
DurationMS: 180000,
ISRC: "USRC17607839",
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected exact ISRC candidate to be selected")
}
if best.ID != "same-isrc" {
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
}
}
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
req := reEnrichRequest{
TrackName: "Unknown Title",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "album-match",
Name: "Sign of the Times",
Artists: "Harry Styles",
AlbumName: "Harry Styles",
DurationMS: 180000,
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
}
if best.ID != "album-match" {
t.Fatalf("selected track = %q, want album-match", best.ID)
}
}
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song",
+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")
}
}
+188 -73
View File
@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
@@ -117,7 +118,11 @@ func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
}
type extensionManager struct {
mu sync.RWMutex
mu sync.RWMutex
// mutationMu serializes install/upgrade/remove (heavy FS + goja VM
// teardown/reload), which are not safe to run concurrently. Acquired before
// m.mu; "*Locked" helpers assume it is held.
mutationMu sync.Mutex
extensions map[string]*loadedExtension
extensionsDir string
dataDir string
@@ -155,13 +160,19 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
}
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
return m.loadExtensionFromFileLocked(filePath)
}
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
}
zipReader, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package")
}
defer zipReader.Close()
@@ -186,16 +197,16 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
}
if manifestData == nil {
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
}
if !hasIndexJS {
return nil, fmt.Errorf("Invalid extension package: index.js not found")
return nil, fmt.Errorf("invalid extension package: index.js not found")
}
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
return nil, fmt.Errorf("invalid extension manifest: %w", err)
}
m.mu.RLock()
@@ -211,11 +222,11 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
if exists {
versionCompare := compareVersions(manifest.Version, existingVersion)
if versionCompare > 0 {
return m.UpgradeExtension(filePath)
return m.upgradeExtensionLocked(filePath)
} else if versionCompare == 0 {
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
} else {
return nil, fmt.Errorf("Cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
return nil, fmt.Errorf("cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
}
}
@@ -223,7 +234,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
defer m.mu.Unlock()
if _, exists := m.extensions[manifest.Name]; exists {
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
return nil, fmt.Errorf("extension '%s' was installed by another process", manifest.DisplayName)
}
extDir := filepath.Join(m.extensionsDir, manifest.Name)
@@ -342,23 +353,90 @@ func initializeVMLocked(ext *loadedExtension) error {
return nil
}
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
vm := goja.New()
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
}
runtime := &extensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
cookieJar: nil,
dataDir: ext.DataDir,
vm: vm,
storageFlushDelay: defaultStorageFlushDelay,
}
if ext.runtime != nil && ext.runtime.cookieJar != nil {
runtime.cookieJar = ext.runtime.cookieJar
} else {
jar, _ := newSimpleCookieJar()
runtime.cookieJar = jar
}
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
GoLog("[Extension:%s] %v\n", ext.ID, args)
return goja.Undefined()
})
vm.Set("console", console)
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
})
if _, err := vm.RunString(string(jsCode)); err != nil {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
}
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
}
settings := getExtensionInitSettings(ext.ID)
if len(settings) > 0 {
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
runtime.closeStorageFlusher()
return nil, nil, err
}
}
return vm, runtime, nil
}
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return initializeVMLocked(ext)
}
func initializeExtensionWithSettingsLocked(
ext *loadedExtension,
func initializeExtensionRuntimeWithSettings(
vm *goja.Runtime,
extensionID string,
settings map[string]interface{},
) error {
if ext.VM == nil {
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
}
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("Failed to save settings")
return fmt.Errorf("failed to save settings")
}
script := fmt.Sprintf(`
@@ -376,11 +454,9 @@ func initializeExtensionWithSettingsLocked(
})()
`, string(settingsJSON))
result, err := ext.VM.RunString(script)
result, err := vm.RunString(script)
if err != nil {
ext.Error = fmt.Sprintf("initialize failed: %v", err)
ext.Enabled = false
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
return err
}
@@ -392,14 +468,29 @@ func initializeExtensionWithSettingsLocked(
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
ext.Error = errMsg
ext.Enabled = false
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
return nil
}
func initializeExtensionWithSettingsLocked(
ext *loadedExtension,
settings map[string]interface{},
) error {
if ext.VM == nil {
return fmt.Errorf("extension failed to load: please reinstall the extension")
}
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
ext.Error = err.Error()
ext.Enabled = false
return err
}
ext.initialized = true
GoLog("[Extension] Initialized %s\n", ext.ID)
return nil
@@ -407,45 +498,56 @@ func initializeExtensionWithSettingsLocked(
func runCleanupLocked(ext *loadedExtension) error {
if ext.VM != nil {
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := ext.VM.RunString(script)
if err != nil {
if err := runCleanupOnVM(ext.VM); err != nil {
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
if ext.VM.Get("extension") != nil {
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
}
}
return nil
}
func runCleanupOnVM(vm *goja.Runtime) error {
if vm == nil {
return nil
}
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := vm.RunString(script)
if err != nil {
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
return nil
}
func teardownVMLocked(ext *loadedExtension) {
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
@@ -478,7 +580,7 @@ func (m *extensionManager) UnloadExtension(extensionID string) error {
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("Extension not found")
return fmt.Errorf("extension not found")
}
ext.VMMu.Lock()
@@ -497,7 +599,7 @@ func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, e
ext, exists := m.extensions[extensionID]
if !exists {
return nil, fmt.Errorf("Extension not found")
return nil, fmt.Errorf("extension not found")
}
return ext, nil
}
@@ -519,7 +621,7 @@ func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool)
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("Extension not found")
return fmt.Errorf("extension not found")
}
if enabled {
@@ -597,12 +699,12 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
return nil, fmt.Errorf("invalid extension manifest: %w", err)
}
indexPath := filepath.Join(dirPath, "index.js")
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
return nil, fmt.Errorf("Extension is missing index.js file")
return nil, fmt.Errorf("extension is missing index.js file")
}
if existing, exists := m.extensions[manifest.Name]; exists {
@@ -644,6 +746,9 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
}
func (m *extensionManager) RemoveExtension(extensionID string) error {
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
ext, err := m.GetExtension(extensionID)
if err != nil {
return err
@@ -664,13 +769,19 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
// Only allows upgrades (new version > current version), not downgrades
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
return m.upgradeExtensionLocked(filePath)
}
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
}
zipReader, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package")
}
defer zipReader.Close()
@@ -695,16 +806,16 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
}
if manifestData == nil {
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
}
if !hasIndexJS {
return nil, fmt.Errorf("Invalid extension package: index.js not found")
return nil, fmt.Errorf("invalid extension package: index.js not found")
}
newManifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
return nil, fmt.Errorf("invalid extension manifest: %w", err)
}
m.mu.RLock()
@@ -712,15 +823,15 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
m.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
return nil, fmt.Errorf("extension '%s' is not installed; use install instead of upgrade", newManifest.DisplayName)
}
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
if versionCompare < 0 {
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
return nil, fmt.Errorf("cannot downgrade extension: current version: %s, new version: %s", existing.Manifest.Version, newManifest.Version)
}
if versionCompare == 0 {
return nil, fmt.Errorf("Extension is already at version %s", existing.Manifest.Version)
return nil, fmt.Errorf("extension is already at version %s", existing.Manifest.Version)
}
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
@@ -814,13 +925,13 @@ type ExtensionUpgradeInfo struct {
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
}
zipReader, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("Cannot open extension file")
return nil, fmt.Errorf("cannot open extension file")
}
defer zipReader.Close()
@@ -847,7 +958,7 @@ func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
newManifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid manifest: %w", err)
return nil, fmt.Errorf("invalid manifest: %w", err)
}
m.mu.RLock()
@@ -908,9 +1019,11 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
HasLyricsProvider bool `json:"has_lyrics_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SkipLyrics bool `json:"skip_lyrics"`
StopProviderFallback bool `json:"stop_provider_fallback"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"service_health,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
@@ -965,9 +1078,11 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
SkipLyrics: ext.Manifest.SkipLyrics,
StopProviderFallback: ext.Manifest.StopsProviderFallback(),
SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching,
PostProcessing: ext.Manifest.PostProcessing,
ServiceHealth: ext.Manifest.ServiceHealth,
Capabilities: ext.Manifest.Capabilities,
}
}
@@ -986,7 +1101,7 @@ func (m *extensionManager) InitializeExtension(extensionID string, settings map[
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("Extension not found")
return fmt.Errorf("extension not found")
}
ext.VMMu.Lock()
@@ -1004,7 +1119,7 @@ func (m *extensionManager) CleanupExtension(extensionID string) error {
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("Extension not found")
return fmt.Errorf("extension not found")
}
if ext.VM == nil {
@@ -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")
}
}
+46 -3
View File
@@ -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,6 +102,17 @@ 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"`
@@ -115,11 +127,13 @@ type ExtensionManifest struct {
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
@@ -202,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 +262,13 @@ 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
+582 -46
View File
@@ -1,33 +1,39 @@
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 TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
func TestSetMetadataProviderPriorityStripsRetiredBuiltIns(t *testing.T) {
original := GetMetadataProviderPriority()
defer SetMetadataProviderPriority(original)
SetMetadataProviderPriority([]string{"tidal"})
SetMetadataProviderPriority([]string{"qobuz"})
got := GetMetadataProviderPriority()
want := []string{"tidal", "qobuz"}
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)
}
if len(got) != 0 {
t.Fatalf("expected retired built-in qobuz to be stripped, got %v", got)
}
}
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
func TestSetExtensionFallbackProviderIDsDedupesExtensions(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
SetExtensionFallbackProviderIDs([]string{"ext-a", "ext-a", " ext-b "})
got := GetExtensionFallbackProviderIDs()
want := []string{"ext-a", "ext-b"}
@@ -50,9 +56,6 @@ func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
if !isExtensionFallbackAllowed("custom-ext") {
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
}
if !isExtensionFallbackAllowed("qobuz") {
t.Fatal("expected built-in provider to remain allowed")
}
}
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
@@ -79,7 +82,7 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
got := GetProviderPriority()
want := []string{"qobuz", "custom-ext", "tidal"}
want := []string{"custom-ext"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
@@ -90,6 +93,125 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
}
}
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
original := GetProviderPriority()
defer SetProviderPriority(original)
manager := getExtensionManager()
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
ext.ID = "deezer"
ext.Manifest.Name = "deezer"
manager.mu.Lock()
previous, hadPrevious := manager.extensions[ext.ID]
manager.extensions[ext.ID] = ext
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadPrevious {
manager.extensions[ext.ID] = previous
} else {
delete(manager.extensions, ext.ID)
}
manager.mu.Unlock()
}()
SetProviderPriority([]string{"deezer", "custom-ext"})
got := GetProviderPriority()
want := []string{"deezer", "custom-ext"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
}
}
}
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
manager := getExtensionManager()
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
amazon.ID = "amazon"
amazon.Manifest.Name = "amazon"
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "://bad",
Required: true,
}}
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
plain.ID = "plain"
plain.Manifest.Name = "plain"
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
deezer.ID = "deezer"
deezer.Manifest.Name = "deezer"
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "https://example.test/health",
}}
manager.mu.Lock()
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
previousPlain, hadPlain := manager.extensions[plain.ID]
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
manager.extensions[amazon.ID] = amazon
manager.extensions[plain.ID] = plain
manager.extensions[deezer.ID] = deezer
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadAmazon {
manager.extensions[amazon.ID] = previousAmazon
} else {
delete(manager.extensions, amazon.ID)
}
if hadPlain {
manager.extensions[plain.ID] = previousPlain
} else {
delete(manager.extensions, plain.ID)
}
if hadDeezer {
manager.extensions[deezer.ID] = previousDeezer
} else {
delete(manager.extensions, deezer.ID)
}
manager.mu.Unlock()
extensionHealthCacheMu.Lock()
delete(extensionHealthCache, deezer.ID)
extensionHealthCacheMu.Unlock()
}()
extensionHealthCacheMu.Lock()
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
result: ExtensionHealthResult{
ExtensionID: deezer.ID,
Status: "online",
CheckedAt: time.Now().UTC().Format(time.RFC3339),
},
expiresAt: time.Now().Add(time.Minute),
}
extensionHealthCacheMu.Unlock()
got := prioritizeFallbackProvidersByHealth(
[]string{"amazon", "plain", "deezer"},
manager,
"",
)
want := []string{"deezer", "plain"}
if len(got) != len(want) {
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
}
}
}
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
if normalized == nil {
@@ -123,6 +245,110 @@ func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
}
}
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)
@@ -180,6 +406,102 @@ func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
}
}
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 {
@@ -207,46 +529,260 @@ func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
}
}
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) {
originalPriority := GetMetadataProviderPriority()
originalSearch := searchBuiltInMetadataTracksFunc
defer func() {
SetMetadataProviderPriority(originalPriority)
searchBuiltInMetadataTracksFunc = originalSearch
}()
SetMetadataProviderPriority([]string{"qobuz", "tidal"})
var calls []string
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
calls = append(calls, providerID)
switch providerID {
case "qobuz":
return []ExtTrackMetadata{
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
}, nil
case "tidal":
return []ExtTrackMetadata{
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
}, nil
default:
return nil, nil
}
}
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) != 2 {
t.Fatalf("unexpected track count: got %d want 2", len(tracks))
}
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" {
t.Fatalf("unexpected track provider order: %+v", tracks)
}
if len(calls) != 2 || calls[0] != "qobuz" || calls[1] != "tidal" {
t.Fatalf("unexpected provider call order: %v", calls)
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)
}
}
+41 -7
View File
@@ -94,6 +94,9 @@ type extensionRuntime struct {
activeDownloadMu sync.RWMutex
activeDownloadItemID string
activeRequestMu sync.RWMutex
activeRequestID string
storageMu sync.RWMutex
storageCache map[string]interface{}
storageLoaded bool
@@ -137,8 +140,8 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
storageFlushDelay: defaultStorageFlushDelay,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second))
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false)
return runtime
}
@@ -209,6 +212,24 @@ func (r *extensionRuntime) getActiveDownloadItemID() string {
return r.activeDownloadItemID
}
func (r *extensionRuntime) setActiveRequestID(requestID string) {
r.activeRequestMu.Lock()
defer r.activeRequestMu.Unlock()
r.activeRequestID = strings.TrimSpace(requestID)
}
func (r *extensionRuntime) clearActiveRequestID() {
r.activeRequestMu.Lock()
defer r.activeRequestMu.Unlock()
r.activeRequestID = ""
}
func (r *extensionRuntime) getActiveRequestID() string {
r.activeRequestMu.RLock()
defer r.activeRequestMu.RUnlock()
return r.activeRequestID
}
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
if req == nil {
return nil
@@ -216,24 +237,34 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re
itemID := r.getActiveDownloadItemID()
if itemID == "" {
return req
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) *http.Client {
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.
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
// 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: sharedTransport,
Transport: transport,
Timeout: timeout,
Jar: jar,
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
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")
}
@@ -473,12 +504,15 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
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()
+1 -1
View File
@@ -461,7 +461,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
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 {
+206 -30
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/dop251/goja"
//lint:ignore SA1019 Blowfish is required for legacy extension crypto compatibility.
"golang.org/x/crypto/blowfish"
)
@@ -157,6 +158,11 @@ func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
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 {
@@ -278,7 +284,10 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
"error": err.Error(),
})
}
if parsedOptions.Mode != "cbc" {
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),
@@ -302,37 +311,49 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
}
if len(parsedOptions.IV) != block.BlockSize() {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
})
}
data := inputData
if !decrypt && parsedOptions.Padding == "pkcs7" {
data = applyPKCS7Padding(data, block.BlockSize())
}
if len(data)%block.BlockSize() != 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
})
}
output := make([]byte, len(data))
if decrypt {
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
if parsedOptions.Padding == "pkcs7" {
output, err = removePKCS7Padding(output, block.BlockSize())
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
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 {
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
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)
@@ -357,3 +378,158 @@ func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value
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,
})
}
+300
View File
@@ -183,3 +183,303 @@ func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
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)
}
}
+1
View File
@@ -131,6 +131,7 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
"sample_rate": quality.SampleRate,
"total_samples": quality.TotalSamples,
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
"codec": quality.Codec,
})
}
+313 -12
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
@@ -134,6 +135,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
var onProgress goja.Callable
var headers map[string]string
var chunkedDownload bool
trackItemBytes := true
var chunkSize int64
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
optionsObj := call.Arguments[2].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
@@ -148,9 +152,39 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
onProgress = callable
}
}
if trackBytes, ok := opts["trackItemBytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
} else if trackBytes, ok := opts["track_item_bytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
}
if chunked, ok := opts["chunked"]; ok {
switch v := chunked.(type) {
case bool:
chunkedDownload = v
case int64:
if v > 0 {
chunkedDownload = true
chunkSize = v
}
case float64:
if v > 0 {
chunkedDownload = true
chunkSize = int64(v)
}
}
}
}
}
// Default chunk size: 1MB (YouTube CDN max without poToken)
if chunkedDownload && chunkSize <= 0 {
chunkSize = 1024 * 1024
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -159,6 +193,20 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
client := r.downloadClient
if client == nil {
client = r.httpClient
}
ua := appUserAgent()
if h, ok := headers["User-Agent"]; ok && h != "" {
ua = h
}
if chunkedDownload {
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
}
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -172,12 +220,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
}
client := r.downloadClient
if client == nil {
client = r.httpClient
req.Header.Set("User-Agent", appUserAgent())
}
resp, err := client.Do(req)
@@ -189,7 +232,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
@@ -205,14 +248,19 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
defer out.Close()
contentLength := resp.ContentLength
activeItemID := r.getActiveDownloadItemID()
if activeItemID != "" && contentLength > 0 {
if activeItemID != "" {
SetItemDownloading(activeItemID)
}
contentLength := resp.ContentLength
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && contentLength > 0 {
SetItemBytesTotal(activeItemID, contentLength)
}
var progressWriter interface{ Write([]byte) (int, error) } = out
if activeItemID != "" {
if shouldTrackItemBytes {
progressWriter = NewItemProgressWriter(out, activeItemID)
}
@@ -263,6 +311,14 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
}
if shouldTrackItemBytes {
if contentLength > 0 {
SetItemProgress(activeItemID, float64(written)/float64(contentLength), written, contentLength)
} else if written > 0 {
SetItemBytesReceived(activeItemID, written)
}
}
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
return r.vm.ToValue(map[string]interface{}{
@@ -272,6 +328,239 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// fileDownloadChunked downloads a URL using sequential Range requests.
// This is needed for servers (like YouTube's googlevideo CDN) that reject
// non-ranged or large-range requests with 403 and require small chunk downloads.
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
// First, get the total content length with a small probe request
probeReq, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: probe request error: %v", err),
})
}
probeReq = r.bindDownloadCancelContext(probeReq)
probeReq.Header.Set("User-Agent", ua)
for k, v := range headers {
if k != "Range" { // Don't copy any existing Range header
probeReq.Header.Set(k, v)
}
}
probeReq.Header.Set("Range", "bytes=0-1")
probeResp, err := client.Do(probeReq)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: probe error: %v", err),
})
}
io.Copy(io.Discard, probeResp.Body)
probeResp.Body.Close()
if probeResp.StatusCode != 206 && probeResp.StatusCode != 200 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: probe HTTP %d", probeResp.StatusCode),
})
}
// Parse Content-Range to get total size: "bytes 0-1/TOTAL"
var totalSize int64
contentRange := probeResp.Header.Get("Content-Range")
if contentRange != "" {
// Format: "bytes 0-1/12345"
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
sizeStr := contentRange[idx+1:]
if sizeStr != "*" {
fmt.Sscanf(sizeStr, "%d", &totalSize)
}
}
}
if totalSize <= 0 {
// Fallback: try Content-Length from a HEAD-like approach
// If we can't determine size, download with unknown size
GoLog("[Extension:%s] Chunked download: unknown total size, will download until server says done\n", r.extensionID)
} else {
GoLog("[Extension:%s] Chunked download: total size %d bytes, chunk size %d\n", r.extensionID, totalSize, chunkSize)
}
out, err := os.Create(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create file: %v", err),
})
}
defer out.Close()
activeItemID := r.getActiveDownloadItemID()
if activeItemID != "" {
SetItemDownloading(activeItemID)
}
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && totalSize > 0 {
SetItemBytesTotal(activeItemID, totalSize)
}
var progressWriter interface{ Write([]byte) (int, error) } = out
if shouldTrackItemBytes {
progressWriter = NewItemProgressWriter(out, activeItemID)
}
var totalWritten int64
buf := make([]byte, 32*1024)
maxRetries := 3
for offset := int64(0); totalSize <= 0 || offset < totalSize; {
end := offset + chunkSize - 1
if totalSize > 0 && end >= totalSize {
end = totalSize - 1
}
var chunkResp *http.Response
var chunkErr error
for retry := 0; retry < maxRetries; retry++ {
chunkReq, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: request error at offset %d: %v", offset, err),
})
}
chunkReq = r.bindDownloadCancelContext(chunkReq)
chunkReq.Header.Set("User-Agent", ua)
for k, v := range headers {
if k != "Range" {
chunkReq.Header.Set(k, v)
}
}
chunkReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, end))
chunkResp, chunkErr = client.Do(chunkReq)
if chunkErr != nil {
if retry < maxRetries-1 {
time.Sleep(time.Duration(retry+1) * time.Second)
continue
}
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: error at offset %d after %d retries: %v", offset, maxRetries, chunkErr),
})
}
if chunkResp.StatusCode == 206 || chunkResp.StatusCode == 200 {
break // Success
}
// Non-success status
io.Copy(io.Discard, chunkResp.Body)
chunkResp.Body.Close()
if chunkResp.StatusCode == 403 || chunkResp.StatusCode == 429 {
if retry < maxRetries-1 {
time.Sleep(time.Duration(retry+1) * 2 * time.Second)
continue
}
}
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("chunked: HTTP %d at offset %d", chunkResp.StatusCode, offset),
})
}
// Read chunk body and write to file
chunkWritten := int64(0)
for {
nr, er := chunkResp.Body.Read(buf)
if nr > 0 {
nw, ew := progressWriter.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
ew = fmt.Errorf("invalid write result")
}
}
chunkWritten += int64(nw)
totalWritten += int64(nw)
if ew != nil {
chunkResp.Body.Close()
if ew == ErrDownloadCancelled {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "download cancelled",
})
}
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to write file: %v", ew),
})
}
if nr != nw {
chunkResp.Body.Close()
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "short write",
})
}
if onProgress != nil && totalSize > 0 {
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize))
}
}
if er != nil {
if er != io.EOF {
chunkResp.Body.Close()
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read chunk at offset %d: %v", offset, er),
})
}
break
}
}
chunkResp.Body.Close()
offset += chunkWritten
// If server returned 200 (full content) instead of 206, we're done
if chunkResp.StatusCode == 200 {
break
}
// If we got less data than expected and we know total size, check if done
if totalSize > 0 && offset >= totalSize {
break
}
// Unknown size: if we got less than chunk size, assume done
if totalSize <= 0 && chunkWritten < chunkSize {
break
}
}
if shouldTrackItemBytes {
if totalSize > 0 {
SetItemProgress(activeItemID, float64(totalWritten)/float64(totalSize), totalWritten, totalSize)
} else if totalWritten > 0 {
SetItemBytesReceived(activeItemID, totalWritten)
}
}
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullPath,
"size": totalWritten,
})
}
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
@@ -374,7 +663,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
"error": "offset must be >= 0",
})
}
file, err := os.Open(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -427,6 +715,20 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
}
}
if strings.EqualFold(strings.TrimSpace(encoding), "bytes") ||
strings.EqualFold(strings.TrimSpace(encoding), "raw") {
// Return raw bytes as an ArrayBuffer to avoid base64 encode/decode of
// large payloads under the goja interpreter.
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": r.vm.NewArrayBuffer(data),
"bytes_read": len(data),
"offset": offset,
"size": size,
"eof": offset+int64(len(data)) >= size,
})
}
encoded, err := encodeRuntimeBytes(data, encoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -444,7 +746,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
"eof": offset+int64(len(data)) >= size,
})
}
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
+24 -5
View File
@@ -17,6 +17,24 @@ type HTTPResponse struct {
Headers map[string]string `json:"headers"`
}
const maxExtensionHTTPResponseBytes = 16 << 20
func readExtensionHTTPResponseBody(resp *http.Response) ([]byte, error) {
body, err := io.ReadAll(
io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes+1),
)
if err != nil {
return nil, err
}
if len(body) > maxExtensionHTTPResponseBytes {
return nil, fmt.Errorf(
"response body exceeds %d byte limit; use file.download for large media",
maxExtensionHTTPResponseBytes,
)
}
return body, nil
}
func (r *extensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
@@ -26,7 +44,8 @@ func (r *extensionRuntime) validateDomain(urlStr string) error {
if parsed.Scheme == "" {
return fmt.Errorf("invalid URL: scheme is required")
}
if parsed.Scheme != "https" {
if parsed.Scheme != "https" &&
!(parsed.Scheme == "http" && r.manifest.Permissions.AllowHTTP) {
return fmt.Errorf("network access denied: only https is allowed")
}
if parsed.User != nil {
@@ -99,7 +118,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
@@ -197,7 +216,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
@@ -307,7 +326,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
@@ -433,7 +452,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
+1 -1
View File
@@ -75,7 +75,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
req.Header.Set("User-Agent", appUserAgent())
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
-10
View File
@@ -340,16 +340,6 @@ func (r *extensionRuntime) ensureCredentialsLoaded() error {
return nil
}
func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
if err := r.ensureCredentialsLoaded(); err != nil {
return nil, err
}
r.credentialsMu.RLock()
defer r.credentialsMu.RUnlock()
return cloneInterfaceMap(r.credentialsCache), nil
}
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
data, err := json.Marshal(creds)
if err != nil {
@@ -0,0 +1,747 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/dop251/goja"
)
func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
vm := goja.New()
runtime := &extensionRuntime{
extensionID: "auth-ext",
manifest: &ExtensionManifest{
Name: "auth-ext",
Description: "Auth extension",
Version: "1.0.0",
Permissions: ExtensionPermissions{
Network: []string{"auth.example.com", "token.example.com", "api.example.com"},
},
},
settings: map[string]interface{}{},
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "token.example.com":
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"access_token":"access","refresh_token":"refresh","expires_in":3600}`)),
Request: req,
}, nil
case "api.example.com":
return &http.Response{
StatusCode: 200,
Header: http.Header{"X-Test": []string{"yes"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"items":[1,2]}`)),
Request: req,
}, nil
default:
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})},
vm: vm,
}
if err := validateExtensionAuthURL("https://user:pass@auth.example.com/login"); err == nil {
t.Fatal("expected embedded credential error")
}
if err := validateExtensionAuthURL("http://auth.example.com/login"); err == nil {
t.Fatal("expected non-https auth URL error")
}
if got := summarizeURLForLog("https://auth.example.com/login?token=secret"); got != "https://auth.example.com/login" {
t.Fatalf("summary = %q", got)
}
openResult := runtime.authOpenUrl(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("https://auth.example.com/login"),
vm.ToValue("app://callback"),
}}).Export().(map[string]interface{})
if openResult["success"] != true {
t.Fatalf("authOpenUrl = %#v", openResult)
}
if pending := GetPendingAuthRequest("auth-ext"); pending == nil || pending.AuthURL == "" {
t.Fatalf("pending auth = %#v", pending)
}
if code := runtime.authGetCode(goja.FunctionCall{}); !goja.IsUndefined(code) {
t.Fatalf("expected undefined code, got %v", code)
}
if ok := runtime.authSetCode(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"code": "abc", "access_token": "access", "refresh_token": "refresh", "expires_in": float64(60)})}}); !ok.ToBoolean() {
t.Fatal("authSetCode returned false")
}
if code := runtime.authGetCode(goja.FunctionCall{}).String(); code != "abc" {
t.Fatalf("code = %q", code)
}
if !runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
t.Fatal("expected authenticated runtime")
}
tokens := runtime.authGetTokens(goja.FunctionCall{}).Export().(map[string]interface{})
if tokens["access_token"] != "access" {
t.Fatalf("tokens = %#v", tokens)
}
pkce := runtime.authGeneratePKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(50))}}).Export().(map[string]interface{})
if pkce["method"] != "S256" || pkce["verifier"] == "" || pkce["challenge"] == "" {
t.Fatalf("pkce = %#v", pkce)
}
if current := runtime.authGetPKCE(goja.FunctionCall{}).Export().(map[string]interface{}); current["verifier"] == "" {
t.Fatalf("current pkce = %#v", current)
}
oauthConfig := map[string]interface{}{
"authUrl": "https://auth.example.com/oauth",
"clientId": "client",
"redirectUri": "app://callback",
"scope": "read",
"extraParams": map[string]interface{}{"prompt": "login"},
}
oauth := runtime.authStartOAuthWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(oauthConfig)}}).Export().(map[string]interface{})
if oauth["success"] != true || !strings.Contains(oauth["authUrl"].(string), "code_challenge") {
t.Fatalf("oauth = %#v", oauth)
}
tokenConfig := map[string]interface{}{
"tokenUrl": "https://token.example.com/token",
"clientId": "client",
"redirectUri": "app://callback",
"code": "abc",
}
token := runtime.authExchangeCodeWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(tokenConfig)}}).Export().(map[string]interface{})
if token["success"] != true || token["access_token"] != "access" {
t.Fatalf("token = %#v", token)
}
runtime.registerTextEncoderDecoder(vm)
runtime.registerURLClass(vm)
runtime.registerJSONGlobal(vm)
vm.Set("fetch", func(call goja.FunctionCall) goja.Value {
return runtime.fetchPolyfill(call)
})
vm.Set("atob", func(call goja.FunctionCall) goja.Value {
return runtime.atobPolyfill(call)
})
vm.Set("btoa", func(call goja.FunctionCall) goja.Value {
return runtime.btoaPolyfill(call)
})
value, err := vm.RunString(`
var encoded = btoa("hello");
var decoded = atob(encoded);
var te = new TextEncoder();
var bytes = te.encode("hi");
var into = te.encodeInto("hi", []);
var td = new TextDecoder();
var text = td.decode(bytes);
var url = new URL("/path?a=1&a=2#frag", "https://api.example.com/base");
var params = new URLSearchParams("?x=1");
params.append("x", "2");
params.set("y", "3");
var response = fetch("https://api.example.com/data", {method: "POST", body: {q: "x"}, headers: {"X-Client": "test"}});
JSON.stringify({
encoded: encoded,
decoded: decoded,
text: text,
read: into.read,
host: url.hostname,
first: url.searchParams.get("a"),
all: url.searchParams.getAll("a").length,
params: params.toString(),
ok: response.ok,
status: response.status,
jsonOk: response.json().ok,
bufferLen: response.arrayBuffer().length
});
`)
if err != nil {
t.Fatalf("polyfill script: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(value.String()), &result); err != nil {
t.Fatalf("decode polyfill result: %v", err)
}
if result["decoded"] != "hello" || result["host"] != "api.example.com" || result["ok"] != true {
t.Fatalf("polyfill result = %#v", result)
}
blocked := runtime.fetchPolyfill(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://blocked.example.com")}}).ToObject(vm)
if blocked.Get("ok").ToBoolean() {
t.Fatal("expected blocked fetch")
}
runtime.authClear(goja.FunctionCall{})
if runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
t.Fatal("expected auth cleared")
}
}
func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
dir := t.TempDir()
store := &extensionStore{
registryURL: "https://registry.example.com/registry.json",
cacheDir: dir,
cacheTTL: time.Hour,
cache: &storeRegistry{
Version: 1,
UpdatedAt: "2026-05-04",
Extensions: []storeExtension{
{
ID: "coverage-ext",
Name: "coverage-ext",
DisplayNameAlt: "Coverage Extension",
Version: "2.0.0",
Description: "Metadata and lyrics provider",
DownloadURLAlt: "https://registry.example.com/coverage.spotiflac-ext",
IconURLAlt: "https://registry.example.com/icon.png",
Category: CategoryMetadata,
Tags: []string{"metadata", "lyrics"},
Downloads: 10,
UpdatedAt: "2026-05-04",
MinAppVersionAlt: "4.5.0",
},
{
ID: "utility-ext",
Name: "utility-ext",
Version: "1.0.0",
Description: "Utility",
DownloadURL: "https://registry.example.com/utility.spotiflac-ext",
Category: CategoryUtility,
UpdatedAt: "2026-05-04",
},
},
},
cacheTime: time.Now(),
}
store.saveDiskCache()
loadedStore := &extensionStore{cacheDir: dir}
loadedStore.loadDiskCache()
if loadedStore.cache == nil || len(loadedStore.cache.Extensions) != 2 {
t.Fatalf("loaded cache = %#v", loadedStore.cache)
}
if got := store.getRegistryURL(); got != "https://registry.example.com/registry.json" {
t.Fatalf("registry URL = %q", got)
}
store.setRegistryURL("https://registry.example.com/new.json")
if store.cache != nil {
t.Fatal("expected cache reset after registry URL change")
}
store.cache = loadedStore.cache
store.cacheTime = time.Now()
manager := getExtensionManager()
manager.mu.Lock()
if manager.extensions == nil {
manager.extensions = map[string]*loadedExtension{}
}
manager.extensions["coverage-ext"] = &loadedExtension{
ID: "coverage-ext",
Manifest: &ExtensionManifest{
Name: "coverage-ext",
DisplayName: "Coverage Extension",
Version: "1.0.0",
Description: "Installed",
Types: []ExtensionType{ExtensionTypeMetadataProvider},
},
Enabled: true,
}
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
delete(manager.extensions, "coverage-ext")
manager.mu.Unlock()
}()
extensions, err := store.getExtensionsWithStatus(false)
if err != nil {
t.Fatalf("getExtensionsWithStatus: %v", err)
}
if len(extensions) != 2 || !extensions[0].IsInstalled || !extensions[0].HasUpdate {
t.Fatalf("extensions = %#v", extensions)
}
found, err := store.searchExtensions("lyrics", CategoryMetadata)
if err != nil || len(found) != 1 || found[0].ID != "coverage-ext" {
t.Fatalf("search = %#v/%v", found, err)
}
all, err := store.searchExtensions("", "")
if err != nil || len(all) != 2 {
t.Fatalf("all search = %#v/%v", all, err)
}
if cats := store.getCategories(); len(cats) != 5 {
t.Fatalf("categories = %#v", cats)
}
if !containsIgnoreCase("Hello Metadata", "metadata") || findSubstring("abcdef", "cd") != 2 || containsStr("abc", "z") {
t.Fatal("string helper mismatch")
}
if err := requireHTTPSURL("http://example.com", "registry"); err == nil {
t.Fatal("expected HTTPS validation error")
}
if _, err := resolveRegistryURL(""); err == nil {
t.Fatal("expected empty registry URL error")
}
if resolved, err := resolveRegistryURL("http://github.com/owner/repo"); err != nil || !strings.Contains(resolved, "raw.githubusercontent.com/owner/repo") {
t.Fatalf("resolved registry = %q/%v", resolved, err)
}
store.clearCache()
if store.cache != nil {
t.Fatal("expected cleared store cache")
}
settingsStore := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
if err := settingsStore.SetDataDir(filepath.Join(dir, "settings")); err != nil {
t.Fatalf("SetDataDir: %v", err)
}
if err := settingsStore.Set("ext", "quality", "lossless"); err != nil {
t.Fatalf("settings Set: %v", err)
}
if value, err := settingsStore.Get("ext", "quality"); err != nil || value != "lossless" {
t.Fatalf("settings Get = %#v/%v", value, err)
}
if _, err := settingsStore.Get("ext", "missing"); err == nil {
t.Fatal("expected missing setting error")
}
if err := settingsStore.SetAll("ext", map[string]interface{}{"a": float64(1), "_secret": "hidden"}); err != nil {
t.Fatalf("settings SetAll: %v", err)
}
if all := settingsStore.GetAll("ext"); all["a"] != float64(1) {
t.Fatalf("settings all = %#v", all)
}
if err := settingsStore.Remove("ext", "a"); err != nil {
t.Fatalf("settings Remove: %v", err)
}
if err := settingsStore.RemoveAll("ext"); err != nil {
t.Fatalf("settings RemoveAll: %v", err)
}
if jsonText, err := settingsStore.GetAllExtensionSettingsJSON(); err != nil || jsonText == "" {
t.Fatalf("settings JSON = %q/%v", jsonText, err)
}
reloaded := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
if err := reloaded.SetDataDir(settingsStore.dataDir); err != nil {
t.Fatalf("reload settings: %v", err)
}
vm := goja.New()
runtime := &extensionRuntime{
extensionID: "storage-ext",
dataDir: filepath.Join(dir, "runtime"),
vm: vm,
storageFlushDelay: time.Hour,
}
if err := os.MkdirAll(runtime.dataDir, 0755); err != nil {
t.Fatal(err)
}
if got := runtime.storageGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("missing"), vm.ToValue("fallback")}}).String(); got != "fallback" {
t.Fatalf("storage fallback = %q", got)
}
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
t.Fatal("storageSet false")
}
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
t.Fatal("storageSet equal false")
}
loaded, err := runtime.loadStorage()
if err != nil || loaded["key"] == nil {
t.Fatalf("loadStorage = %#v/%v", loaded, err)
}
if err := runtime.flushStorageNow(); err != nil {
t.Fatalf("flushStorageNow: %v", err)
}
if ok := runtime.storageRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key")}}); !ok.ToBoolean() {
t.Fatal("storageRemove false")
}
runtime.closeStorageFlusher()
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("after_close"), vm.ToValue("x")}}); ok.ToBoolean() {
t.Fatal("expected storageSet false after close")
}
credRuntime := &extensionRuntime{
extensionID: "cred-ext",
dataDir: filepath.Join(dir, "creds"),
vm: vm,
}
if err := os.MkdirAll(credRuntime.dataDir, 0755); err != nil {
t.Fatal(err)
}
if result := credRuntime.credentialsStore(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token"), vm.ToValue("secret")}}).Export().(map[string]interface{}); result["success"] != true {
t.Fatalf("credentialsStore = %#v", result)
}
if got := credRuntime.credentialsGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).String(); got != "secret" {
t.Fatalf("credential = %q", got)
}
if !credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
t.Fatal("expected credential")
}
if ok := credRuntime.credentialsRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}); !ok.ToBoolean() {
t.Fatal("credentialsRemove false")
}
if credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
t.Fatal("expected credential removed")
}
key, err := credRuntime.getEncryptionKey()
if err != nil {
t.Fatalf("getEncryptionKey: %v", err)
}
encrypted, err := encryptAES([]byte("plain"), key)
if err != nil {
t.Fatalf("encryptAES: %v", err)
}
decrypted, err := decryptAES(encrypted, key)
if err != nil || string(decrypted) != "plain" {
t.Fatalf("decryptAES = %q/%v", decrypted, err)
}
if _, err := decryptAES([]byte("short"), key); err == nil {
t.Fatal("expected short ciphertext error")
}
}
func TestExtensionRuntimeHTTPMatchingAndMetadataHelpers(t *testing.T) {
vm := goja.New()
jar, _ := newSimpleCookieJar()
runtime := &extensionRuntime{
extensionID: "http-ext",
manifest: &ExtensionManifest{
Name: "http-ext",
Description: "HTTP extension",
Version: "1.0.0",
Permissions: ExtensionPermissions{
Network: []string{"api.example.com"},
},
},
vm: vm,
cookieJar: jar,
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
var body []byte
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
}
header := make(http.Header)
header.Set("X-Method", req.Method)
if req.URL.Path == "/huge" {
return &http.Response{StatusCode: 200, Header: header, Body: io.NopCloser(io.LimitReader(strings.NewReader(strings.Repeat("x", maxExtensionHTTPResponseBytes+2)), maxExtensionHTTPResponseBytes+2)), Request: req}, nil
}
return &http.Response{
StatusCode: 201,
Header: header,
Body: io.NopCloser(strings.NewReader(req.Method + ":" + string(body))),
Request: req,
}, nil
})},
}
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
t.Fatalf("validateDomain allowed: %v", err)
}
for _, rawURL := range []string{"notaurl", "http://api.example.com", "https://user:pass@api.example.com", "https://127.0.0.1/x", "https://blocked.example.com/x"} {
if err := runtime.validateDomain(rawURL); err == nil {
t.Fatalf("expected domain validation error for %s", rawURL)
}
}
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/get"), vm.ToValue(map[string]interface{}{"X-Test": "yes"})}}).Export().(map[string]interface{}); got["status"] != 201 || !strings.Contains(got["body"].(string), "GET") {
t.Fatalf("httpGet = %#v", got)
}
if got := runtime.httpPost(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/post"), vm.ToValue(map[string]interface{}{"a": "b"})}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "POST") {
t.Fatalf("httpPost = %#v", got)
}
requestOptions := map[string]interface{}{"method": "patch", "body": []interface{}{"x"}, "headers": map[string]interface{}{"X-Req": "1"}}
if got := runtime.httpRequest(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/request"), vm.ToValue(requestOptions)}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "PATCH") {
t.Fatalf("httpRequest = %#v", got)
}
for _, method := range []struct {
name string
call func(goja.FunctionCall) goja.Value
args []goja.Value
}{
{name: "PUT", call: runtime.httpPut, args: []goja.Value{vm.ToValue("https://api.example.com/put"), vm.ToValue("body")}},
{name: "DELETE", call: runtime.httpDelete, args: []goja.Value{vm.ToValue("https://api.example.com/delete"), vm.ToValue(map[string]interface{}{"X-Delete": "1"})}},
{name: "PATCH", call: runtime.httpPatch, args: []goja.Value{vm.ToValue("https://api.example.com/patch"), vm.ToValue(map[string]interface{}{"p": "q"})}},
} {
if got := method.call(goja.FunctionCall{Arguments: method.args}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), method.name) {
t.Fatalf("%s = %#v", method.name, got)
}
}
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/huge")}}).Export().(map[string]interface{}); !strings.Contains(got["error"].(string), "exceeds") {
t.Fatalf("huge response = %#v", got)
}
if !runtime.httpClearCookies(goja.FunctionCall{}).ToBoolean() {
t.Fatal("expected cookies cleared")
}
if runtime.matchingCompareStrings(goja.FunctionCall{}).ToFloat() != 0 {
t.Fatal("missing string compare args should be zero")
}
if runtime.matchingCompareStrings(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song"), vm.ToValue("song")}}).ToFloat() != 1 {
t.Fatal("expected exact string similarity")
}
if runtime.matchingCompareDuration(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(180000), vm.ToValue(182000)}}).ToBoolean() != true {
t.Fatal("expected duration match")
}
if runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String() != "song" {
t.Fatalf("normalized = %q", runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String())
}
if formatMusicBrainzGenre([]musicBrainzTag{{Count: 1, Name: "rock"}, {Count: 5, Name: "electronic"}, {Count: 10, Name: "rock"}}) != "Electronic" {
t.Fatal("unexpected genre selection")
}
credits := []musicBrainzArtistCredit{{Name: "A", JoinPhrase: " & "}, {Name: "B"}}
if formatMusicBrainzArtistCredit(credits) != "A & B" {
t.Fatal("artist credit format mismatch")
}
releases := []musicBrainzRelease{
{Title: "Other", ArtistCredit: []musicBrainzArtistCredit{{Name: "Fallback"}}},
{Title: "Album", ArtistCredit: credits},
}
if selectMusicBrainzAlbumArtist(releases, "Album") != "A & B" || selectMusicBrainzAlbumArtist(releases, "") != "Fallback" {
t.Fatal("album artist selection mismatch")
}
}
func TestExtensionRuntimeFileAPIs(t *testing.T) {
vm := goja.New()
dir := t.TempDir()
SetAllowedDownloadDirs(nil)
defer SetAllowedDownloadDirs(nil)
fileBody := "chunk"
runtime := &extensionRuntime{
extensionID: "file-ext",
manifest: &ExtensionManifest{
Name: "file-ext",
Description: "File extension",
Version: "1.0.0",
Permissions: ExtensionPermissions{
File: true,
Network: []string{"files.example.com"},
},
},
dataDir: dir,
vm: vm,
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("Range") == "" {
body := "downloaded"
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Request: req,
}, nil
}
rangeHeader := req.Header.Get("Range")
start, end := 0, len(fileBody)-1
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil {
start, end = 0, 1
}
if start < 0 {
start = 0
}
if end >= len(fileBody) {
end = len(fileBody) - 1
}
if start > len(fileBody) {
start = len(fileBody)
}
body := fileBody[start : end+1]
header := http.Header{"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(fileBody))}}
return &http.Response{StatusCode: 206, Header: header, Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
})},
}
runtime.downloadClient = runtime.httpClient
if _, err := (&extensionRuntime{manifest: &ExtensionManifest{}}).validatePath("x"); err == nil {
t.Fatal("expected file permission error")
}
if _, err := runtime.validatePath("../escape.txt"); err == nil {
t.Fatal("expected sandbox escape error")
}
AddAllowedDownloadDir(dir)
absolutePath := filepath.Join(dir, "allowed.txt")
if got, err := runtime.validatePath(absolutePath); err != nil || got != absolutePath {
t.Fatalf("absolute validatePath = %q/%v", got, err)
}
write := runtime.fileWrite(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt"), vm.ToValue("hello")}}).Export().(map[string]interface{})
if write["success"] != true {
t.Fatalf("fileWrite = %#v", write)
}
if !runtime.fileExists(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).ToBoolean() {
t.Fatal("expected written file to exist")
}
read := runtime.fileRead(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).Export().(map[string]interface{})
if read["data"] != "hello" {
t.Fatalf("fileRead = %#v", read)
}
writeBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("nested/bytes.bin"),
vm.ToValue("4869"),
vm.ToValue(map[string]interface{}{"encoding": "hex", "truncate": true}),
}}).Export().(map[string]interface{})
if writeBytes["success"] != true {
t.Fatalf("fileWriteBytes = %#v", writeBytes)
}
appendBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("nested/bytes.bin"),
vm.ToValue([]interface{}{float64('!')}),
vm.ToValue(map[string]interface{}{"append": true}),
}}).Export().(map[string]interface{})
if appendBytes["success"] != true {
t.Fatalf("append fileWriteBytes = %#v", appendBytes)
}
readBytes := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("nested/bytes.bin"),
vm.ToValue(map[string]interface{}{"encoding": "text", "offset": float64(1), "length": float64(2)}),
}}).Export().(map[string]interface{})
if readBytes["data"] != "i!" || readBytes["bytes_read"] != 2 {
t.Fatalf("fileReadBytes = %#v", readBytes)
}
if bad := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("nested/bad.bin"),
vm.ToValue("x"),
vm.ToValue(map[string]interface{}{"append": true, "offset": float64(1)}),
}}).Export().(map[string]interface{}); bad["success"] != false {
t.Fatalf("expected append+offset failure, got %#v", bad)
}
if bad := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("nested/bytes.bin"),
vm.ToValue(map[string]interface{}{"encoding": "bad"}),
}}).Export().(map[string]interface{}); bad["success"] != false {
t.Fatalf("expected bad encoding failure, got %#v", bad)
}
copyResult := runtime.fileCopy(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/bytes.bin"), vm.ToValue("nested/copy.bin")}}).Export().(map[string]interface{})
if copyResult["success"] != true {
t.Fatalf("fileCopy = %#v", copyResult)
}
moveResult := runtime.fileMove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/copy.bin"), vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
if moveResult["success"] != true {
t.Fatalf("fileMove = %#v", moveResult)
}
sizeResult := runtime.fileGetSize(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
if sizeResult["success"] != true || sizeResult["size"] != int64(3) {
t.Fatalf("fileGetSize = %#v", sizeResult)
}
deleteResult := runtime.fileDelete(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
if deleteResult["success"] != true {
t.Fatalf("fileDelete = %#v", deleteResult)
}
download := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("https://files.example.com/file"),
vm.ToValue("downloads/file.bin"),
}}).Export().(map[string]interface{})
if download["success"] != true {
t.Fatalf("fileDownload = %#v", download)
}
if data, err := os.ReadFile(filepath.Join(dir, "downloads/file.bin")); err != nil || string(data) != "downloaded" {
t.Fatalf("downloaded data = %q/%v", data, err)
}
chunked := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
vm.ToValue("https://files.example.com/chunk"),
vm.ToValue("downloads/chunk.bin"),
vm.ToValue(map[string]interface{}{"chunked": float64(2), "headers": map[string]interface{}{"X-Test": "yes"}}),
}}).Export().(map[string]interface{})
if chunked["success"] != true {
t.Fatalf("chunked fileDownload = %#v", chunked)
}
if data, err := os.ReadFile(filepath.Join(dir, "downloads/chunk.bin")); err != nil || string(data) != fileBody {
t.Fatalf("chunked data = %q/%v", data, err)
}
if missing := runtime.fileDownload(goja.FunctionCall{}).Export().(map[string]interface{}); missing["success"] != false {
t.Fatalf("expected missing download args error, got %#v", missing)
}
}
func TestExtensionRuntimeUtilityAPIs(t *testing.T) {
vm := goja.New()
runtime := &extensionRuntime{extensionID: "utils-ext", vm: vm}
if runtime.sha256Hash(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("abc")}}).String() == "" {
t.Fatal("expected sha256")
}
if runtime.hmacSHA256(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
t.Fatal("expected hmac sha256")
}
if runtime.hmacSHA256Base64(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
t.Fatal("expected hmac sha256 base64")
}
if value := runtime.hmacSHA1(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue([]interface{}{float64(1), float64(2)}), vm.ToValue([]interface{}{float64(3)})}}); len(value.Export().([]interface{})) == 0 {
t.Fatal("expected hmac sha1 bytes")
}
if !goja.IsUndefined(runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{bad`)}})) {
t.Fatal("expected invalid JSON to return undefined")
}
parsed := runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{"ok":true}`)}}).Export().(map[string]interface{})
if parsed["ok"] != true {
t.Fatalf("parseJSON = %#v", parsed)
}
if text := runtime.stringifyJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"ok": true})}}).String(); !strings.Contains(text, "ok") {
t.Fatalf("stringifyJSON = %q", text)
}
encrypted := runtime.cryptoEncrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("plain"), vm.ToValue("secret")}}).Export().(map[string]interface{})
if encrypted["success"] != true || encrypted["data"] == "" {
t.Fatalf("cryptoEncrypt = %#v", encrypted)
}
decrypted := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(encrypted["data"]), vm.ToValue("secret")}}).Export().(map[string]interface{})
if decrypted["success"] != true || decrypted["data"] != "plain" {
t.Fatalf("cryptoDecrypt = %#v", decrypted)
}
if bad := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("bad"), vm.ToValue("secret")}}).Export().(map[string]interface{}); bad["success"] != false {
t.Fatalf("expected bad decrypt failure, got %#v", bad)
}
key := runtime.cryptoGenerateKey(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(8))}}).Export().(map[string]interface{})
if key["success"] != true || key["key"] == "" || key["hex"] == "" {
t.Fatalf("cryptoGenerateKey = %#v", key)
}
if runtime.randomUserAgent(goja.FunctionCall{}).String() == "" || runtime.appUserAgent(goja.FunctionCall{}).String() == "" {
t.Fatal("expected user agents")
}
SetAppVersion("9.9.9")
if runtime.appVersion(goja.FunctionCall{}).String() != "9.9.9" {
t.Fatal("appVersion mismatch")
}
if !runtime.sleep(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(0))}}).ToBoolean() {
t.Fatal("zero sleep should succeed")
}
itemID := "utils-item"
runtime.setActiveDownloadItemID(itemID)
initDownloadCancel(itemID)
if runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
t.Fatal("item should not be cancelled yet")
}
runtime.setDownloadStatus(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(itemProgressStatusDownloading)}})
cancelDownload(itemID)
if !runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
t.Fatal("item should be cancelled")
}
clearDownloadCancel(itemID)
runtime.clearActiveDownloadItemID()
requestID := "utils-request"
runtime.setActiveRequestID(requestID)
initExtensionRequestCancel(requestID)
if runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
t.Fatal("request should not be cancelled yet")
}
cancelExtensionRequest(requestID)
if !runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
t.Fatal("request should be cancelled")
}
clearExtensionRequestCancel(requestID)
runtime.clearActiveRequestID()
if msg := runtime.formatLogArgs([]goja.Value{vm.ToValue("a"), vm.ToValue(1)}); msg != "a 1" {
t.Fatalf("formatLogArgs = %q", msg)
}
runtime.logDebug(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("debug")}})
runtime.logInfo(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("info")}})
runtime.logWarn(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("warn")}})
runtime.logError(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("error")}})
if clean := runtime.sanitizeFilenameWrapper(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("A/B?")}}).String(); strings.ContainsAny(clean, "/?") {
t.Fatalf("sanitize wrapper = %q", clean)
}
}
+104
View File
@@ -312,6 +312,33 @@ func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(isDownloadCancelled(itemID))
}
func (r *extensionRuntime) isRequestCancelled(call goja.FunctionCall) goja.Value {
requestID := r.getActiveRequestID()
if requestID == "" {
return r.vm.ToValue(false)
}
return r.vm.ToValue(isExtensionRequestCancelled(requestID))
}
func (r *extensionRuntime) setDownloadStatus(call goja.FunctionCall) goja.Value {
itemID := r.getActiveDownloadItemID()
if itemID == "" || len(call.Arguments) < 1 {
return goja.Undefined()
}
status := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
switch status {
case itemProgressStatusPreparing:
SetItemPreparing(itemID)
case itemProgressStatusDownloading:
SetItemDownloading(itemID)
case itemProgressStatusFinalizing:
SetItemFinalizing(itemID)
}
return goja.Undefined()
}
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
@@ -387,6 +414,83 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
"bitDepth": quality.BitDepth,
"sampleRate": quality.SampleRate,
"totalSamples": quality.TotalSamples,
"duration": quality.Duration,
"codec": quality.Codec,
})
})
obj.Set("getLyricsLRC", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 3 {
return vm.ToValue(map[string]interface{}{
"error": "spotifyID, trackName, and artistName are required",
})
}
spotifyID := strings.TrimSpace(call.Arguments[0].String())
trackName := strings.TrimSpace(call.Arguments[1].String())
artistName := strings.TrimSpace(call.Arguments[2].String())
filePath := ""
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
filePath = strings.TrimSpace(call.Arguments[3].String())
}
var durationMs int64
if len(call.Arguments) > 4 && !goja.IsUndefined(call.Arguments[4]) && !goja.IsNull(call.Arguments[4]) {
durationMs = call.Arguments[4].ToInteger()
}
lyrics, err := GetLyricsLRC(spotifyID, trackName, artistName, filePath, durationMs)
if err != nil {
return vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
return vm.ToValue(map[string]interface{}{
"lyrics": lyrics,
})
})
obj.Set("checkISRCExists", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return vm.ToValue(map[string]interface{}{
"error": "outputDir and isrc are required",
})
}
outputDir := strings.TrimSpace(call.Arguments[0].String())
isrc := strings.TrimSpace(call.Arguments[1].String())
if outputDir == "" || isrc == "" {
return vm.ToValue(map[string]interface{}{
"error": "outputDir and isrc are required",
})
}
filePath, exists := checkISRCExistsInternal(outputDir, isrc)
return vm.ToValue(map[string]interface{}{
"exists": exists,
"filePath": filePath,
})
})
obj.Set("addToISRCIndex", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 3 {
return vm.ToValue(map[string]interface{}{
"error": "outputDir, isrc, and filePath are required",
})
}
outputDir := strings.TrimSpace(call.Arguments[0].String())
isrc := strings.TrimSpace(call.Arguments[1].String())
filePath := strings.TrimSpace(call.Arguments[2].String())
if outputDir == "" || isrc == "" || filePath == "" {
return vm.ToValue(map[string]interface{}{
"error": "outputDir, isrc, and filePath are required",
})
}
AddToISRCIndex(outputDir, isrc, filePath)
return vm.ToValue(map[string]interface{}{
"success": true,
})
})
+70 -3
View File
@@ -1,6 +1,8 @@
package gobackend
import (
"context"
"errors"
"net/http"
"path/filepath"
"testing"
@@ -44,6 +46,23 @@ func TestParseManifest_Valid(t *testing.T) {
}
}
func TestExtensionManifestStopsProviderFallback(t *testing.T) {
modernManifest := &ExtensionManifest{StopProviderFallback: true}
if !modernManifest.StopsProviderFallback() {
t.Fatal("expected stopProviderFallback to stop provider fallback")
}
legacyManifest := &ExtensionManifest{SkipBuiltInFallback: true}
if !legacyManifest.StopsProviderFallback() {
t.Fatal("expected legacy skipBuiltInFallback to stop provider fallback")
}
defaultManifest := &ExtensionManifest{}
if defaultManifest.StopsProviderFallback() {
t.Fatal("expected default manifest to allow provider fallback")
}
}
func TestParseManifest_MissingName(t *testing.T) {
invalidManifest := `{
"version": "1.0.0",
@@ -97,7 +116,6 @@ func TestIsDomainAllowed(t *testing.T) {
}
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
// Create a mock extension with limited network permissions
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
@@ -126,6 +144,15 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
t.Error("Expected notallowed.com to be denied")
}
if err := runtime.validateDomain("http://api.allowed.com/path"); err == nil {
t.Error("Expected http URL to be denied without allowHttp")
}
ext.Manifest.Permissions.AllowHTTP = true
if err := runtime.validateDomain("http://api.allowed.com/path"); err != nil {
t.Errorf("Expected http URL to be allowed with allowHttp, got error: %v", err)
}
}
func TestExtensionRuntime_FileSandbox(t *testing.T) {
@@ -234,7 +261,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
if err != nil {
t.Fatalf("stringifyJSON failed: %v", err)
}
// JSON output may vary in order, just check it's valid
if result.String() == "" {
t.Error("Expected non-empty JSON string")
}
@@ -362,8 +388,49 @@ func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t
}
}
func TestRunWithTimeoutContextCancelsExecution(t *testing.T) {
vm := goja.New()
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := RunWithTimeoutContextAndRecover(ctx, vm, `while (true) {}`, 5*time.Second)
if !errors.Is(err, ErrExtensionRequestCancelled) {
t.Fatalf("expected extension request cancellation, got %v", err)
}
}
func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
const requestID = "test-extension-request"
clearExtensionRequestCancel(requestID)
defer clearExtensionRequestCancel(requestID)
runtime.setActiveRequestID(requestID)
defer runtime.clearActiveRequestID()
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
cancelExtensionRequest(requestID)
select {
case <-req.Context().Done():
case <-time.After(time.Second):
t.Fatal("expected request context to be cancelled")
}
}
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
// Create extension with limited network permissions
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
+25 -3
View File
@@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string {
}
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
return RunWithTimeoutContext(context.Background(), vm, script, timeout)
}
func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
if vm == nil {
return nil, fmt.Errorf("extension runtime unavailable")
}
@@ -28,7 +32,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
timeout = DefaultJSTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
type result struct {
@@ -67,11 +74,16 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
case res := <-resultCh:
return res.value, res.err
case <-ctx.Done():
cancelled := ctx.Err() == context.Canceled
interruptMu.Lock()
interrupted = true
interruptMu.Unlock()
vm.Interrupt("execution timeout")
if cancelled {
vm.Interrupt("extension request cancelled")
} else {
vm.Interrupt("execution timeout")
}
// MUST wait for the goroutine to finish before returning.
// The Goja VM is NOT thread-safe — if we return while the goroutine
@@ -80,6 +92,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
// pointer dereference.
select {
case res := <-resultCh:
if cancelled {
return nil, ErrExtensionRequestCancelled
}
if res.err != nil {
return nil, res.err
}
@@ -91,6 +106,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
// Log a warning — the VM should NOT be reused after this.
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
if cancelled {
return nil, ErrExtensionRequestCancelled
}
return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)",
IsTimeout: true,
@@ -102,7 +120,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
// This should be used when you want to continue using the VM after a timeout
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
result, err := RunWithTimeout(vm, script, timeout)
return RunWithTimeoutContextAndRecover(context.Background(), vm, script, timeout)
}
func RunWithTimeoutContextAndRecover(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
result, err := RunWithTimeoutContext(ctx, vm, script, timeout)
if vm != nil {
vm.ClearInterrupt()
+20 -1
View File
@@ -48,7 +48,7 @@ func sanitizeFilename(filename string) string {
}
if len(sanitized) > 200 {
sanitized = sanitized[:200]
sanitized = truncateUTF8Bytes(sanitized, 200)
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
sanitized = strings.Trim(sanitized, "_ ")
}
@@ -60,6 +60,25 @@ func sanitizeFilename(filename string) string {
return sanitized
}
func truncateUTF8Bytes(value string, maxBytes int) string {
if maxBytes <= 0 || len(value) <= maxBytes {
return value
}
used := 0
for i, r := range value {
runeLen := utf8.RuneLen(r)
if runeLen < 0 {
runeLen = len(string(r))
}
if used+runeLen > maxBytes {
return value[:i]
}
used += runeLen
}
return value
}
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
if template == "" {
template = "{artist} - {title}"
+15 -1
View File
@@ -1,6 +1,10 @@
package gobackend
import "testing"
import (
"strings"
"testing"
"unicode/utf8"
)
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
metadata := map[string]interface{}{
@@ -98,3 +102,13 @@ func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
t.Fatalf("expected %q, got %q", "Unknown", got)
}
}
func TestSanitizeFilenameTruncatesWithoutSplittingUTF8(t *testing.T) {
got := sanitizeFilename(strings.Repeat("あ", 80))
if !utf8.ValidString(got) {
t.Fatalf("sanitizeFilename returned invalid UTF-8: %q", got)
}
if len(got) > 200 {
t.Fatalf("sanitizeFilename length = %d, want <= 200", len(got))
}
}
+14 -14
View File
@@ -2,28 +2,28 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0
toolchain go1.25.8
toolchain go1.25.9
require (
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d
github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60
golang.org/x/net v0.52.0
golang.org/x/text v0.35.0
golang.org/x/crypto v0.53.0
golang.org/x/mobile v0.0.0-20260602190626-68735029466e
golang.org/x/net v0.56.0
golang.org/x/text v0.38.0
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/klauspost/compress v1.18.5 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/tools v0.43.0 // indirect
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/tools v0.45.0 // indirect
)
+30 -30
View File
@@ -1,13 +1,13 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
@@ -16,12 +16,14 @@ github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sY
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
@@ -30,23 +32,21 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e h1:YxPXu/HWDTcSSrzSX+sCltsfcNCa/ZYVG43oslMouNU=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+24 -11
View File
@@ -77,6 +77,26 @@ var sharedTransport = &http.Transport{
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: true,
TLSClientConfig: newTLSCompatibilityConfig(false),
}
var extensionAPITransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: false,
TLSClientConfig: newTLSCompatibilityConfig(false),
}
var metadataTransport = &http.Transport{
@@ -95,6 +115,7 @@ var metadataTransport = &http.Transport{
WriteBufferSize: 32 * 1024,
ReadBufferSize: 32 * 1024,
DisableCompression: true,
TLSClientConfig: newTLSCompatibilityConfig(false),
}
var sharedClient = &http.Client{
@@ -131,6 +152,7 @@ func GetDownloadClient() *http.Client {
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
extensionAPITransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
}
@@ -143,6 +165,7 @@ func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
networkCompatibilityMu.Unlock()
applyTLSCompatibility(sharedTransport, insecureTLS)
applyTLSCompatibility(extensionAPITransport, insecureTLS)
applyTLSCompatibility(metadataTransport, insecureTLS)
CloseIdleConnections()
@@ -156,17 +179,7 @@ func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
}
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
if insecureTLS {
cfg := &tls.Config{InsecureSkipVerify: true}
if transport.TLSClientConfig != nil {
cfg = transport.TLSClientConfig.Clone()
cfg.InsecureSkipVerify = true
}
transport.TLSClientConfig = cfg
return
}
transport.TLSClientConfig = nil
transport.TLSClientConfig = newTLSCompatibilityConfig(insecureTLS)
}
type compatibilityTransport struct {
+183
View File
@@ -0,0 +1,183 @@
package gobackend
import (
"crypto/x509"
"encoding/pem"
"errors"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
)
func TestHTTPUtilityHelpers(t *testing.T) {
SetAppVersion("7.0.0")
apiURL := mustParseURL(t, "https://api.zarz.moe/test")
if ua := userAgentForURL(apiURL); !strings.Contains(ua, "7.0.0") {
t.Fatalf("api user agent = %q", ua)
}
if userAgentForURL(nil) == "" || userAgentForURL(mustParseURL(t, "https://example.com")) == "" {
t.Fatal("expected fallback user agent")
}
if NewHTTPClientWithTimeout(time.Second).Timeout != time.Second || NewMetadataHTTPClient(time.Second).Timeout != time.Second {
t.Fatal("client timeout mismatch")
}
if GetSharedClient() == nil || GetDownloadClient() == nil {
t.Fatal("expected shared clients")
}
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.RootCAs == nil {
t.Fatal("expected supplemental TLS root pool")
}
block, _ := pem.Decode([]byte(isrgRootX2PEM))
if block == nil {
t.Fatal("failed to decode ISRG Root X2")
}
rootX2, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("failed to parse ISRG Root X2: %v", err)
}
if _, err := rootX2.Verify(x509.VerifyOptions{
Roots: supplementalRootCAs(),
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}); err != nil {
t.Fatalf("ISRG Root X2 should verify with supplemental roots: %v", err)
}
SetNetworkCompatibilityOptions(true, true)
if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS {
t.Fatalf("network opts = %#v", opts)
}
if !sharedTransport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("expected insecure TLS config to be applied")
}
SetNetworkCompatibilityOptions(false, false)
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("expected secure TLS config to be restored")
}
if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) {
t.Fatal("GET should fallback")
}
if canFallbackToHTTP(&http.Request{Method: http.MethodPost}) {
t.Fatal("POST without GetBody should not fallback")
}
req, _ := http.NewRequest(http.MethodPost, "https://example.com/path", strings.NewReader("body"))
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("body")), nil }
cloned, err := cloneRequestWithHTTPScheme(req, "http")
if err != nil || cloned.URL.Scheme != "http" || cloned.Body == nil {
t.Fatalf("cloneRequestWithHTTPScheme = %#v/%v", cloned, err)
}
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("User-Agent") == "" {
t.Fatal("missing User-Agent")
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("ok")), Request: req}, nil
})}
resp, err := DoRequestWithUserAgent(client, mustNewRequest(t, "https://example.com/ok"))
if err != nil || resp.StatusCode != 200 {
t.Fatalf("DoRequestWithUserAgent = %#v/%v", resp, err)
}
resp.Body.Close()
attempts := 0
retryClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
attempts++
switch attempts {
case 1:
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("server")), Request: req}, nil
case 2:
return &http.Response{StatusCode: 429, Header: http.Header{"Retry-After": []string{"0"}}, Body: io.NopCloser(strings.NewReader("rate")), Request: req}, nil
default:
return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
}
})}
resp, err = DoRequestWithRetry(retryClient, mustNewRequest(t, "https://example.com/retry"), RetryConfig{MaxRetries: 3, InitialDelay: 0, MaxDelay: time.Millisecond, BackoffFactor: 2})
if err != nil || resp.StatusCode != 204 || attempts != 3 {
t.Fatalf("DoRequestWithRetry = %#v/%v attempts=%d", resp, err, attempts)
}
resp.Body.Close()
blockingClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 403, Body: io.NopCloser(strings.NewReader("access denied by region")), Request: req}, nil
})}
if _, err := DoRequestWithRetry(blockingClient, mustNewRequest(t, "https://blocked.example.com"), RetryConfig{MaxRetries: 0}); err == nil {
t.Fatal("expected blocking retry error")
}
if _, err := ReadResponseBody(nil); err == nil {
t.Fatal("expected nil response body error")
}
if _, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader(""))}); err == nil {
t.Fatal("expected empty response body error")
}
if body, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader("ok"))}); err != nil || string(body) != "ok" {
t.Fatalf("ReadResponseBody = %q/%v", body, err)
}
if err := ValidateResponse(nil); err == nil {
t.Fatal("expected nil response validation error")
}
if err := ValidateResponse(&http.Response{StatusCode: 404, Status: "404 Not Found"}); err == nil {
t.Fatal("expected bad status validation error")
}
if err := ValidateResponse(&http.Response{StatusCode: 200}); err != nil {
t.Fatalf("ValidateResponse: %v", err)
}
if msg := BuildErrorMessage("api", 500, strings.Repeat("x", 120)); !strings.Contains(msg, "...") {
t.Fatalf("BuildErrorMessage = %q", msg)
}
if calculateNextDelay(10*time.Millisecond, RetryConfig{BackoffFactor: 3, MaxDelay: 20 * time.Millisecond}) != 20*time.Millisecond {
t.Fatal("calculateNextDelay mismatch")
}
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
t.Fatal("invalid retry-after should be zero")
}
if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
t.Fatalf("IsISPBlocking = %#v", isp)
}
if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") {
t.Fatal("expected logged ISP blocking")
}
if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
}
if WrapErrorWithISPCheck(nil, "", "test") != nil {
t.Fatal("nil wrap should stay nil")
}
if extractDomain("https://example.com/path") != "example.com" || extractDomain("bad://") != "unknown" || extractDomain("") != "unknown" {
t.Fatal("extractDomain mismatch")
}
}
func TestRateLimiterHelpers(t *testing.T) {
limiter := NewRateLimiter(1, time.Hour)
if limiter.Available() != 1 {
t.Fatalf("available = %d", limiter.Available())
}
if !limiter.TryAcquire() || limiter.TryAcquire() {
t.Fatal("TryAcquire mismatch")
}
if limiter.Available() != 0 {
t.Fatalf("available after acquire = %d", limiter.Available())
}
if GetSongLinkRateLimiter() == nil {
t.Fatal("expected global limiter")
}
}
func mustNewRequest(t *testing.T, rawURL string) *http.Request {
t.Helper()
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
t.Fatal(err)
}
return req
}
func mustParseURL(t *testing.T, rawURL string) *url.URL {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
return parsed
}
+6 -7
View File
@@ -10,16 +10,13 @@ import (
"net/http"
"net/url"
"strings"
"sync"
utls "github.com/refraction-networking/utls"
"golang.org/x/net/http2"
)
type utlsTransport struct {
dialer *net.Dialer
mu sync.Mutex
h2Transports map[string]*http2.Transport
dialer *net.Dialer
}
func newUTLSTransport() *utlsTransport {
@@ -28,7 +25,6 @@ func newUTLSTransport() *utlsTransport {
Timeout: 30 * Second,
KeepAlive: 30 * Second,
},
h2Transports: make(map[string]*http2.Transport),
}
}
@@ -46,9 +42,12 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return nil, err
}
opts := GetNetworkCompatibilityOptions()
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
RootCAs: supplementalRootCAs(),
InsecureSkipVerify: opts.InsecureTLS,
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
}, utls.HelloChrome_Auto)
if err := tlsConn.Handshake(); err != nil {
+75 -12
View File
@@ -68,12 +68,17 @@ var (
var supportedAudioFormats = map[string]bool{
".flac": true,
".m4a": true,
".mp4": true,
".aac": true,
".mp3": true,
".opus": true,
".ogg": true,
".ape": true,
".wv": true,
".mpc": true,
".wav": true,
".aiff": true,
".aif": true,
".cue": true,
}
@@ -87,10 +92,23 @@ type scannedCueFileInfo struct {
audioPath string
}
func isLibraryStagingFile(path string) bool {
name := strings.ToLower(filepath.Base(path))
if strings.HasSuffix(name, ".partial") {
return true
}
for ext := range supportedAudioFormats {
if strings.HasSuffix(name, ".partial"+ext) {
return true
}
}
return false
}
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
var files []libraryAudioFileInfo
err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
err := filepath.WalkDir(folderPath, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return nil
}
@@ -101,7 +119,10 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
default:
}
if info.IsDir() {
if entry.IsDir() {
return nil
}
if isLibraryStagingFile(path) {
return nil
}
@@ -110,6 +131,11 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
return nil
}
info, err := entry.Info()
if err != nil {
return nil
}
files = append(files, libraryAudioFileInfo{
path: path,
modTime: info.ModTime().UnixMilli(),
@@ -271,18 +297,10 @@ func ScanLibraryFolder(folderPath string) (string, error) {
return string(jsonBytes), nil
}
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
}
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
}
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
}
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
ext := resolveLibraryAudioExt(filePath, displayNameHint)
@@ -317,7 +335,7 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
switch ext {
case ".flac":
return scanFLACFile(filePath, result, displayNameHint)
case ".m4a":
case ".m4a", ".mp4", ".aac":
return scanM4AFile(filePath, result, displayNameHint)
case ".mp3":
return scanMP3File(filePath, result, displayNameHint)
@@ -325,6 +343,10 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
return scanOggFile(filePath, result, displayNameHint)
case ".ape", ".wv", ".mpc":
return scanAPEFile(filePath, result, displayNameHint)
case ".wav":
return scanWAVFile(filePath, result, displayNameHint)
case ".aiff", ".aif", ".aifc":
return scanAIFFFile(filePath, result, displayNameHint)
default:
return scanFromFilename(filePath, displayNameHint, result)
}
@@ -397,7 +419,6 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
metadata, err := ReadM4ATags(filePath)
if err != nil {
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
if metadata != nil {
@@ -424,12 +445,54 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate
}
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
result.Format = format
if isLosslessLibraryFormat(format) {
result.Bitrate = 0
}
}
}
if metadata == nil {
return scanFromFilename(filePath, displayNameHint, result)
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func libraryFormatForM4ACodec(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "flac":
return "flac"
case "alac":
return "alac"
case "eac3", "ec-3":
return "eac3"
case "ac3", "ac-3":
return "ac3"
case "ac4", "ac-4":
return "ac4"
case "aac", "mp4a":
return "m4a"
default:
return ""
}
}
func isLosslessLibraryFormat(format string) bool {
switch strings.ToLower(strings.TrimSpace(format)) {
case "flac", "alac", "wav", "aiff", "aif", "aifc":
return true
default:
return false
}
}
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
+163
View File
@@ -0,0 +1,163 @@
package gobackend
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
dir := t.TempDir()
albumDir := filepath.Join(dir, "Album")
if err := os.MkdirAll(albumDir, 0755); err != nil {
t.Fatal(err)
}
mp3Path := filepath.Join(albumDir, "Artist - Song.mp3")
if err := os.WriteFile(mp3Path, []byte("not really mp3"), 0600); err != nil {
t.Fatal(err)
}
numberedPath := filepath.Join(albumDir, "01 - Intro.ogg")
if err := os.WriteFile(numberedPath, []byte("not really ogg"), 0600); err != nil {
t.Fatal(err)
}
apePath := filepath.Join(albumDir, "tagged.ape")
if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
if err := WriteAPETags(apePath, &APETag{Items: AudioMetadataToAPEItems(&AudioMetadata{
Title: "Tagged",
Artist: "APE Artist",
Album: "APE Album",
TrackNumber: 2,
TotalTracks: 3,
Date: "2026",
Genre: "Pop",
Composer: "Composer",
})}); err != nil {
t.Fatalf("write ape tags: %v", err)
}
cuePath, _ := writeExportCueFixture(t, albumDir)
if err := os.WriteFile(filepath.Join(albumDir, "ignored.txt"), []byte("ignore"), 0600); err != nil {
t.Fatal(err)
}
legacyPartialPath := filepath.Join(albumDir, "Artist - Song.partial.flac")
if err := os.WriteFile(legacyPartialPath, []byte("partial flac"), 0600); err != nil {
t.Fatal(err)
}
newPartialPath := filepath.Join(albumDir, "Artist - Song.flac.partial")
if err := os.WriteFile(newPartialPath, []byte("partial flac"), 0600); err != nil {
t.Fatal(err)
}
files, err := collectLibraryAudioFiles(dir, make(chan struct{}))
if err != nil {
t.Fatalf("collectLibraryAudioFiles: %v", err)
}
if len(files) < 4 {
t.Fatalf("files = %#v", files)
}
for _, file := range files {
if file.path == legacyPartialPath || file.path == newPartialPath {
t.Fatalf("staging file should be ignored: %#v", files)
}
}
cancelCh := make(chan struct{})
close(cancelCh)
if _, err := collectLibraryAudioFiles(dir, cancelCh); err == nil {
t.Fatal("expected cancelled collect")
}
jsonText, err := ScanLibraryFolder(dir)
if err != nil {
t.Fatalf("ScanLibraryFolder: %v", err)
}
var results []LibraryScanResult
if err := json.Unmarshal([]byte(jsonText), &results); err != nil {
t.Fatalf("decode scan results: %v", err)
}
if len(results) < 4 {
t.Fatalf("scan results = %#v", results)
}
foundTagged := false
for _, result := range results {
if result.FilePath == apePath {
foundTagged = result.TrackName == "Tagged" && result.ArtistName == "APE Artist"
}
}
if !foundTagged {
t.Fatalf("tagged APE not found in %#v", results)
}
if progress := GetLibraryScanProgress(); !strings.Contains(progress, `"IsComplete":true`) && !strings.Contains(progress, `"is_complete":true`) {
t.Fatalf("progress = %s", progress)
}
metaJSON, err := ReadAudioMetadataWithDisplayName(mp3Path, "Display Artist - Display Song.mp3")
if err != nil {
t.Fatalf("ReadAudioMetadataWithDisplayName: %v", err)
}
if !strings.Contains(metaJSON, "Display Song") {
t.Fatalf("metadata json = %s", metaJSON)
}
noExtPath := filepath.Join(albumDir, "noext")
if err := os.WriteFile(noExtPath, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
noExtJSON, err := ReadAudioMetadataWithDisplayNameAndCoverCacheKey(noExtPath, "Artist - No Ext.mp3", "cache-key")
if err != nil {
t.Fatalf("ReadAudioMetadataWithDisplayNameAndCoverCacheKey: %v", err)
}
if !strings.Contains(noExtJSON, "No Ext") {
t.Fatalf("no ext metadata = %s", noExtJSON)
}
existing := map[string]int64{}
for _, file := range files {
existing[file.path] = file.modTime
}
if info, err := os.Stat(cuePath); err == nil {
existing[cuePath+"#track01"] = info.ModTime().UnixMilli()
}
incJSON, err := scanLibraryFolderIncrementalWithExistingFiles(dir, existing)
if err != nil {
t.Fatalf("incremental existing: %v", err)
}
var inc IncrementalScanResult
if err := json.Unmarshal([]byte(incJSON), &inc); err != nil {
t.Fatalf("decode incremental: %v", err)
}
if inc.SkippedCount == 0 {
t.Fatalf("incremental = %#v", inc)
}
if _, err := ScanLibraryFolderIncremental("", "{}"); err == nil {
t.Fatal("expected empty incremental folder error")
}
if incJSON, err := ScanLibraryFolderIncremental(dir, `not-json`); err != nil || incJSON == "" {
t.Fatalf("incremental invalid existing JSON = %q/%v", incJSON, err)
}
snapshot := filepath.Join(dir, "snapshot.txt")
if err := os.WriteFile(snapshot, []byte("bad\n123\t"+mp3Path+"\nnotint\tpath\n999\t"+filepath.Join(dir, "deleted.mp3")+"\n"), 0600); err != nil {
t.Fatal(err)
}
fromSnapshot, err := ScanLibraryFolderIncrementalFromSnapshot(dir, snapshot)
if err != nil {
t.Fatalf("snapshot incremental: %v", err)
}
if !strings.Contains(fromSnapshot, "deleted.mp3") {
t.Fatalf("snapshot result = %s", fromSnapshot)
}
if _, err := ScanLibraryFolder(""); err == nil {
t.Fatal("expected empty folder scan error")
}
fileInsteadOfFolder := filepath.Join(dir, "file.flac")
if err := os.WriteFile(fileInsteadOfFolder, []byte("audio"), 0600); err != nil {
t.Fatal(err)
}
if _, err := ScanLibraryFolder(fileInsteadOfFolder); err == nil {
t.Fatal("expected not folder error")
}
CancelLibraryScan()
SetLibraryCoverCacheDir("")
}
@@ -0,0 +1,123 @@
package gobackend
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/dop251/goja"
)
func TestLogBufferExportedHelpersAndRedaction(t *testing.T) {
ClearLogs()
SetLoggingEnabled(false)
LogInfo("test", "ignored access_token=secret")
LogError("test", "Authorization: Bearer secret-token api_key=value")
if GetLogCount() != 1 {
t.Fatalf("disabled logging should keep errors only, got %d", GetLogCount())
}
SetLoggingEnabled(true)
defer SetLoggingEnabled(false)
LogDebug("debug", "client_secret=secret")
LogWarn("warn", "warning password=secret")
GoLog("[GoTag] success token=abc")
var entries []LogEntry
if err := json.Unmarshal([]byte(GetLogs()), &entries); err != nil {
t.Fatalf("GetLogs JSON: %v", err)
}
if len(entries) < 4 {
t.Fatalf("expected log entries, got %#v", entries)
}
for _, entry := range entries {
if strings.Contains(entry.Message, "secret-token") || strings.Contains(entry.Message, "api_key=value") || strings.Contains(entry.Message, "password=secret") {
t.Fatalf("log was not redacted: %#v", entry)
}
}
sinceJSON := GetLogsSince(1)
if !strings.Contains(sinceJSON, `"next_index"`) || !strings.Contains(sinceJSON, `"logs"`) {
t.Fatalf("GetLogsSince = %q", sinceJSON)
}
if emptyJSON := GetLogsSince(999); !strings.Contains(emptyJSON, `"logs":[]`) {
t.Fatalf("GetLogsSince empty = %q", emptyJSON)
}
if negativeJSON := GetLogsSince(-5); !strings.Contains(negativeJSON, `"logs"`) {
t.Fatalf("GetLogsSince negative = %q", negativeJSON)
}
ClearLogs()
if GetLogCount() != 0 || GetLogs() != "[]" {
t.Fatalf("logs were not cleared: count=%d logs=%s", GetLogCount(), GetLogs())
}
}
func TestProgressItemHelpersAndWriter(t *testing.T) {
ClearAllItemProgress()
itemID := "progress-writer"
StartItemProgress(itemID)
SetItemBytesTotal(itemID, int64(progressUpdateThreshold*2))
SetItemBytesReceived(itemID, int64(progressUpdateThreshold))
progressJSON := GetItemProgress(itemID)
if !strings.Contains(progressJSON, `"bytes_received":131072`) || !strings.Contains(progressJSON, `"progress":0.5`) {
t.Fatalf("GetItemProgress = %q", progressJSON)
}
if missing := GetItemProgress("missing"); missing != "{}" {
t.Fatalf("missing progress = %q", missing)
}
var out bytes.Buffer
writer := NewItemProgressWriter(&out, itemID)
payload := bytes.Repeat([]byte("x"), progressUpdateThreshold+1)
n, err := writer.Write(payload)
if err != nil || n != len(payload) {
t.Fatalf("progress writer = %d/%v", n, err)
}
if out.Len() != len(payload) {
t.Fatalf("writer output length = %d", out.Len())
}
if progressJSON = GetItemProgress(itemID); !strings.Contains(progressJSON, `"bytes_received":131073`) {
t.Fatalf("progress after writer = %q", progressJSON)
}
cancelDownload(itemID)
defer clearDownloadCancel(itemID)
n, err = writer.Write([]byte("cancelled"))
if n != 0 || !errors.Is(err, ErrDownloadCancelled) {
t.Fatalf("cancelled writer = %d/%v", n, err)
}
ClearAllItemProgress()
}
func TestRunWithTimeoutBranches(t *testing.T) {
if _, err := RunWithTimeout(nil, "1 + 1", time.Millisecond); err == nil {
t.Fatal("expected nil VM error")
}
vm := goja.New()
value, err := RunWithTimeout(vm, "1 + 2", time.Second)
if err != nil || value.ToInteger() != 3 {
t.Fatalf("RunWithTimeout success = %v/%v", value, err)
}
timeoutVM := goja.New()
_, err = RunWithTimeoutAndRecover(timeoutVM, "for (;;) {}", 10*time.Millisecond)
if err == nil {
t.Fatal("expected timeout error")
}
if !IsTimeoutError(&JSExecutionError{Message: "timeout", IsTimeout: true}) {
t.Fatal("JSExecutionError should be recognized as timeout")
}
if IsTimeoutError(errors.New("plain")) {
t.Fatal("plain error should not be timeout")
}
if (&JSExecutionError{Message: "boom"}).Error() != "boom" {
t.Fatal("JSExecutionError Error mismatch")
}
}
+182 -11
View File
@@ -26,14 +26,17 @@ const (
LyricsProviderMusixmatch = "musixmatch"
LyricsProviderAppleMusic = "apple_music"
LyricsProviderQQMusic = "qqmusic"
LyricsProviderSpotify = "spotify"
LyricsProviderDeezer = "deezer"
LyricsProviderYouTube = "youtube"
LyricsProviderKugou = "kugou"
LyricsProviderGenius = "genius"
LyricsProviderLyricsPlus = "lyricsplus"
)
var DefaultLyricsProviders = []string{
LyricsProviderLRCLIB,
LyricsProviderMusixmatch,
LyricsProviderNetease,
LyricsProviderAppleMusic,
LyricsProviderQQMusic,
}
var (
@@ -71,6 +74,7 @@ type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
AppleElrcWordSync bool `json:"apple_elrc_word_sync"`
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
}
@@ -78,9 +82,12 @@ var defaultLyricsFetchOptions = LyricsFetchOptions{
IncludeTranslationNetease: false,
IncludeRomanizationNetease: false,
MultiPersonWordByWord: true,
AppleElrcWordSync: false,
MusixmatchLanguage: "",
}
var instrumentalTrackPattern = regexp.MustCompile(`(?i)(?:^|[\s\[(\-])(?:instrumental|inst\.?)(?:[\s\])]|$)`)
var (
lyricsFetchOptionsMu sync.RWMutex
lyricsFetchOptions = defaultLyricsFetchOptions
@@ -101,6 +108,12 @@ func SetLyricsProviderOrder(providers []string) {
LyricsProviderMusixmatch: true,
LyricsProviderAppleMusic: true,
LyricsProviderQQMusic: true,
LyricsProviderSpotify: true,
LyricsProviderDeezer: true,
LyricsProviderYouTube: true,
LyricsProviderKugou: true,
LyricsProviderGenius: true,
LyricsProviderLyricsPlus: true,
}
var valid []string
@@ -131,10 +144,16 @@ func GetLyricsProviderOrder() []string {
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics"},
{"id": LyricsProviderSpotify, "name": "Spotify", "has_proxy_dependency": true, "description": "Spotify synced lyrics"},
{"id": LyricsProviderDeezer, "name": "Deezer", "has_proxy_dependency": true, "description": "Deezer lyrics"},
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
{"id": LyricsProviderLyricsPlus, "name": "LyricsPlus", "has_proxy_dependency": true, "description": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)"},
}
}
@@ -152,12 +171,18 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
lyricsFetchOptionsMu.Lock()
defer lyricsFetchOptionsMu.Unlock()
changed := lyricsFetchOptions != normalized
lyricsFetchOptions = normalized
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
if changed {
globalLyricsCache.ClearAll()
}
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v apple_elrc=%v musixmatch_lang=%q\n",
normalized.IncludeTranslationNetease,
normalized.IncludeRomanizationNetease,
normalized.MultiPersonWordByWord,
normalized.AppleElrcWordSync,
normalized.MusixmatchLanguage,
)
}
@@ -411,6 +436,16 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
primaryArtist := normalizeArtistName(artistName)
fetchOptions := GetLyricsFetchOptions()
if isLikelyInstrumentalTrack(trackName) {
GoLog("[Lyrics] Track marked instrumental by title heuristic, skipping lyrics search: %s - %s\n", artistName, trackName)
instrumental := &LyricsResponse{
Instrumental: true,
Source: "Heuristic: Instrumental",
}
globalLyricsCache.Set(artistName, trackName, durationSec, instrumental)
return instrumental, nil
}
extManager := getExtensionManager()
var extensionProviders []*extensionProviderWrapper
if extManager != nil {
@@ -521,9 +556,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
case LyricsProviderAppleMusic:
appleClient := NewAppleMusicClient()
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
if err != nil && primaryArtist != artistName {
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
}
case LyricsProviderQQMusic:
@@ -533,6 +568,84 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
}
case LyricsProviderSpotify:
spotifyClient := NewSpotifyLyricsClient()
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = spotifyClient.FetchLyrics("", simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderDeezer:
deezerClient := NewDeezerLyricsClient()
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
case LyricsProviderYouTube:
youtubeClient := NewYouTubeLyricsClient()
lyrics, err = youtubeClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = youtubeClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = youtubeClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderKugou:
kugouClient := NewKugouLyricsClient()
lyrics, err = kugouClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = kugouClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = kugouClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderGenius:
geniusClient := NewGeniusLyricsClient()
lyrics, err = geniusClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = geniusClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderLyricsPlus:
lyricsPlusClient := NewLyricsPlusClient()
lyrics, err = lyricsPlusClient.FetchLyrics(
trackName,
primaryArtist,
"",
durationSec,
fetchOptions.MultiPersonWordByWord,
fetchOptions.AppleElrcWordSync,
)
if err != nil && primaryArtist != artistName {
lyrics, err = lyricsPlusClient.FetchLyrics(
trackName,
artistName,
"",
durationSec,
fetchOptions.MultiPersonWordByWord,
fetchOptions.AppleElrcWordSync,
)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = lyricsPlusClient.FetchLyrics(
simplifiedTrack,
primaryArtist,
"",
durationSec,
fetchOptions.MultiPersonWordByWord,
fetchOptions.AppleElrcWordSync,
)
}
default:
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
continue
@@ -764,6 +877,41 @@ func msToLRCTimestampInline(ms int64) string {
return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
}
// extractLyricsSourceFromLRC reads the provider recorded in the LRC [by:] tag,
// e.g. "[by:SpotiFLAC-Mobile (source: LRCLIB)]". Returns "" when absent.
const lrcSourceMarker = "(source: "
func lyricsSourceUsesPaxsenix(source string) bool {
s := strings.ToLower(strings.TrimSpace(source))
if s == "" {
return false
}
if strings.HasPrefix(s, "lrclib") ||
strings.HasPrefix(s, "extension:") ||
strings.HasPrefix(s, "heuristic") {
return false
}
return true
}
func extractLyricsSourceFromLRC(lrc string) string {
for _, line := range strings.Split(lrc, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(strings.ToLower(trimmed), "[by:") {
continue
}
idx := strings.Index(trimmed, lrcSourceMarker)
if idx < 0 {
return ""
}
rest := strings.TrimSpace(trimmed[idx+len(lrcSourceMarker):])
rest = strings.TrimSuffix(rest, "]")
rest = strings.TrimSuffix(rest, ")")
return strings.TrimSpace(rest)
}
return ""
}
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 {
return ""
@@ -773,7 +921,21 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
source := strings.TrimSpace(lyrics.Source)
if source == "" {
source = strings.TrimSpace(lyrics.Provider)
}
credit := "SpotiFLAC-Mobile"
if lyricsSourceUsesPaxsenix(source) {
credit = "SpotiFLAC-Mobile via Paxsenix API"
}
if source == "" {
builder.WriteString(fmt.Sprintf("[by:%s]\n", credit))
} else {
builder.WriteString(
fmt.Sprintf("[by:%s %s%s)]\n", credit, lrcSourceMarker, source),
)
}
builder.WriteString("\n")
if lyrics.SyncType == "LINE_SYNCED" {
@@ -847,6 +1009,15 @@ func normalizeArtistName(name string) string {
return strings.TrimSpace(result)
}
func isLikelyInstrumentalTrack(name string) bool {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return false
}
return instrumentalTrackPattern.MatchString(trimmed)
}
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
if lrcContent == "" {
return "", fmt.Errorf("empty LRC content")
+224 -33
View File
@@ -7,7 +7,9 @@ import (
"math"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
@@ -15,6 +17,8 @@ type AppleMusicClient struct {
httpClient *http.Client
}
const appleMusicCatalogBaseURL = "https://amp-api.music.apple.com/v1/catalog/us"
type appleMusicSearchResult struct {
ID string `json:"id"`
SongName string `json:"songName"`
@@ -23,9 +27,33 @@ type appleMusicSearchResult struct {
Duration int `json:"duration"`
}
type appleMusicCatalogSearchResponse struct {
Results struct {
Songs *struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
} `json:"songs"`
} `json:"results"`
Resources *struct {
Songs map[string]struct {
Attributes struct {
Name string `json:"name"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
DurationInMillis int `json:"durationInMillis"`
} `json:"attributes"`
} `json:"songs"`
} `json:"resources"`
}
type paxResponse struct {
Type string `json:"type"` // "Syllable" or "Line"
Content []paxLyrics `json:"content"` // List of lyric lines
Type string `json:"type"` // "Syllable" or "Line"
Content []paxLyrics `json:"content"`
ELRC string `json:"elrc"`
ELRCMultiPerson string `json:"elrcMultiPerson"`
Plain string `json:"plain"`
TTMLContent string `json:"ttmlContent"`
}
type paxLyrics struct {
@@ -44,6 +72,11 @@ type paxLyricDetail struct {
EndTime *int `json:"endtime"`
}
var (
appleMusicTokenMu sync.Mutex
appleMusicCachedToken string
)
func NewAppleMusicClient() *AppleMusicClient {
return &AppleMusicClient{
httpClient: NewMetadataHTTPClient(20 * time.Second),
@@ -100,36 +133,164 @@ func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackNam
return &results[bestIndex]
}
func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
appleMusicTokenMu.Lock()
defer appleMusicTokenMu.Unlock()
if appleMusicCachedToken != "" {
return appleMusicCachedToken, nil
}
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
if err != nil {
return "", fmt.Errorf("failed to create apple music page request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch apple music page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("apple music page returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read apple music page: %w", err)
}
indexPath := regexp.MustCompile(`/assets/index~[^"' <]+\.js`).FindString(string(body))
if indexPath == "" {
return "", fmt.Errorf("apple music index script not found")
}
jsReq, err := http.NewRequest("GET", "https://beta.music.apple.com"+indexPath, nil)
if err != nil {
return "", fmt.Errorf("failed to create apple music script request: %w", err)
}
jsReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
jsResp, err := c.httpClient.Do(jsReq)
if err != nil {
return "", fmt.Errorf("failed to fetch apple music script: %w", err)
}
defer jsResp.Body.Close()
if jsResp.StatusCode != http.StatusOK {
return "", fmt.Errorf("apple music script returned HTTP %d", jsResp.StatusCode)
}
jsBody, err := io.ReadAll(jsResp.Body)
if err != nil {
return "", fmt.Errorf("failed to read apple music script: %w", err)
}
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
if token == "" {
return "", fmt.Errorf("apple music token not found")
}
appleMusicCachedToken = token
return token, nil
}
func clearAppleMusicToken() {
appleMusicTokenMu.Lock()
defer appleMusicTokenMu.Unlock()
appleMusicCachedToken = ""
}
func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusicSearchResult, error) {
params := url.Values{}
params.Set("term", query)
params.Set("types", "songs")
params.Set("limit", "25")
params.Set("l", "en-US")
params.Set("platform", "web")
params.Set("format[resources]", "map")
params.Set("include[songs]", "artists")
params.Set("extend", "artistUrl")
searchURL := appleMusicCatalogBaseURL + "/search?" + params.Encode()
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create apple music catalog request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Origin", "https://music.apple.com")
req.Header.Set("Referer", "https://music.apple.com/")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
req.Header.Set("x-apple-renewal", "true")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("apple music catalog search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("apple music catalog search unauthorized")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
}
var searchResp appleMusicCatalogSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode apple music catalog response: %w", err)
}
if searchResp.Results.Songs == nil || searchResp.Resources == nil {
return nil, nil
}
results := make([]appleMusicSearchResult, 0, len(searchResp.Results.Songs.Data))
for _, item := range searchResp.Results.Songs.Data {
detail, ok := searchResp.Resources.Songs[item.ID]
if !ok {
continue
}
attr := detail.Attributes
results = append(results, appleMusicSearchResult{
ID: item.ID,
SongName: attr.Name,
ArtistName: attr.ArtistName,
AlbumName: attr.AlbumName,
Duration: attr.DurationInMillis,
})
}
return results, nil
}
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return "", fmt.Errorf("empty search query")
}
encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
req, err := http.NewRequest("GET", searchURL, nil)
token, err := c.getAppleMusicToken()
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
return "", err
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
clearAppleMusicToken()
token, tokenErr := c.getAppleMusicToken()
if tokenErr != nil {
return "", tokenErr
}
searchResp, err = c.searchSongWithToken(token, strings.TrimSpace(query))
}
if err != nil {
return "", fmt.Errorf("apple music search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
}
var searchResp []appleMusicSearchResult
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return "", fmt.Errorf("failed to decode apple music response: %w", err)
return "", err
}
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
@@ -173,25 +334,50 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
return bodyStr, nil
}
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
var stringPayload string
if err := json.Unmarshal([]byte(rawJSON), &stringPayload); err == nil {
stringPayload = strings.TrimSpace(stringPayload)
if stringPayload != "" {
return stringPayload, nil
}
}
var paxResp paxResponse
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil &&
(paxResp.Content != nil ||
strings.TrimSpace(paxResp.ELRCMultiPerson) != "" ||
strings.TrimSpace(paxResp.ELRC) != "" ||
strings.TrimSpace(paxResp.Plain) != "" ||
strings.TrimSpace(paxResp.TTMLContent) != "") {
if preserveWordTiming && multiPersonWordByWord && strings.TrimSpace(paxResp.ELRCMultiPerson) != "" {
return strings.TrimSpace(paxResp.ELRCMultiPerson), nil
}
if preserveWordTiming && strings.TrimSpace(paxResp.ELRC) != "" {
return strings.TrimSpace(paxResp.ELRC), nil
}
if strings.TrimSpace(paxResp.Plain) != "" && len(paxResp.Content) == 0 {
return strings.TrimSpace(paxResp.Plain), nil
}
if len(paxResp.Content) == 0 {
return "", fmt.Errorf("unsupported apple music lyrics payload")
}
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
}
var directLyrics []paxLyrics
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord, preserveWordTiming), nil
}
return "", fmt.Errorf("failed to parse pax lyrics response")
}
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail, preserveWordTiming bool) {
lastStart := ""
for _, syllable := range details {
if syllable.Timestamp != nil {
if preserveWordTiming && syllable.Timestamp != nil {
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
if start != lastStart {
builder.WriteString(start)
@@ -204,13 +390,13 @@ func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
builder.WriteString(" ")
}
if syllable.EndTime != nil {
if preserveWordTiming && syllable.EndTime != nil {
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
}
}
}
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool, preserveWordTiming bool) string {
var sb strings.Builder
for i, line := range content {
@@ -230,11 +416,11 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
}
}
appendPaxLyricDetail(&sb, line.Text)
appendPaxLyricDetail(&sb, line.Text, preserveWordTiming)
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
sb.WriteString("\n[bg:")
appendPaxLyricDetail(&sb, line.BackgroundText)
appendPaxLyricDetail(&sb, line.BackgroundText, preserveWordTiming)
sb.WriteString("]")
}
} else {
@@ -253,6 +439,7 @@ func (c *AppleMusicClient) FetchLyrics(
artistName string,
durationSec float64,
multiPersonWordByWord bool,
preserveWordTiming bool,
) (*LyricsResponse, error) {
songID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
@@ -267,8 +454,12 @@ func (c *AppleMusicClient) FetchLyrics(
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
}
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
if err != nil {
trimmedRaw := strings.TrimSpace(rawLyrics)
if strings.HasPrefix(trimmedRaw, "{") || strings.HasPrefix(trimmedRaw, "[") {
return nil, err
}
lrcText = rawLyrics
}
+243
View File
@@ -0,0 +1,243 @@
package gobackend
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// LyricsPlus (KPOE) provider.
//
// LyricsPlus aggregates word-by-word ("karaoke") synced lyrics from Apple
// Music, Musixmatch, Spotify and QQ Music via a community-run backend. It
// frequently has word-level timing for tracks that other providers only offer
// line-synced or not at all.
//
// API: GET {server}/v2/lyrics/get?title=&artist=&album=&duration=&isrc=
// The response is the KPOE JSON format which we convert into the same enhanced
// LRC text the Apple/QQ providers emit, so embedding/export behaves identically.
// Public LyricsPlus / KPOE servers (mirrors). Tried in order with failover.
// Sourced from the upstream YouLy+ client server list.
var lyricsPlusServers = []string{
"https://lyricsplus.prjktla.my.id",
"https://lyricsplus.atomix.one",
"https://lyricsplus.binimum.org",
"https://lyricsplus.prjktla.workers.dev",
"https://lyricsplus-seven.vercel.app",
"https://lyrics-plus-backend.vercel.app",
}
type LyricsPlusClient struct {
httpClient *http.Client
}
func NewLyricsPlusClient() *LyricsPlusClient {
return &LyricsPlusClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
type lyricsPlusSyllable struct {
Text string `json:"text"`
Time float64 `json:"time"` // absolute ms
Duration float64 `json:"duration"` // ms
IsBackground bool `json:"isBackground"`
}
type lyricsPlusLine struct {
Time float64 `json:"time"` // absolute ms
Duration float64 `json:"duration"` // ms
Text string `json:"text"`
Syllabus []lyricsPlusSyllable `json:"syllabus"`
}
type lyricsPlusResponse struct {
Type string `json:"type"` // "Word" | "Line" | "Syllable" | "None"
Lyrics []lyricsPlusLine `json:"lyrics"`
}
// FetchLyrics tries each LyricsPlus server in order until one returns usable
// lyrics. multiPersonWordByWord and preserveWordTiming mirror the Apple/QQ
// options so word/background timing is only emitted when the user enabled it.
func (c *LyricsPlusClient) FetchLyrics(
trackName,
artistName,
isrc string,
durationSec float64,
multiPersonWordByWord bool,
preserveWordTiming bool,
) (*LyricsResponse, error) {
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
return nil, fmt.Errorf("lyricsplus: missing track or artist")
}
var lastErr error
for _, server := range lyricsPlusServers {
lyrics, err := c.fetchFromServer(server, trackName, artistName, isrc, durationSec, multiPersonWordByWord, preserveWordTiming)
if err == nil && lyricsHasUsableText(lyrics) {
return lyrics, nil
}
if err != nil {
lastErr = err
GoLog("[Lyrics] LyricsPlus server %s failed: %v\n", server, err)
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("lyricsplus: no lyrics found")
}
func (c *LyricsPlusClient) fetchFromServer(
server,
trackName,
artistName,
isrc string,
durationSec float64,
multiPersonWordByWord bool,
preserveWordTiming bool,
) (*LyricsResponse, error) {
base := strings.TrimRight(strings.TrimSpace(server), "/")
if base == "" {
return nil, fmt.Errorf("empty server")
}
params := url.Values{}
params.Set("title", trackName)
params.Set("artist", artistName)
if durationSec > 0 {
params.Set("duration", strconv.FormatFloat(durationSec, 'f', 3, 64))
}
if strings.TrimSpace(isrc) != "" {
params.Set("isrc", strings.TrimSpace(isrc))
}
fullURL := base + "/v2/lyrics/get?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
// Retry without the ISRC filter, which can be too strict.
if strings.TrimSpace(isrc) != "" {
return c.fetchFromServer(server, trackName, artistName, "", durationSec, multiPersonWordByWord, preserveWordTiming)
}
return nil, fmt.Errorf("lyrics not found")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var payload lyricsPlusResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("failed to decode lyricsplus response: %w", err)
}
if len(payload.Lyrics) == 0 {
return nil, fmt.Errorf("lyricsplus returned no lines")
}
lrcText := buildLyricsPlusLRC(&payload, multiPersonWordByWord, preserveWordTiming)
if strings.TrimSpace(lrcText) == "" {
return nil, fmt.Errorf("lyricsplus produced empty lyrics")
}
lyrics := lyricsResponseFromText(lrcText, "LyricsPlus")
return lyrics, nil
}
// buildLyricsPlusLRC converts the KPOE JSON into enhanced LRC text. When word
// timing is available and enabled, each syllable is emitted as an inline
// <mm:ss.xx> tag (matching the Apple/QQ output); otherwise a line-synced LRC
// is produced from the full line text.
func buildLyricsPlusLRC(resp *lyricsPlusResponse, multiPersonWordByWord bool, preserveWordTiming bool) string {
isWordType := strings.EqualFold(resp.Type, "Word") || strings.EqualFold(resp.Type, "Syllable")
var sb strings.Builder
first := true
for _, line := range resp.Lyrics {
lineText := line.Text
hasSyllables := len(line.Syllabus) > 0
timestamp := msToLRCTimestamp(int64(line.Time))
if isWordType && preserveWordTiming && hasSyllables {
mainSyllables := make([]lyricsPlusSyllable, 0, len(line.Syllabus))
bgSyllables := make([]lyricsPlusSyllable, 0)
for _, syl := range line.Syllabus {
if syl.IsBackground {
bgSyllables = append(bgSyllables, syl)
} else {
mainSyllables = append(mainSyllables, syl)
}
}
if len(mainSyllables) == 0 {
mainSyllables = line.Syllabus
bgSyllables = nil
}
if !first {
sb.WriteString("\n")
}
first = false
sb.WriteString(timestamp)
appendLyricsPlusSyllables(&sb, mainSyllables)
if multiPersonWordByWord && len(bgSyllables) > 0 {
sb.WriteString("\n[bg:")
appendLyricsPlusSyllables(&sb, bgSyllables)
sb.WriteString("]")
}
continue
}
// Line-synced fallback. Reconstruct text from syllables if needed.
if strings.TrimSpace(lineText) == "" && hasSyllables {
var lineBuilder strings.Builder
for _, syl := range line.Syllabus {
lineBuilder.WriteString(syl.Text)
}
lineText = lineBuilder.String()
}
lineText = strings.TrimSpace(lineText)
if lineText == "" {
continue
}
if !first {
sb.WriteString("\n")
}
first = false
sb.WriteString(timestamp)
sb.WriteString(lineText)
}
return strings.TrimSpace(sb.String())
}
// appendLyricsPlusSyllables writes each syllable as "<mm:ss.xx>text". KPOE
// already embeds spacing inside the syllable text, so no extra spaces are added.
func appendLyricsPlusSyllables(sb *strings.Builder, syllables []lyricsPlusSyllable) {
for _, syl := range syllables {
sb.WriteString("<")
sb.WriteString(msToLRCTimestampInline(int64(syl.Time)))
sb.WriteString(">")
sb.WriteString(syl.Text)
}
}
-26
View File
@@ -16,32 +16,6 @@ type MusixmatchClient struct {
baseURL string
}
type musixmatchSearchResponse struct {
ID int64 `json:"id"`
SongName string `json:"songName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
Artwork string `json:"artwork"`
ReleaseDate string `json:"releaseDate"`
Duration int `json:"duration"`
URL string `json:"url"`
AlbumID int64 `json:"albumId"`
HasSyncedLyrics bool `json:"hasSyncedLyrics"`
HasUnsyncedLyrics bool `json:"hasUnsyncedLyrics"`
AvailableLanguages []string `json:"availableLanguages"`
OriginalLanguage string `json:"originalLanguage"`
SyncedLyrics *musixmatchLyricsResponse `json:"syncedLyrics"`
UnsyncedLyrics *musixmatchLyricsResponse `json:"unsyncedLyrics"`
}
type musixmatchLyricsResponse struct {
ID int64 `json:"id"`
Duration int `json:"duration"`
Language string `json:"language"`
UpdatedTime string `json:"updatedTime"`
Lyrics string `json:"lyrics"`
}
func NewMusixmatchClient() *MusixmatchClient {
return &MusixmatchClient{
httpClient: NewMetadataHTTPClient(15 * time.Second),

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