Compare commits

..

1175 Commits

Author SHA1 Message Date
Zarz Eleutherius 637504db41 New translations app_en.arb (Ukrainian) 2026-04-14 08:44:40 +07:00
Zarz Eleutherius 48e499eaeb New translations app_en.arb (Turkish) 2026-04-14 08:44:39 +07:00
Zarz Eleutherius 7372a34d25 New translations app_en.arb (Hindi) 2026-04-14 08:44:38 +07:00
Zarz Eleutherius 4411d80a19 New translations app_en.arb (Indonesian) 2026-04-14 08:44:37 +07:00
Zarz Eleutherius 316d7677c7 New translations app_en.arb (Chinese Traditional) 2026-04-14 08:44:36 +07:00
Zarz Eleutherius fa061fc587 New translations app_en.arb (Chinese Simplified) 2026-04-14 08:44:35 +07:00
Zarz Eleutherius 38605080b7 New translations app_en.arb (Russian) 2026-04-14 08:44:34 +07:00
Zarz Eleutherius 478179169c New translations app_en.arb (Portuguese) 2026-04-14 08:44:32 +07:00
Zarz Eleutherius 83594831a9 New translations app_en.arb (Dutch) 2026-04-14 08:44:31 +07:00
Zarz Eleutherius cec3acfff6 New translations app_en.arb (Korean) 2026-04-14 08:44:30 +07:00
Zarz Eleutherius 18ef5e0aee New translations app_en.arb (Japanese) 2026-04-14 08:44:29 +07:00
Zarz Eleutherius f674eef681 New translations app_en.arb (German) 2026-04-14 08:44:28 +07:00
Zarz Eleutherius 1b95085977 New translations app_en.arb (Spanish) 2026-04-14 08:44:27 +07:00
Zarz Eleutherius 35ab00a7bd New translations app_en.arb (French) 2026-04-14 08:44:26 +07:00
Zarz Eleutherius f2ec276b91 Update source file app_en.arb 2026-04-14 08:44:24 +07:00
Zarz Eleutherius ee797756f7 New translations app_en.arb (Ukrainian) 2026-04-13 08:33:42 +07:00
Zarz Eleutherius 2d54ac1d12 New translations app_en.arb (Ukrainian) 2026-04-12 06:54:47 +07:00
Zarz Eleutherius 87f624c685 New translations app_en.arb (Spanish) 2026-04-11 06:49:41 +07:00
Zarz Eleutherius 48ec563aa1 New translations app_en.arb (Turkish) 2026-04-10 06:27:43 +07:00
Zarz Eleutherius 070e0cd8cf New translations app_en.arb (Ukrainian) 2026-04-08 01:58:38 +07:00
Zarz Eleutherius 948d7aa735 New translations app_en.arb (Turkish) 2026-04-08 01:58:36 +07:00
Zarz Eleutherius 1aaa033dc1 New translations app_en.arb (Ukrainian) 2026-04-07 01:58:46 +07:00
Zarz Eleutherius 56a7ec0763 New translations app_en.arb (Turkish) 2026-04-07 01:58:44 +07:00
Zarz Eleutherius 7da5f69551 New translations app_en.arb (Hindi) 2026-04-07 01:58:43 +07:00
Zarz Eleutherius ace70de9e1 New translations app_en.arb (Indonesian) 2026-04-07 01:58:42 +07:00
Zarz Eleutherius e7369bb4a9 New translations app_en.arb (Chinese Traditional) 2026-04-07 01:58:41 +07:00
Zarz Eleutherius cd6598a866 New translations app_en.arb (Chinese Simplified) 2026-04-07 01:58:39 +07:00
Zarz Eleutherius 93dc95ccc4 New translations app_en.arb (Russian) 2026-04-07 01:58:38 +07:00
Zarz Eleutherius 951518ba81 New translations app_en.arb (Portuguese) 2026-04-07 01:58:37 +07:00
Zarz Eleutherius e3449ded60 New translations app_en.arb (Dutch) 2026-04-07 01:58:36 +07:00
Zarz Eleutherius 913db0c97d New translations app_en.arb (Korean) 2026-04-07 01:58:34 +07:00
Zarz Eleutherius f675c1f223 New translations app_en.arb (Japanese) 2026-04-07 01:58:33 +07:00
Zarz Eleutherius 2d8ee8b04f New translations app_en.arb (German) 2026-04-07 01:58:32 +07:00
Zarz Eleutherius ef1f1b381f New translations app_en.arb (Spanish) 2026-04-07 01:58:31 +07:00
Zarz Eleutherius e2dce6c623 New translations app_en.arb (French) 2026-04-07 01:58:29 +07:00
Zarz Eleutherius 1da8228f89 Update source file app_en.arb 2026-04-07 01:58:27 +07:00
Zarz Eleutherius 67df645ca0 New translations app_en.arb (German) 2026-04-06 01:49:58 +07:00
Zarz Eleutherius 258166c973 New translations app_en.arb (Turkish) 2026-04-05 01:48:02 +07:00
Zarz Eleutherius 780aa8494b New translations app_en.arb (Hindi) 2026-04-05 01:48:01 +07:00
Zarz Eleutherius 0a539bde70 New translations app_en.arb (Indonesian) 2026-04-05 01:48:00 +07:00
Zarz Eleutherius 5232af5a36 New translations app_en.arb (Chinese Traditional) 2026-04-05 01:47:59 +07:00
Zarz Eleutherius 01b4c257ff New translations app_en.arb (Chinese Simplified) 2026-04-05 01:47:58 +07:00
Zarz Eleutherius 914c179a1c New translations app_en.arb (Russian) 2026-04-05 01:47:57 +07:00
Zarz Eleutherius 6d3bea874c New translations app_en.arb (Portuguese) 2026-04-05 01:47:56 +07:00
Zarz Eleutherius 10a3fed592 New translations app_en.arb (Dutch) 2026-04-05 01:47:55 +07:00
Zarz Eleutherius 9245b7fe5d New translations app_en.arb (Korean) 2026-04-05 01:47:54 +07:00
Zarz Eleutherius bca72234be New translations app_en.arb (Japanese) 2026-04-05 01:47:53 +07:00
Zarz Eleutherius d3d77688bf New translations app_en.arb (German) 2026-04-05 01:47:52 +07:00
Zarz Eleutherius a1fb0f1db7 New translations app_en.arb (Spanish) 2026-04-05 01:47:51 +07:00
Zarz Eleutherius 2f58426385 New translations app_en.arb (French) 2026-04-05 01:47:50 +07:00
Zarz Eleutherius f495ce4340 Update source file app_en.arb 2026-04-05 01:47:48 +07:00
Zarz Eleutherius cace5993d2 New translations app_en.arb (German) 2026-04-04 01:51:35 +07:00
Zarz Eleutherius d0da28209e New translations app_en.arb (Spanish) 2026-04-03 00:21:28 +07:00
Zarz Eleutherius ea30ac3eb9 New translations app_en.arb (Turkish) 2026-03-31 16:23:59 +07:00
Zarz Eleutherius 1ff9963209 New translations app_en.arb (Hindi) 2026-03-31 16:23:58 +07:00
Zarz Eleutherius 1e00024ca2 New translations app_en.arb (Indonesian) 2026-03-31 16:23:57 +07:00
Zarz Eleutherius e685bef532 New translations app_en.arb (Chinese Traditional) 2026-03-31 16:23:56 +07:00
Zarz Eleutherius 4b2d61ef2d New translations app_en.arb (Chinese Simplified) 2026-03-31 16:23:54 +07:00
Zarz Eleutherius d79d739200 New translations app_en.arb (Russian) 2026-03-31 16:23:53 +07:00
Zarz Eleutherius 08281b9302 New translations app_en.arb (Portuguese) 2026-03-31 16:23:52 +07:00
Zarz Eleutherius 95b85b9ad4 New translations app_en.arb (Dutch) 2026-03-31 16:23:51 +07:00
Zarz Eleutherius d1ff6b6311 New translations app_en.arb (Korean) 2026-03-31 16:23:50 +07:00
Zarz Eleutherius fe159efc5e New translations app_en.arb (Japanese) 2026-03-31 16:23:48 +07:00
Zarz Eleutherius 92b83fc7ba New translations app_en.arb (German) 2026-03-31 16:23:47 +07:00
Zarz Eleutherius f828e21b39 New translations app_en.arb (Spanish) 2026-03-31 16:23:46 +07:00
Zarz Eleutherius 581b394d46 New translations app_en.arb (French) 2026-03-31 16:23:44 +07:00
Zarz Eleutherius 7f120f3a7e Update source file app_en.arb 2026-03-31 16:23:41 +07:00
Zarz Eleutherius 7c4714db36 New translations app_en.arb (Turkish) 2026-03-30 16:25:37 +07:00
Zarz Eleutherius 7c3f8e6297 New translations app_en.arb (Hindi) 2026-03-30 16:25:36 +07:00
Zarz Eleutherius cb416fffd4 New translations app_en.arb (Indonesian) 2026-03-30 16:25:34 +07:00
Zarz Eleutherius a46644abd3 New translations app_en.arb (Chinese Traditional) 2026-03-30 16:25:33 +07:00
Zarz Eleutherius 660cca6fc4 New translations app_en.arb (Chinese Simplified) 2026-03-30 16:25:31 +07:00
Zarz Eleutherius ef9715f54a New translations app_en.arb (Russian) 2026-03-30 16:25:30 +07:00
Zarz Eleutherius b38132d3b7 New translations app_en.arb (Portuguese) 2026-03-30 16:25:28 +07:00
Zarz Eleutherius 1b00569cb2 New translations app_en.arb (Dutch) 2026-03-30 16:25:27 +07:00
Zarz Eleutherius 4e2539167a New translations app_en.arb (Korean) 2026-03-30 16:25:25 +07:00
Zarz Eleutherius dff7d33461 New translations app_en.arb (Japanese) 2026-03-30 16:25:24 +07:00
Zarz Eleutherius ec228788ca New translations app_en.arb (German) 2026-03-30 16:25:22 +07:00
Zarz Eleutherius 83b6ce7648 New translations app_en.arb (Spanish) 2026-03-30 16:25:21 +07:00
Zarz Eleutherius 7f669680cd New translations app_en.arb (French) 2026-03-30 16:25:19 +07:00
Zarz Eleutherius 1e2e201eff Update source file app_en.arb 2026-03-30 16:25:16 +07:00
Zarz Eleutherius b2fcfe5f18 New translations app_en.arb (Turkish) 2026-03-27 15:58:26 +07:00
Zarz Eleutherius 9d9c3ff1e8 New translations app_en.arb (Hindi) 2026-03-27 15:58:25 +07:00
Zarz Eleutherius 071d096314 New translations app_en.arb (Indonesian) 2026-03-27 15:58:24 +07:00
Zarz Eleutherius 983971ec83 New translations app_en.arb (Chinese Traditional) 2026-03-27 15:58:23 +07:00
Zarz Eleutherius 2adcffd95f New translations app_en.arb (Chinese Simplified) 2026-03-27 15:58:22 +07:00
Zarz Eleutherius bd3734a68c New translations app_en.arb (Russian) 2026-03-27 15:58:20 +07:00
Zarz Eleutherius 0a0eefaf3f New translations app_en.arb (Portuguese) 2026-03-27 15:58:19 +07:00
Zarz Eleutherius 2b65d5aedd New translations app_en.arb (Dutch) 2026-03-27 15:58:18 +07:00
Zarz Eleutherius 77f5fc68c8 New translations app_en.arb (Korean) 2026-03-27 15:58:17 +07:00
Zarz Eleutherius fd79bde4ab New translations app_en.arb (Japanese) 2026-03-27 15:58:16 +07:00
Zarz Eleutherius a99b0230f4 New translations app_en.arb (German) 2026-03-27 15:58:15 +07:00
Zarz Eleutherius 81e41e2f6c New translations app_en.arb (Spanish) 2026-03-27 15:58:14 +07:00
Zarz Eleutherius 97ff250465 New translations app_en.arb (French) 2026-03-27 15:58:13 +07:00
Zarz Eleutherius f8700ee017 Update source file app_en.arb 2026-03-27 15:58:11 +07:00
Zarz Eleutherius d7a009cade New translations app_en.arb (Turkish) 2026-03-26 16:01:55 +07:00
Zarz Eleutherius a2d8feebb3 New translations app_en.arb (Hindi) 2026-03-26 16:01:54 +07:00
Zarz Eleutherius e6f9b4c01d New translations app_en.arb (Indonesian) 2026-03-26 16:01:52 +07:00
Zarz Eleutherius 9682f30fd6 New translations app_en.arb (Chinese Traditional) 2026-03-26 16:01:51 +07:00
Zarz Eleutherius 5c85cb5575 New translations app_en.arb (Chinese Simplified) 2026-03-26 16:01:50 +07:00
Zarz Eleutherius 4bc93381d4 New translations app_en.arb (Russian) 2026-03-26 16:01:49 +07:00
Zarz Eleutherius a41c62548a New translations app_en.arb (Portuguese) 2026-03-26 16:01:47 +07:00
Zarz Eleutherius fd028b6d6c New translations app_en.arb (Dutch) 2026-03-26 16:01:46 +07:00
Zarz Eleutherius 01dd2d52c3 New translations app_en.arb (Korean) 2026-03-26 16:01:44 +07:00
Zarz Eleutherius 3f777eb1cb New translations app_en.arb (Japanese) 2026-03-26 16:01:43 +07:00
Zarz Eleutherius ebfb5150e7 New translations app_en.arb (German) 2026-03-26 16:01:42 +07:00
Zarz Eleutherius aed56e7717 New translations app_en.arb (Spanish) 2026-03-26 16:01:41 +07:00
Zarz Eleutherius 7f4f69620b New translations app_en.arb (French) 2026-03-26 16:01:40 +07:00
zarzet da9d64ccfd chore: update VirusTotal hash in README 2026-03-25 17:15:36 +07:00
zarzet 02e64b7a3c Merge remote-tracking branch 'origin/main' 2026-03-25 17:12:06 +07:00
zarzet a435009d4d fix(qobuz): skip SongLink when ISRC is already available 2026-03-25 17:09:54 +07:00
github-actions[bot] 9ca73a99a6 chore: update AltStore source to v3.9.0 2026-03-25 09:29:51 +00:00
zarzet 4974284760 fix(l10n): consolidate Crowdin locale files and fix ICU plural warnings
- Replace app_es-ES.arb, app_pt-PT.arb, app_tr-TR.arb (hyphen format)
  with properly named app_es_ES.arb, app_pt_PT.arb, app_tr.arb
- Fix @@locale values to match Flutter filename convention (underscore)
- Fix ICU plural syntax: remove redundant 'one {}' before '=1{...}'
  in es_ES, pt_PT, tr translations
- Regenerate l10n output files
2026-03-25 16:12:37 +07:00
Zarz Eleutherius a0306bd345 Merge pull request #258 from zarzet/l10n_dev
New Crowdin updates
2026-03-25 16:08:16 +07:00
zarzet ea7e594c68 Merge remote-tracking branch 'origin/dev' into l10n_dev
# Conflicts:
#	lib/l10n/arb/app_es-ES.arb
#	lib/l10n/arb/app_id.arb
#	lib/l10n/arb/app_pt-PT.arb
#	lib/l10n/arb/app_tr-TR.arb
2026-03-25 16:08:10 +07:00
Zarz Eleutherius d00a84f1b9 New translations app_en.arb (Indonesian) 2026-03-25 16:02:56 +07:00
Zarz Eleutherius 58b6203681 New translations app_en.arb (Chinese Simplified) 2026-03-25 16:02:54 +07:00
Zarz Eleutherius d299144c47 New translations app_en.arb (Russian) 2026-03-25 16:02:53 +07:00
Zarz Eleutherius 40b224e5a1 New translations app_en.arb (Dutch) 2026-03-25 16:02:51 +07:00
Zarz Eleutherius 7021e5493f New translations app_en.arb (Japanese) 2026-03-25 16:02:49 +07:00
Zarz Eleutherius 68bbc8a259 New translations app_en.arb (German) 2026-03-25 16:02:47 +07:00
zarzet be94a59441 chore: bump version to 3.9.0+115, add new translators
- Bump app version from 3.8.8 to 3.9.0 (build 115)
- Add 4 new Crowdin translators: unkn0wn (Indonesian), lunching1272
  (Chinese Simplified), Сергей Ильченко (Russian), Girl-lass (Chinese
  Simplified)
2026-03-25 15:47:08 +07:00
zarzet 3a73aee1b7 feat: add home feed provider setting, fix Qobuz cover URL propagation
- Add homeFeedProvider field to AppSettings with picker UI in extensions page
- Update explore_provider to respect user's home feed provider preference
- Add normalizeCoverReference() and normalizeRemoteHttpUrl() to filter
  invalid cover URLs (no scheme, no host, protocol-relative)
- Apply cover URL normalization across all screens and providers to
  prevent 'no host specified in URI' errors from Qobuz
- Propagate CoverURL from QobuzDownloadResult through Go backend so
  cover art is available even when request metadata is incomplete
2026-03-25 15:46:22 +07:00
zarzet c91154ea3e feat: add built-in search provider in settings, fix bottom sheet overflow 2026-03-25 15:46:12 +07:00
zarzet 4f365ca7fe feat: add built-in Tidal/Qobuz search with recommended service picker
- Add SearchAll() for Tidal and Qobuz in Go backend (tracks, artists, albums)
- Add searchTidalAll/searchQobuzAll platform routing for Android and iOS
- Add Tidal/Qobuz options to search provider dropdown in home tab
- Show (Recommended) label and auto-select service in download picker
2026-03-25 13:52:57 +07:00
zarzet 98fdc0ed7c feat: restore Tidal HIGH (AAC 320kbps) lossy quality option (closes #242)
Requested by @okinaau in issue #242 — brings back the ability to
download tracks in lossy format for users on low storage devices.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- Wrap popular track items in Consumer for scoped provider watches

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

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

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

- Memoize filter/sort computations per build pass

- Isolate queue header/list into dedicated Consumer slivers

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

Resolves Android 10+ scoped storage permission issues
2026-02-06 07:09:57 +07:00
Zarz Eleutherius 4273edd836 New translations app_en.arb (Russian) 2026-02-05 13:19:12 +07:00
Zarz Eleutherius 7ce41fc1c1 Update source file app_en.arb 2026-02-05 13:19:10 +07:00
zarzet 7ade57e010 perf: optimize all providers for mobile networks with retry logic
- Add retry logic with exponential backoff to all providers (Qobuz, Tidal, Amazon, Deezer)
- Increase API timeouts: 15s → 25s (Qobuz/Tidal/Deezer), 30s (Amazon)
- Extract QobuzID/TidalID directly from SongLink URLs
- Add SongLink lookup strategy before ISRC search in Qobuz
- Cache hit now uses GetTrackByID() directly instead of re-searching
- Pre-warm cache tries SongLink first before direct ISRC search
2026-02-05 09:12:25 +07:00
Zarz Eleutherius 6e7c766945 Fix VirusTotal badge link formatting in README 2026-02-04 18:29:22 +07:00
Zarz Eleutherius 55b457a4c0 Update VirusTotal badge link in README
Updated VirusTotal badge link in README.md.
2026-02-04 18:28:50 +07:00
zarzet 65a152cada fix: persist metadata and download provider priority across app restarts
- Save priority order to SharedPreferences when set
- Load from SharedPreferences on app start, sync to Go backend
- Fixes issue where custom order reverted to default after restart
2026-02-04 17:45:07 +07:00
Zarz Eleutherius fb7a576e00 New translations app_en.arb (Turkish) 2026-02-04 12:53:12 +07:00
Zarz Eleutherius 30a559b279 New translations app_en.arb (Hindi) 2026-02-04 12:53:11 +07:00
Zarz Eleutherius f77d5fdf14 New translations app_en.arb (Indonesian) 2026-02-04 12:53:10 +07:00
Zarz Eleutherius 0a0667889c New translations app_en.arb (Chinese Traditional) 2026-02-04 12:53:09 +07:00
Zarz Eleutherius 14d8cd54d7 New translations app_en.arb (Chinese Simplified) 2026-02-04 12:53:08 +07:00
Zarz Eleutherius 5fa3d405e6 New translations app_en.arb (Russian) 2026-02-04 12:53:07 +07:00
Zarz Eleutherius 34eb335fd0 New translations app_en.arb (Portuguese) 2026-02-04 12:53:06 +07:00
Zarz Eleutherius c910530927 New translations app_en.arb (Dutch) 2026-02-04 12:53:05 +07:00
Zarz Eleutherius 69e1a6cf6b New translations app_en.arb (Korean) 2026-02-04 12:53:04 +07:00
Zarz Eleutherius bd84613624 New translations app_en.arb (Japanese) 2026-02-04 12:53:02 +07:00
Zarz Eleutherius 0b4777fc6b New translations app_en.arb (German) 2026-02-04 12:53:01 +07:00
Zarz Eleutherius e22813caec New translations app_en.arb (Spanish) 2026-02-04 12:53:00 +07:00
Zarz Eleutherius 8f6e8432de New translations app_en.arb (French) 2026-02-04 12:52:59 +07:00
zarzet e4a6177cb5 feat: add metadata screen support for local library items
- TrackMetadataScreen now accepts both DownloadHistoryItem and LocalLibraryItem
- Tapping local library tracks in Library tab opens metadata screen
- Shows extracted metadata from audio files (artist, album, track number, etc)
- Supports local cover art display from extracted covers
2026-02-04 12:42:16 +07:00
zarzet 34ffbca3e8 fix: improve share intent handling for YouTube Music links
- Check both path and message fields for shared URLs
- Wait for extensions to initialize before handling shared URLs
- Add retry logic (3 attempts) for extension URL handlers with empty data
- Show error message if metadata fails to load after retries
2026-02-04 12:18:53 +07:00
zarzet f8acd8f3b6 fix: show search filter bar only after results load 2026-02-04 11:48:38 +07:00
zarzet 9956f051ac feat: disable Amazon in service picker (fallback only) 2026-02-04 11:26:29 +07:00
zarzet b33ae905a2 feat: add support for Deezer, Tidal, and YT Music links 2026-02-04 11:18:52 +07:00
zarzet 11eb0aa12a docs: shorten changelog format for v3.3.6 and v3.4.0 2026-02-04 10:57:34 +07:00
zarzet 7c08321ce3 refactor: continue code cleanup 2026-02-04 10:42:51 +07:00
zarzet e20becdca7 refactor: remove more redundant comments 2026-02-04 10:20:04 +07:00
zarzet 24897e25e2 refactor: clean up redundant comments and code 2026-02-04 10:05:32 +07:00
zarzet 2dc4cef583 feat: add device info to log export
- Add exportWithDeviceInfo() method to LogBuffer
- Include app version, build number in export
- Include device info: manufacturer, model, OS version, SDK level
- Include log summary with counts by level
- Update copy/share logs to use new method with device info
2026-02-04 09:53:40 +07:00
zarzet 34c95fbd81 fix: persist lastScannedAt to SharedPreferences
- lastScannedAt was only stored in memory state, lost on app restart
- Now saves to SharedPreferences after scan completes
- Loads lastScannedAt when loading library from database
- Clears lastScannedAt when clearing library
2026-02-04 09:49:22 +07:00
zarzet 9071db9b88 feat: block duplicate downloads for tracks in local library
- Add local library check before downloading in all screens
- Show 'Already exists in your library' snackbar when track is found
- Add 'In Library' badge to track items in album and playlist screens
- Update home_tab, album_screen, playlist_screen, artist_screen
- Add snackbarAlreadyInLibrary localization string
2026-02-04 09:39:54 +07:00
zarzet 3eb2fdd7fa fix: resolve analyzer warnings
- Fix empty catch block in track_provider.dart with comment
- Replace deprecated withOpacity() with withValues(alpha:) in library_settings_page.dart
2026-02-03 23:15:32 +07:00
zarzet 99e0d3d361 refactor: remove cloud save feature entirely
This feature was deemed unnecessary and was adding complexity to the app.

Removed:
- lib/services/cloud_upload_service.dart
- lib/providers/upload_queue_provider.dart
- lib/screens/settings/cloud_settings_page.dart
- lib/screens/settings/widgets/ (cloud status hero card)
- All cloud* settings from AppSettings model
- All cloud-related localization keys (~70 keys)
- Cloud settings from settings_provider.dart
- Cloud menu item from settings_tab.dart
- Cloud upload trigger from download_queue_provider.dart

Dependencies removed:
- webdav_client: ^1.2.2
- dartssh2: ^2.13.0

CHANGELOG updated to remove all cloud save references.
2026-02-03 23:10:19 +07:00
zarzet a2eb89e230 feat: add advanced library filters (source, quality, format, date)
- Add filter button next to Select in Library tab (All/Singles)
- Source filter: All, Downloaded, Local
- Quality filter: Hi-Res (24bit), CD (16bit), Lossy
- Format filter: Dynamic based on available formats (FLAC, MP3, etc.)
- Date filter: Today, This Week, This Month, This Year
- Badge shows active filter count
- Long-press filter button to reset all filters
- Filters apply to both All and Singles tabs
- Add 18 new localization keys for filter UI
2026-02-03 22:52:29 +07:00
zarzet b21e953ef1 perf: optimize LocalAlbumScreen with track caching
- Cache sorted tracks, disc groups, and disc numbers on init
- Rebuild caches only when tracks change (didUpdateWidget)
- Use cached dominant color from PaletteService if available
- Defer color extraction to post-frame callback
- Eliminates redundant sorting and grouping on every build
2026-02-03 22:25:49 +07:00
zarzet 0ef086ce57 fix: improve queue_tab code quality and consistency
- Fix redundant ternary operator for placeholder icon (Icons.music_note)
- Normalize UnifiedLibraryItem.albumKey to lowercase for consistency
- Fix empty state condition to include local albums check
- Add caching for unified items and local library filtering
- Optimize file existence check with scheduled updates
- Refactor filtering logic for better performance
2026-02-03 22:25:11 +07:00
zarzet 72d45746a5 feat: add local library albums to Albums tab with unified grid and LocalAlbumScreen
- Add local library albums as clickable cards in Albums filter
- Merge downloaded and local albums into single unified grid (fix layout gaps)
- Create LocalAlbumScreen for viewing local album details with:
  - Cover art display with dominant color extraction
  - Album info card with Local badge and quality info
  - Track list with disc grouping support
  - Selection mode with delete functionality
  - UI consistent with DownloadedAlbumScreen (Card + ListTile layout)
- Add singles filter support for local library singles
- Add extractDominantColorFromFile to PaletteService
- Add delete(id) method to LibraryDatabase
- Add removeItem(id) method to LocalLibraryNotifier
- Update CHANGELOG.md for v3.4.0
2026-02-03 21:51:40 +07:00
zarzet 9c22f41a3e feat: rename History tab to Library and show local library items
- Rename bottom navigation 'History' to 'Library'
- Add Local Library section showing scanned tracks below downloaded tracks
- Add source badge to each item (Downloaded/Local) for clear identification
- Add new localization strings for Library tab and source badges
- Local library items can be played directly from the library tab
2026-02-03 19:53:53 +07:00
zarzet 22f001a735 feat: add SFTP host key management and security improvements
- Add HTTPS URL validation for extension store registry and downloads
- Add Reset SFTP Host Key button (per-server)
- Add Reset All SFTP Host Keys button
- Add SFTP host key verification with TOFU (Trust On First Use)
- Update cloud upload service with host key storage
- Add flutter_secure_storage dependency for secure password storage
2026-02-03 19:25:09 +07:00
zarzet 26d464d3c7 feat: add local library scanning with duplicate detection
- Add Go backend library scanner for FLAC, M4A, MP3, Opus, OGG files
- Read metadata from file tags (ISRC, track name, artist, album, bit depth, sample rate)
- Fallback to filename parsing when tags unavailable
- Add SQLite database for O(1) duplicate lookups
- Show 'In Library' badge on search results for existing tracks
- Match by ISRC (exact) or track name + artist (fuzzy)
- Add Library Settings page with scan, cleanup, and clear actions
- Add 30+ localization strings for library feature
2026-02-03 19:24:28 +07:00
zarzet 3d6a3f8d04 fix: improve failed downloads export organization
- Create 'failed_downloads' subfolder to keep exports separate from music
- Use daily files (YYYY-MM-DD) instead of timestamp per export
- Append to existing file if same day, create new file on new day
- Add time prefix to each entry for tracking within the day
- Keeps failed downloads organized and prevents file fragmentation
2026-02-03 15:26:25 +07:00
zarzet 39ce22a9e2 refactor(ui): move Download Network and Auto Export settings higher
- Relocate Download Network and Auto Export Failed settings
- Now appears after File Settings, before Storage Access section
- Grouped together under 'Download' section header
- More visible and easier to access
2026-02-03 15:22:43 +07:00
zarzet 88f9a65d11 fix(l10n): fix ICU plural syntax warnings in Russian and Turkish
- Remove redundant =1 plural forms that override 'one' category
- Russian: fix 10 plural strings (tracks, albums, releases, delete messages)
- Turkish: fix 5 plural strings (tracks, albums, delete messages)
2026-02-03 15:17:22 +07:00
zarzet 663ee12bcc feat: implement cloud upload with WebDAV and SFTP support
- Add CloudUploadService with WebDAV and SFTP upload methods
- Add UploadQueueProvider for managing upload queue
- Integrate upload trigger after download completes
- Update CloudSettingsPage with actual connection test and queue UI
- Add webdav_client ^1.2.2 and dartssh2 ^2.13.0 dependencies
- Remove Google Drive option (not implemented)
- Bump version to 3.4.0+72
2026-02-03 15:14:29 +07:00
Zarz Eleutherius b3c98cecc3 New translations app_en.arb (Turkish) 2026-02-02 08:25:49 +07:00
Zarz Eleutherius 49a18a977b New translations app_en.arb (Hindi) 2026-02-02 08:25:48 +07:00
Zarz Eleutherius a5d0feeedf New translations app_en.arb (Indonesian) 2026-02-02 08:25:47 +07:00
Zarz Eleutherius a574e73b44 New translations app_en.arb (Chinese Traditional) 2026-02-02 08:25:46 +07:00
Zarz Eleutherius a66f6a739f New translations app_en.arb (Chinese Simplified) 2026-02-02 08:25:45 +07:00
Zarz Eleutherius cc7e1b54b6 New translations app_en.arb (Russian) 2026-02-02 08:25:44 +07:00
Zarz Eleutherius 28cb7fcd3d New translations app_en.arb (Portuguese) 2026-02-02 08:25:43 +07:00
Zarz Eleutherius aeb370beca New translations app_en.arb (Dutch) 2026-02-02 08:25:42 +07:00
Zarz Eleutherius 239707e2da New translations app_en.arb (Korean) 2026-02-02 08:25:41 +07:00
Zarz Eleutherius c1e2778735 New translations app_en.arb (Japanese) 2026-02-02 08:25:40 +07:00
Zarz Eleutherius fb608a554d New translations app_en.arb (German) 2026-02-02 08:25:39 +07:00
Zarz Eleutherius 7561065802 New translations app_en.arb (Spanish) 2026-02-02 08:25:38 +07:00
Zarz Eleutherius 56c8d89999 New translations app_en.arb (French) 2026-02-02 08:25:37 +07:00
Zarz Eleutherius 9192760f3c Update source file app_en.arb 2026-02-02 08:25:35 +07:00
zarzet 8c201b5b4a feat: add Cloud Save settings page (UI only)
- Add cloud upload settings to settings model (provider, URL, credentials, path)
- Create CloudSettingsPage with WebDAV and SFTP provider options
- Add Cloud Save menu item in main settings
- Add localization strings for cloud settings
- Actual upload implementation will come in v3.4.0

Settings fields added:
- cloudUploadEnabled: toggle auto-upload
- cloudProvider: 'webdav', 'sftp', or 'none'
- cloudServerUrl: server URL
- cloudUsername/cloudPassword: credentials
- cloudRemotePath: destination folder path
2026-02-02 07:20:15 +07:00
zarzet 5e19178bc0 feat: WiFi-only download mode
- Add network mode setting (any/wifi_only) in settings model
- Add connectivity check in download queue processor
- Downloads pause automatically when on mobile data (if wifi_only enabled)
- Add UI toggle in Download Settings page
- Add localization strings for network mode setting
- Bump version to 3.3.6+71
2026-02-02 07:13:03 +07:00
zarzet 107d9ca007 feat: export failed downloads to TXT file
- Add Export button in queue when there are failed downloads
- Add auto-export setting in Download Settings
- Export includes track name, artist, Spotify/Deezer URL, and error message
- Clear Failed action in snackbar after export
2026-02-02 07:02:04 +07:00
Zarz Eleutherius 423695c24d Update VirusTotal badge link in README.md 2026-02-01 22:00:38 +07:00
zarzet 4633c7253a fix(ios): block iCloud Drive folder selection
- Detect iCloud path and show error when user tries to select it
- Fallback to app Documents folder if iCloud path detected at runtime
- Add localization string for iCloud not supported error
2026-02-01 21:14:45 +07:00
zarzet 8ace180fa8 fix: service selection priority and Amazon fallback-only
- Fix service selection ignored: user's preferred service now takes priority
- Add preferredService parameter to downloadWithExtensions
- Gray out Amazon in service picker (fallback only)
- Clean up unused code in Go backend
2026-02-01 21:04:35 +07:00
zarzet b9c3f2f0dd fix: remove duplicate plugin registration warning
Remove manual GeneratedPluginRegistrant.registerWith() call since
super.configureFlutterEngine() already handles this automatically.
2026-02-01 20:18:51 +07:00
zarzet 81b0eede8c v3.3.5: Same as 3.3.1 but fixes crash issues caused by FFmpeg
Changes:
- Fix FFmpeg crash issues during M4A to MP3/Opus conversion
- Add format picker (MP3/Opus) when selecting Tidal Lossy 320kbps
- Fix Deezer album blank screen when opened from home
- LRC file generation now follows lyrics mode setting
- Version bump to 3.3.5 (build 70)
2026-02-01 20:12:00 +07:00
zarzet eb0cdbeba8 feat(tidal): convert M4A to MP3/Opus for HIGH quality, remove LOSSY option
- Add tidalHighFormat setting (mp3_320 or opus_128) for Tidal HIGH quality
- Add convertM4aToLossy() in FFmpegService for M4A to MP3/Opus conversion
- Remove inefficient LOSSY option (FLAC download then convert)
- Update download_queue_provider to handle HIGH quality conversion
- Clean up LOSSY references from download_service_picker and log messages
- Update Go backend: amazon.go, tidal.go, metadata.go improvements
- UI: minor updates to album, playlist, and home screens
2026-02-01 19:07:02 +07:00
zarzet ee212a0e48 fix(tidal): fix DASH download path for HIGH quality AAC
- Fix m4aPath calculation in downloadFromManifest for HIGH quality
- When outputPath is already .m4a, use it directly instead of appending .m4a
- Reset httputil.go to fix build errors from merge conflict
2026-02-01 17:44:19 +07:00
zarzet 2073516666 feat(tidal): add native AAC 320kbps quality option
- Add HIGH quality option (AAC 320kbps) for Tidal downloads
- Download directly as M4A without FLAC conversion
- Embed metadata to M4A using EmbedM4AMetadata()
- Skip M4A to FLAC conversion in download provider for HIGH quality
- Add AAC 320kbps option in settings page (Tidal only)
- Add HIGH quality option in download service picker
2026-02-01 17:26:25 +07:00
zarzet 9d479b61d6 Merge main into dev 2026-02-01 17:25:01 +07:00
zarzet 203e6bc4eb docs: fix #108 reference in changelog - MP3 403 error fix 2026-02-01 16:23:16 +07:00
zarzet 5f1ffbee4e fix(android): manually register Flutter plugins for proper initialization 2026-02-01 16:15:19 +07:00
Zarz Eleutherius 40ec24db69 New translations app_en.arb (Turkish) 2026-02-01 08:04:39 +07:00
Zarz Eleutherius ba8d0a3438 New translations app_en.arb (Hindi) 2026-02-01 08:04:38 +07:00
Zarz Eleutherius 82decf99a6 New translations app_en.arb (Indonesian) 2026-02-01 08:04:37 +07:00
Zarz Eleutherius 6ba9fc1fec New translations app_en.arb (Chinese Traditional) 2026-02-01 08:04:36 +07:00
Zarz Eleutherius 715d94c2ed New translations app_en.arb (Chinese Simplified) 2026-02-01 08:04:35 +07:00
Zarz Eleutherius e1a722f479 New translations app_en.arb (Russian) 2026-02-01 08:04:34 +07:00
Zarz Eleutherius edbe12c512 New translations app_en.arb (Portuguese) 2026-02-01 08:04:33 +07:00
Zarz Eleutherius 9fc6542792 New translations app_en.arb (Dutch) 2026-02-01 08:04:32 +07:00
Zarz Eleutherius 4c01ee26c2 New translations app_en.arb (Korean) 2026-02-01 08:04:31 +07:00
Zarz Eleutherius 813b9fcf61 New translations app_en.arb (Japanese) 2026-02-01 08:04:30 +07:00
Zarz Eleutherius fe070e0177 New translations app_en.arb (German) 2026-02-01 08:04:29 +07:00
Zarz Eleutherius 423bb87ed8 New translations app_en.arb (Spanish) 2026-02-01 08:04:28 +07:00
Zarz Eleutherius 1641f51b0c New translations app_en.arb (French) 2026-02-01 08:04:27 +07:00
Zarz Eleutherius 3f78a1f3d1 Update source file app_en.arb 2026-02-01 08:04:25 +07:00
zarzet b29dc63337 fix: release build crash on Android 15+ (API 36)
- Add ProGuard rules for Flutter plugins (path_provider, local_notifications, receive_sharing_intent, etc.)
- Upgrade Go to 1.25.6 for 16KB page alignment support
- Expand ProGuard rules for Go backend and Kotlin coroutines
- Fix R8 stripping plugin implementations in release builds
2026-01-31 17:17:08 +07:00
zarzet 29699117dc fix(ci): add -tags ios to gomobile bind for iOS build 2026-01-31 15:34:34 +07:00
zarzet 3c75f9ecc6 fix(ios): separate uTLS code with build tags for iOS compatibility
- Create httputil_utls.go with uTLS/Cloudflare bypass for Android (build tag: !ios)
- Create httputil_ios.go with fallback implementation for iOS (build tag: ios)
- Remove uTLS-dependent code from httputil.go (shared code)
- Fixes iOS build failure due to undefined DNS resolver symbols (_res_9_*)
2026-01-31 15:31:21 +07:00
zarzet 79340703c1 fix(ios): add filter parameter to SearchDeezerAll call 2026-01-31 15:16:51 +07:00
zarzet df23e3f96c docs: remove outdated suspension notice from README 2026-01-31 15:12:14 +07:00
zarzet d9f788ddeb chore: fix linter warnings and remove unused functions 2026-01-31 15:12:13 +07:00
zarzet 62afbdcaaa feat: show quality badge for lossy formats (MP3/Opus) in history 2026-01-31 15:12:13 +07:00
zarzet 6c578cfd78 fix: show correct audio quality for lossy files in metadata screen 2026-01-31 15:12:13 +07:00
zarzet a17abec799 fix: preserve golang.org/x/mobile/bind dependency for gomobile 2026-01-31 15:12:13 +07:00
zarzet 2a71b70a34 perf: optimize cache cleanup and reduce unnecessary widget rebuilds 2026-01-31 15:12:13 +07:00
zarzet 03f77daf19 docs: add VPN compatibility to changelog 2026-01-31 15:12:13 +07:00
zarzet 270b0c1af6 feat(http): add uTLS Chrome fingerprint for Cloudflare bypass
- Added uTLS library to mimic Chrome's TLS fingerprint
- Uses HTTP/2 for optimal performance with uTLS
- Auto-detects Cloudflare challenge and retries with Chrome fingerprint
- Helps VPN users bypass Cloudflare TLS fingerprint detection
2026-01-31 15:12:12 +07:00
zarzet 317bb523a4 docs: add optional all files access to changelog 2026-01-31 15:12:12 +07:00
zarzet 2c8ad87b7e feat(android13): make All Files Access optional
- Android 13+ now only requires READ_MEDIA_AUDIO by default
- MANAGE_EXTERNAL_STORAGE is optional and can be enabled in Settings
- Added 'All Files Access' toggle in Download Settings (Android 13+ only)
- Users who encounter write errors can enable full storage access
- Respects privacy-conscious users who prefer limited permissions
2026-01-31 15:12:12 +07:00
zarzet 5e06729029 docs: shorten changelog entries for v3.3.0 2026-01-31 15:12:11 +07:00
zarzet 21bcfe1157 docs: update changelog with Opus cover art fix details 2026-01-31 15:12:11 +07:00
zarzet 3aeaaaf4f2 fix(opus): implement METADATA_BLOCK_PICTURE for cover art embedding
- OGG/Opus container doesn't support video stream for cover art
- Implemented FLAC picture block format with base64 encoding
- Cover art now embedded via METADATA_BLOCK_PICTURE Vorbis comment tag
- Follows OGG/Vorbis specification for embedded pictures
2026-01-31 15:12:11 +07:00
zarzet 3a9d1395db feat(ui): add Clear All button to download queue header (#96) 2026-01-31 15:12:11 +07:00
zarzet 90c46d99d4 chore: bump version to 3.3.0 2026-01-31 15:12:11 +07:00
zarzet 96f44fefd4 fix(ui): remove duplicate Embed Lyrics setting from Options page (#110) 2026-01-31 15:12:11 +07:00
zarzet 38a0a76b69 chore: update special thanks - add sjdonado (IDHS), remove DoubleDouble 2026-01-31 15:12:11 +07:00
zarzet 7fc73b6038 feat(backend): add IDHS as fallback link resolver when SongLink fails 2026-01-31 15:12:10 +07:00
zarzet 6b61dbc2da docs: update CHANGELOG with recent changes 2026-01-31 15:12:10 +07:00
zarzet fd3158fd15 feat: add search filters for Deezer default search
- Add filter parameter to Deezer SearchAll (track/artist/album/playlist)
- When filter is specified, increase limit for that type only
- Add default Deezer filters when not using extension search
- Reduce artist limit from 5 to 2 in home search results
- Filter bar now shows for both extension and default Deezer search
- Fix filter not being passed correctly during search (preserve filter state)
2026-01-31 15:12:10 +07:00
zarzet ff7135bf2c feat: add playlist search to Deezer default search
- Add SearchPlaylist class and parsing in track_provider.dart
- Add playlist search to Deezer SearchAll API (5 results)
- Add SearchPlaylistResult struct in Go backend
- Add _SearchPlaylistItemWidget for displaying playlists
- Add _navigateToSearchPlaylist method
- Update PlaylistScreen to support fetching tracks by playlistId
- Display playlists in search results alongside artists and albums
2026-01-31 15:12:10 +07:00
zarzet 74bac570c7 feat: unify search results display and add album search to Deezer
- Add SearchAlbumResult struct to Go backend
- Add album search to Deezer SearchAll() function (returns albums alongside tracks/artists)
- Change artist display from horizontal scroll to vertical list style (consistent with extension search)
- Add SearchAlbum class and searchAlbums field to TrackState
- Add _SearchArtistItemWidget and _SearchAlbumItemWidget for vertical list display
- Add _navigateToSearchAlbum method for navigating to album details
- Remove old horizontal artist scroll (_buildArtistSearchResults, _buildArtistCard)

Now default search (Deezer/Spotify) shows Artists, Albums, and Songs in the same vertical list style as extension search results.
2026-01-31 15:12:10 +07:00
zarzet 5f999035c3 fix(deezer): add pagination for albums and playlists with >25 tracks
- Deezer API default limit is 25 tracks per request

- Now fetches all tracks using pagination for albums >25 tracks

- Also fixes playlists with >25 tracks

- Fixes issue where Greatest Hits albums only showed 25 tracks
2026-01-31 15:12:09 +07:00
zarzet fa7b5a3559 docs: add Turkish translators credit
- Add Kaan (glai) and BedirhanGltkn as Turkish translators
2026-01-31 15:12:09 +07:00
zarzet 187821b2ae docs: add Japanese translator credit and fix Opus bitrate
- Add Re*Index.(ot_inc) as Japanese translator in About page

- Fix CHANGELOG: Opus is 128kbps not 256kbps
2026-01-31 15:12:09 +07:00
zarzet 1435ba9658 fix: add cover art embedding for Opus files
- Add embedMetadataToOpus() in FFmpegService

- Add _embedMetadataToOpus() in download queue provider

- Now both MP3 and Opus get cover art embedded after conversion

- Previously Opus files had no cover art (only audio was copied)
2026-01-31 15:12:09 +07:00
zarzet 62e2e1703c refactor: remove emojis from Go backend code
- Remove emoji detection (checkmark/cross) from GoLog() in logbuffer.go

- Remove emojis from log messages in tidal.go, qobuz.go, amazon.go

- Add code-style.md steering rule for no emojis in code

- Update logging.md to remove emoji examples
2026-01-31 15:12:09 +07:00
zarzet 21a732379b feat(backend): update Amazon and Qobuz download APIs
Amazon:
- Replace DoubleDouble service with AfkarXYZ API
- Simpler implementation without polling mechanism
- Remove rate limiting (not needed for AfkarXYZ)

Qobuz:
- Add qobuz.squid.wtf as additional API endpoint
- Add Jumo API as fallback when standard APIs fail
- Add XOR decoding for Jumo response
- Add quality fallback (27 -> 7 -> 6) for Jumo
2026-01-31 15:12:09 +07:00
zarzet 8ac035d146 chore: remove obsolete iOS-specific files
- Delete pubspec_ios.yaml (now identical to main pubspec.yaml)
- Delete build_assets/ffmpeg_service_ios.dart (main service works for both platforms)
- Remove iOS pubspec/FFmpeg swap steps from release.yml
- Both Android and iOS now use ffmpeg_kit_flutter_new_audio plugin
2026-01-31 15:11:19 +07:00
zarzet d7e7fb065e docs: update CHANGELOG for v3.2.2 2026-01-31 15:11:19 +07:00
zarzet 11d3b8ab3b chore(l10n): complete Turkish and Portuguese Portugal translations to 70%+ threshold
- Turkish (tr): 84% (533/638 keys)
- Portuguese Portugal (pt_PT): 89% (567/638 keys)

Both languages now included in supported locales.
2026-01-31 15:11:19 +07:00
zarzet 566e5996bc chore: fix locale file naming (dash to underscore) and regenerate l10n 2026-01-31 15:11:18 +07:00
Zarz Eleutherius 51618c7dbd New translations app_en.arb (German) 2026-01-31 15:11:18 +07:00
Zarz Eleutherius bdff3a6135 New translations app_en.arb (Russian) 2026-01-31 15:11:18 +07:00
Zarz Eleutherius ef7cd4ff5d New translations app_en.arb (Spanish) 2026-01-31 15:11:18 +07:00
Zarz Eleutherius 431e437dee New translations app_en.arb (Indonesian) 2026-01-31 15:11:18 +07:00
Zarz Eleutherius cebd43e75a New translations app_en.arb (Turkish) 2026-01-31 15:11:18 +07:00
Zarz Eleutherius 17bfbf95f2 New translations app_en.arb (Hindi) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius dad525be40 New translations app_en.arb (Indonesian) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius 7dd0dbd594 New translations app_en.arb (Chinese Traditional) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius a0bf423a50 New translations app_en.arb (Chinese Simplified) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius 288b060983 New translations app_en.arb (Russian) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius 5ba60d4fd0 New translations app_en.arb (Portuguese) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius 07dae97fe6 New translations app_en.arb (Dutch) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius b210f67728 New translations app_en.arb (Korean) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius 728d1d58c2 New translations app_en.arb (Japanese) 2026-01-31 15:11:16 +07:00
Zarz Eleutherius 6b9650d451 New translations app_en.arb (German) 2026-01-31 15:11:16 +07:00
Zarz Eleutherius 72ae9072bf New translations app_en.arb (Spanish) 2026-01-31 15:11:16 +07:00
Zarz Eleutherius e82263dc14 New translations app_en.arb (French) 2026-01-31 15:11:16 +07:00
Zarz Eleutherius f03b218775 New translations app_en.arb (Turkish) 2026-01-31 15:11:16 +07:00
Zarz Eleutherius c840b59ae1 New translations app_en.arb (Hindi) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius 1213fc449a New translations app_en.arb (Indonesian) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius ca21bb0f0c New translations app_en.arb (Chinese Traditional) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius 00555b2df6 New translations app_en.arb (Chinese Simplified) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius efca120470 New translations app_en.arb (Russian) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius a178c3943a New translations app_en.arb (Portuguese) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius 01ed1f20ad New translations app_en.arb (Dutch) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius e2bd67083e New translations app_en.arb (Korean) 2026-01-31 15:11:14 +07:00
Zarz Eleutherius 31fb0a87c9 New translations app_en.arb (Japanese) 2026-01-31 15:11:14 +07:00
Zarz Eleutherius ac4d9fc602 New translations app_en.arb (German) 2026-01-31 15:11:14 +07:00
Zarz Eleutherius 8b1b581dbe New translations app_en.arb (Spanish) 2026-01-31 15:11:14 +07:00
Zarz Eleutherius ebdaa24cfc New translations app_en.arb (French) 2026-01-31 15:11:14 +07:00
Zarz Eleutherius 5633e3adf8 New translations app_en.arb (Turkish) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius fcae5e066d New translations app_en.arb (Hindi) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius c312aea75f New translations app_en.arb (Indonesian) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius 1e6e19ecd2 New translations app_en.arb (Chinese Traditional) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius 0866b04766 New translations app_en.arb (Chinese Simplified) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius 78cef8d58e New translations app_en.arb (Russian) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius ce84aee8da New translations app_en.arb (Portuguese) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius 1ba1665215 New translations app_en.arb (Dutch) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius 60fb18c8e2 New translations app_en.arb (Korean) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius c042b490b8 New translations app_en.arb (Japanese) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius f544b46d97 New translations app_en.arb (German) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius 70759724fe New translations app_en.arb (Spanish) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius fbfe252df6 New translations app_en.arb (French) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius 2c3def8c7b New translations app_en.arb (Hindi) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius 47e67e8299 New translations app_en.arb (Indonesian) 2026-01-31 15:10:33 +07:00
Zarz Eleutherius ec15516230 New translations app_en.arb (Chinese Traditional) 2026-01-31 15:10:22 +07:00
Zarz Eleutherius 462013bc2a New translations app_en.arb (Chinese Simplified) 2026-01-31 15:10:22 +07:00
Zarz Eleutherius 6b5e53864d New translations app_en.arb (Russian) 2026-01-31 15:10:21 +07:00
Zarz Eleutherius a8a47589c8 New translations app_en.arb (Portuguese) 2026-01-31 15:10:21 +07:00
Zarz Eleutherius b9d567d421 New translations app_en.arb (Dutch) 2026-01-31 15:10:21 +07:00
Zarz Eleutherius 81c77af558 New translations app_en.arb (Korean) 2026-01-31 15:10:21 +07:00
Zarz Eleutherius 1121680da6 New translations app_en.arb (Japanese) 2026-01-31 15:10:20 +07:00
Zarz Eleutherius d31f2e8894 New translations app_en.arb (German) 2026-01-31 15:10:20 +07:00
Zarz Eleutherius 5895a59cb2 New translations app_en.arb (Spanish) 2026-01-31 15:10:20 +07:00
Zarz Eleutherius 3e5e8d7a42 New translations app_en.arb (French) 2026-01-31 15:10:20 +07:00
zarzet 518a7fd2cf feat: replace custom FFmpeg AAR with ffmpeg_kit_flutter plugin, add Lossy format support (MP3/Opus)
- Replace custom ffmpeg-kit-with-lame.aar with ffmpeg_kit_flutter_new_audio plugin
- Rename MP3 option to Lossy with format selection (MP3 320kbps or Opus 128kbps)
- Add convertFlacToOpus() and convertFlacToLossy() functions in FFmpegService
- Update settings model: enableMp3Option -> enableLossyOption, add lossyFormat field
- Update download_queue_provider to use LOSSY quality with format from settings
- Remove FFMPEG_CHANNEL MethodChannel from MainActivity.kt
- Delete custom FFmpeg AAR files from android/app/libs/
- Add new localization strings for lossy format options
2026-01-31 15:10:20 +07:00
zarzet 6c832d1754 fix: MP3 download returns 403 - download FLAC first then convert
When user selects MP3 quality, the app was sending 'MP3' directly to
Tidal/Qobuz APIs which don't support MP3 as a quality parameter,
resulting in 403 Forbidden errors.

Fix: Convert quality 'MP3' to 'LOSSLESS' before sending to backend,
then convert the downloaded FLAC to MP3 using FFmpeg (existing logic).
2026-01-31 15:10:19 +07:00
zarzet d898b5f23e chore: revert version to 3.2.2+66 2026-01-31 15:10:19 +07:00
zarzet c38a1428f1 chore: ignore Claude local settings file 2026-01-31 15:10:19 +07:00
zarzet 759eeccc1f fix: disable Impeller on legacy/problematic GPUs for stability
Add dynamic GPU detection to use Skia renderer instead of Impeller on:
- Known problematic device models (Nexus 5, Samsung Tab A7 Lite, etc.)
- Problematic chipsets (MSM8974, MT6762, etc.)
- Legacy GPUs (Adreno 300/400, Mali-400/T6, PowerVR SGX, etc.)
- Android versions < 8.0 (API 26)

This fixes SIGSEGV crashes in libsc-a3xx.so GPU shader compiler
on older Qualcomm Adreno devices when Impeller attempts to
compile Vulkan/OpenGL shaders.

Uses FlutterShellArgs --enable-impeller=false which is the only
reliable method since AndroidManifest meta-data is broken in
Flutter 3.27+ (flutter/flutter#160595)
2026-01-31 15:10:18 +07:00
zarzet d0bc3b203c feat: add search filter bar for extension custom search
- Add SearchFilter struct in Go backend and Dart
- Add filters array to SearchBehaviorConfig manifest
- Add selectedSearchFilter state to TrackProvider
- Add filter bar UI with FilterChips below search bar
- Filter bar only shows when search results exist or loading
- Preserve selectedSearchFilter during customSearch loading
- Pass filter option to extension customSearch
2026-01-31 15:10:18 +07:00
zarzet 831b68b6cc fix: update Telegram community link in About page 2026-01-31 15:10:18 +07:00
zarzet a06111f445 chore: bump version to 26.2.1+65 (new year.month.day format) 2026-01-31 15:10:18 +07:00
zarzet 31fdd30c13 fix: use --data-urlencode for Telegram message to handle special chars (+, &) 2026-01-31 15:10:17 +07:00
zarzet e207ef89d5 docs: remove outdated suspension notice from README 2026-01-31 14:57:36 +07:00
zarzet 1261da2e5b chore: fix linter warnings and remove unused functions 2026-01-31 14:55:46 +07:00
zarzet 0c917bc41e feat: show quality badge for lossy formats (MP3/Opus) in history 2026-01-31 14:52:20 +07:00
zarzet f525d6c7e6 fix: show correct audio quality for lossy files in metadata screen 2026-01-31 14:50:19 +07:00
zarzet ed7c67a622 fix: preserve golang.org/x/mobile/bind dependency for gomobile 2026-01-31 14:37:43 +07:00
zarzet 99281df5fb perf: optimize cache cleanup and reduce unnecessary widget rebuilds 2026-01-31 14:29:14 +07:00
zarzet 24c2fd6a15 docs: add VPN compatibility to changelog 2026-01-31 14:18:09 +07:00
zarzet ec3fe34dc0 feat(http): add uTLS Chrome fingerprint for Cloudflare bypass
- Added uTLS library to mimic Chrome's TLS fingerprint
- Uses HTTP/2 for optimal performance with uTLS
- Auto-detects Cloudflare challenge and retries with Chrome fingerprint
- Helps VPN users bypass Cloudflare TLS fingerprint detection
2026-01-31 14:17:23 +07:00
zarzet 56f36da5f9 docs: add optional all files access to changelog 2026-01-31 14:10:02 +07:00
zarzet 9bbd774175 feat(android13): make All Files Access optional
- Android 13+ now only requires READ_MEDIA_AUDIO by default
- MANAGE_EXTERNAL_STORAGE is optional and can be enabled in Settings
- Added 'All Files Access' toggle in Download Settings (Android 13+ only)
- Users who encounter write errors can enable full storage access
- Respects privacy-conscious users who prefer limited permissions
2026-01-31 14:08:59 +07:00
zarzet 020ac32ee6 docs: shorten changelog entries for v3.3.0 2026-01-31 13:55:11 +07:00
zarzet 67a72210ac docs: update changelog with Opus cover art fix details 2026-01-31 13:52:14 +07:00
zarzet 020f41fd1e fix(opus): implement METADATA_BLOCK_PICTURE for cover art embedding
- OGG/Opus container doesn't support video stream for cover art
- Implemented FLAC picture block format with base64 encoding
- Cover art now embedded via METADATA_BLOCK_PICTURE Vorbis comment tag
- Follows OGG/Vorbis specification for embedded pictures
2026-01-31 13:41:28 +07:00
zarzet 820eb8cc32 feat(ui): add Clear All button to download queue header (#96) 2026-01-31 13:21:12 +07:00
zarzet 47fa5c2009 chore: bump version to 3.3.0 2026-01-31 13:17:08 +07:00
zarzet 9b0c929423 fix(ui): remove duplicate Embed Lyrics setting from Options page (#110) 2026-01-31 13:06:07 +07:00
zarzet 93105a45fe chore: update special thanks - add sjdonado (IDHS), remove DoubleDouble 2026-01-31 13:03:32 +07:00
zarzet d8b2f4d367 feat(backend): add IDHS as fallback link resolver when SongLink fails 2026-01-31 12:54:11 +07:00
zarzet f1478bb2ca docs: update CHANGELOG with recent changes 2026-01-31 12:35:51 +07:00
zarzet 8b3c377688 feat: add search filters for Deezer default search
- Add filter parameter to Deezer SearchAll (track/artist/album/playlist)
- When filter is specified, increase limit for that type only
- Add default Deezer filters when not using extension search
- Reduce artist limit from 5 to 2 in home search results
- Filter bar now shows for both extension and default Deezer search
- Fix filter not being passed correctly during search (preserve filter state)
2026-01-31 12:34:58 +07:00
zarzet 8c98b02dca feat: add playlist search to Deezer default search
- Add SearchPlaylist class and parsing in track_provider.dart
- Add playlist search to Deezer SearchAll API (5 results)
- Add SearchPlaylistResult struct in Go backend
- Add _SearchPlaylistItemWidget for displaying playlists
- Add _navigateToSearchPlaylist method
- Update PlaylistScreen to support fetching tracks by playlistId
- Display playlists in search results alongside artists and albums
2026-01-31 12:12:14 +07:00
zarzet 3743e35e8a feat: unify search results display and add album search to Deezer
- Add SearchAlbumResult struct to Go backend
- Add album search to Deezer SearchAll() function (returns albums alongside tracks/artists)
- Change artist display from horizontal scroll to vertical list style (consistent with extension search)
- Add SearchAlbum class and searchAlbums field to TrackState
- Add _SearchArtistItemWidget and _SearchAlbumItemWidget for vertical list display
- Add _navigateToSearchAlbum method for navigating to album details
- Remove old horizontal artist scroll (_buildArtistSearchResults, _buildArtistCard)

Now default search (Deezer/Spotify) shows Artists, Albums, and Songs in the same vertical list style as extension search results.
2026-01-31 12:00:29 +07:00
zarzet 05a02de4a9 fix(deezer): add pagination for albums and playlists with >25 tracks
- Deezer API default limit is 25 tracks per request

- Now fetches all tracks using pagination for albums >25 tracks

- Also fixes playlists with >25 tracks

- Fixes issue where Greatest Hits albums only showed 25 tracks
2026-01-31 11:47:00 +07:00
zarzet c28378cbb5 docs: add Turkish translators credit
- Add Kaan (glai) and BedirhanGltkn as Turkish translators
2026-01-31 11:41:34 +07:00
zarzet b2bef63b6b docs: add Japanese translator credit and fix Opus bitrate
- Add Re*Index.(ot_inc) as Japanese translator in About page

- Fix CHANGELOG: Opus is 128kbps not 256kbps
2026-01-31 11:40:53 +07:00
zarzet 6513e14b21 fix: add cover art embedding for Opus files
- Add embedMetadataToOpus() in FFmpegService

- Add _embedMetadataToOpus() in download queue provider

- Now both MP3 and Opus get cover art embedded after conversion

- Previously Opus files had no cover art (only audio was copied)
2026-01-31 11:37:12 +07:00
zarzet fd53755ad6 refactor: remove emojis from Go backend code
- Remove emoji detection (checkmark/cross) from GoLog() in logbuffer.go

- Remove emojis from log messages in tidal.go, qobuz.go, amazon.go

- Add code-style.md steering rule for no emojis in code

- Update logging.md to remove emoji examples
2026-01-31 11:28:12 +07:00
zarzet 1dbacb3027 feat(backend): update Amazon and Qobuz download APIs
Amazon:
- Replace DoubleDouble service with AfkarXYZ API
- Simpler implementation without polling mechanism
- Remove rate limiting (not needed for AfkarXYZ)

Qobuz:
- Add qobuz.squid.wtf as additional API endpoint
- Add Jumo API as fallback when standard APIs fail
- Add XOR decoding for Jumo response
- Add quality fallback (27 -> 7 -> 6) for Jumo
2026-01-31 11:24:35 +07:00
zarzet 910d9a7662 chore: remove obsolete iOS-specific files
- Delete pubspec_ios.yaml (now identical to main pubspec.yaml)
- Delete build_assets/ffmpeg_service_ios.dart (main service works for both platforms)
- Remove iOS pubspec/FFmpeg swap steps from release.yml
- Both Android and iOS now use ffmpeg_kit_flutter_new_audio plugin
2026-01-31 11:18:50 +07:00
zarzet 09bd8c6b21 docs: update CHANGELOG for v3.2.2 2026-01-31 11:16:06 +07:00
zarzet 908d108858 chore(l10n): complete Turkish and Portuguese Portugal translations to 70%+ threshold
- Turkish (tr): 84% (533/638 keys)
- Portuguese Portugal (pt_PT): 89% (567/638 keys)

Both languages now included in supported locales.
2026-01-31 11:12:13 +07:00
zarzet 3135993cf4 chore: fix locale file naming (dash to underscore) and regenerate l10n 2026-01-31 10:56:26 +07:00
zarzet 7a315b5fd4 Merge PR #85: New Crowdin updates - localization updates for multiple languages 2026-01-31 10:54:18 +07:00
zarzet 4bd6dcc3d7 feat: replace custom FFmpeg AAR with ffmpeg_kit_flutter plugin, add Lossy format support (MP3/Opus)
- Replace custom ffmpeg-kit-with-lame.aar with ffmpeg_kit_flutter_new_audio plugin
- Rename MP3 option to Lossy with format selection (MP3 320kbps or Opus 128kbps)
- Add convertFlacToOpus() and convertFlacToLossy() functions in FFmpegService
- Update settings model: enableMp3Option -> enableLossyOption, add lossyFormat field
- Update download_queue_provider to use LOSSY quality with format from settings
- Remove FFMPEG_CHANNEL MethodChannel from MainActivity.kt
- Delete custom FFmpeg AAR files from android/app/libs/
- Add new localization strings for lossy format options
2026-01-31 08:03:38 +07:00
zarzet 3f7fa19cdf fix: MP3 download returns 403 - download FLAC first then convert
When user selects MP3 quality, the app was sending 'MP3' directly to
Tidal/Qobuz APIs which don't support MP3 as a quality parameter,
resulting in 403 Forbidden errors.

Fix: Convert quality 'MP3' to 'LOSSLESS' before sending to backend,
then convert the downloaded FLAC to MP3 using FFmpeg (existing logic).
2026-01-31 07:53:13 +07:00
Zarz Eleutherius 867ec4d125 Enhance README with support and disclaimer sections
Added a section for supporting the project and a disclaimer about usage.
2026-01-30 17:24:30 +07:00
Zarz Eleutherius 164467f3a2 Update GitHub badge link with refresh parameter 2026-01-28 18:54:57 +07:00
Zarz Eleutherius fc9a2ddc2a New translations app_en.arb (German) 2026-01-25 12:23:03 +07:00
Zarz Eleutherius 543cb45c11 Merge pull request #104 from Amonoman/main
Update about_page.dart
2026-01-25 03:20:53 +07:00
Zarz Eleutherius c49e5adc52 New translations app_en.arb (Russian) 2026-01-24 12:05:26 +07:00
Zarz Eleutherius 0fedd446ca New translations app_en.arb (Spanish) 2026-01-24 12:05:25 +07:00
zarzet 0c7b8a68d9 chore: revert version to 3.2.2+66 2026-01-24 09:06:36 +07:00
zarzet 6dd6accbcc chore: ignore Claude local settings file 2026-01-24 09:02:37 +07:00
zarzet ca67f7f79d fix: disable Impeller on legacy/problematic GPUs for stability
Add dynamic GPU detection to use Skia renderer instead of Impeller on:
- Known problematic device models (Nexus 5, Samsung Tab A7 Lite, etc.)
- Problematic chipsets (MSM8974, MT6762, etc.)
- Legacy GPUs (Adreno 300/400, Mali-400/T6, PowerVR SGX, etc.)
- Android versions < 8.0 (API 26)

This fixes SIGSEGV crashes in libsc-a3xx.so GPU shader compiler
on older Qualcomm Adreno devices when Impeller attempts to
compile Vulkan/OpenGL shaders.

Uses FlutterShellArgs --enable-impeller=false which is the only
reliable method since AndroidManifest meta-data is broken in
Flutter 3.27+ (flutter/flutter#160595)
2026-01-24 09:02:32 +07:00
zarzet 1aa12c5857 feat: add search filter bar for extension custom search
- Add SearchFilter struct in Go backend and Dart
- Add filters array to SearchBehaviorConfig manifest
- Add selectedSearchFilter state to TrackProvider
- Add filter bar UI with FilterChips below search bar
- Filter bar only shows when search results exist or loading
- Preserve selectedSearchFilter during customSearch loading
- Pass filter option to extension customSearch
2026-01-24 08:50:41 +07:00
Amonoman 80707fc438 Update about_page.dart
i changed it becouse "Max" is not my username
2026-01-23 20:34:43 +01:00
Zarz Eleutherius ff121dfeb8 New translations app_en.arb (Indonesian) 2026-01-23 11:53:28 +07:00
zarzet c3aa6a441b fix: update Telegram community link in About page 2026-01-22 07:58:55 +07:00
Zarz Eleutherius 496d32e35b New translations app_en.arb (Turkish) 2026-01-22 07:34:38 +07:00
Zarz Eleutherius 291fa58757 New translations app_en.arb (Hindi) 2026-01-22 07:34:37 +07:00
Zarz Eleutherius eddbc2f986 New translations app_en.arb (Indonesian) 2026-01-22 07:34:36 +07:00
Zarz Eleutherius 81b8281d2c New translations app_en.arb (Chinese Traditional) 2026-01-22 07:34:35 +07:00
Zarz Eleutherius 57f87d9a4c New translations app_en.arb (Chinese Simplified) 2026-01-22 07:34:33 +07:00
Zarz Eleutherius c9d0c57d86 New translations app_en.arb (Russian) 2026-01-22 07:34:32 +07:00
Zarz Eleutherius 54ab5a9243 New translations app_en.arb (Portuguese) 2026-01-22 07:34:31 +07:00
Zarz Eleutherius 17b6b27cd7 New translations app_en.arb (Dutch) 2026-01-22 07:34:30 +07:00
Zarz Eleutherius ed131ca1fd New translations app_en.arb (Korean) 2026-01-22 07:34:29 +07:00
Zarz Eleutherius 190d65cdee New translations app_en.arb (Japanese) 2026-01-22 07:34:28 +07:00
Zarz Eleutherius dbf2e337f0 New translations app_en.arb (German) 2026-01-22 07:34:27 +07:00
Zarz Eleutherius 12e76bed4f New translations app_en.arb (Spanish) 2026-01-22 07:34:26 +07:00
Zarz Eleutherius e00db80dae New translations app_en.arb (French) 2026-01-22 07:34:24 +07:00
Zarz Eleutherius 5de0aa8145 Update source file app_en.arb 2026-01-22 07:34:20 +07:00
zarzet 91ffb25027 chore: bump version to 26.2.1+65 (new year.month.day format) 2026-01-22 07:06:15 +07:00
zarzet 6bcbdfedf0 Merge branch 'main' into dev 2026-01-22 07:04:03 +07:00
zarzet 3f42128cb9 fix: update Telegram community link and VirusTotal hash for v3.2.1 2026-01-22 04:50:46 +07:00
zarzet ccb8f98df5 fix: use --data-urlencode for Telegram message to handle special chars (+, &) 2026-01-22 04:26:02 +07:00
zarzet 591a597333 Merge branch 'dev'
# Conflicts:
#	.github/workflows/release.yml
#	README.md
2026-01-22 04:01:24 +07:00
zarzet 6388f3a5b8 perf: optimize providers, caching, and reduce rebuilds
- Cache SharedPreferences.getInstance() in providers (settings, theme, recent_access)
- Pre-compute download counts in queue provider to avoid repeated filtering
- Add identical() caching for RecentAccessView in HomeTab
- Use selective watching for exploreProvider (sections, greeting, isLoading only)
- Move isYTMusicQuickPicks computation to ExploreSection.fromJson()
- Hoist static RegExp patterns to avoid repeated compilation
- Use batch operations for iOS path migration in history_database
- Replace containsKey+lookup with single lookup in palette_service
- Pre-compute lowercase strings outside filter loops in logger
- Fix _isLoaded race condition in DownloadHistoryNotifier
2026-01-22 03:56:47 +07:00
Zarz Eleutherius 22f52f4af2 New translations app_en.arb (Turkish) 2026-01-22 02:27:30 +07:00
Zarz Eleutherius ceaaff8c9b New translations app_en.arb (Hindi) 2026-01-22 02:27:29 +07:00
Zarz Eleutherius a318495046 New translations app_en.arb (Indonesian) 2026-01-22 02:27:27 +07:00
Zarz Eleutherius 8ffc6d3821 New translations app_en.arb (Chinese Traditional) 2026-01-22 02:27:26 +07:00
Zarz Eleutherius 2036e46da0 New translations app_en.arb (Chinese Simplified) 2026-01-22 02:27:25 +07:00
Zarz Eleutherius b82000e87c New translations app_en.arb (Russian) 2026-01-22 02:27:24 +07:00
Zarz Eleutherius 144906fd8f New translations app_en.arb (Portuguese) 2026-01-22 02:27:23 +07:00
Zarz Eleutherius 8a109e9013 New translations app_en.arb (Dutch) 2026-01-22 02:27:21 +07:00
Zarz Eleutherius ba05f6b470 New translations app_en.arb (Korean) 2026-01-22 02:27:20 +07:00
Zarz Eleutherius 2f80ae7e84 New translations app_en.arb (Japanese) 2026-01-22 02:27:19 +07:00
Zarz Eleutherius e248fef130 New translations app_en.arb (German) 2026-01-22 02:27:18 +07:00
Zarz Eleutherius 174724ddd3 New translations app_en.arb (Spanish) 2026-01-22 02:27:17 +07:00
Zarz Eleutherius 730945d892 New translations app_en.arb (French) 2026-01-22 02:27:15 +07:00
Zarz Eleutherius 4abdce8c58 Update source file app_en.arb 2026-01-22 02:27:13 +07:00
zarzet 55b75dc48d chore: bump version to 3.2.1+64 2026-01-22 02:17:47 +07:00
zarzet f6cea1a683 feat: v3.2.1 - lyrics improvements, pause/resume, folder options
- Add instrumental track detection (shows 'Instrumental track' instead of 'not available')
- Add embed lyrics button in Track Info (preserves synced timestamps)
- Add pause/resume button next to 'Downloading' header in History
- Add Artist/Album + Singles folder structure option
- Fix multi-artist lyrics search (try primary artist first)
- Fix lyrics display stripping metadata tags ([ti:], [ar:], [by:])
- Skip lyrics embedding for instrumental tracks during download
2026-01-22 02:15:43 +07:00
zarzet 8d205600b8 fix: iOS path migration, local greeting timezone, ICU plural warnings
- iOS: Auto-migrate file paths when container UUID changes after app update
- Greeting: Use device local time instead of extension response
- i18n: Fix 16 ICU plural syntax warnings in Spanish and Portuguese
2026-01-22 00:48:45 +07:00
zarzet aa35f60fad fix: fallback to index+1 for Deezer track position when API returns 0 2026-01-21 16:33:30 +07:00
zarzet b627ae1874 fix: handle CRLF in changelog extraction for Telegram 2026-01-21 16:23:19 +07:00
zarzet 46afa6e733 fix: use HTML parse mode for Telegram notifications to handle special chars 2026-01-21 13:30:35 +07:00
zarzet c01b189477 fix: discography download context issue after quality picker closes 2026-01-21 13:04:48 +07:00
zarzet 966935b677 feat: add missing platform bridge functions for batch duplicate check and cross-platform IDs 2026-01-21 12:22:46 +07:00
zarzet f2f8ca4528 feat: artist navigation from album, UI improvements, concise changelog
- Add tappable artist name in album screen to navigate to artist page
- Show track number instead of cover image in album track list
- Add release date badge next to track count on album screen
- Modernize Download All buttons with rounded corners (borderRadius: 24)
- Add downloaded indicator for recent items (primary colored subtitle)
- Condense v3.2.0 changelog and add note about concise format
- Fix withOpacity deprecation and unnecessary null assertion in home_tab
- Go backend: add artist_id support for Spotify, Deezer, and extensions
2026-01-21 12:07:50 +07:00
zarzet 7844bd2f42 docs: add discography download to changelog as highly requested feature 2026-01-21 10:28:17 +07:00
zarzet ac3d51e2cd feat: add discography download with album selection support
- Download entire artist discography, albums only, or singles only
- Album selection mode with multi-select and batch download
- Progress dialog while fetching tracks from albums
- Skip already downloaded tracks (checks history)
- Works with Spotify, Deezer, and Extensions
- Add 18 localization strings for discography feature
2026-01-21 10:26:35 +07:00
zarzet b899b54bb8 perf: migrate history to SQLite and optimize palette extraction
- Add SQLite database for download history with O(1) indexed lookups
- Add in-memory Map indexes for O(1) getBySpotifyId/getByIsrc
- Automatic migration from SharedPreferences on first launch
- Fix PaletteService to use PaletteGenerator (isolate approach didn't work)
- Use small image size (64x64) and limited colors (8) for speed
- Add caching to avoid re-extraction
- All screens now use consistent PaletteService
- Update CHANGELOG with all v3.2.0 changes
2026-01-21 10:05:39 +07:00
zarzet 7a17de49b2 fix: add duration_ms to home feed items and bump version to 3.2.0
- Add duration_ms field to ExploreItem model
- Parse duration_ms from spotify-web and ytmusic home feed responses
- Update _downloadExploreTrack to use item.durationMs
- Fixes track duration showing 0:00 in metadata screen after download
- Bump version to 3.2.0+63
2026-01-21 09:16:11 +07:00
zarzet 79180dd918 feat: add Home Feed with pull-to-refresh and gobackend.getLocalTime() API
- Add Home Feed/Explore feature with extension capabilities system
- Add pull-to-refresh on home feed (replaces refresh button)
- Add gobackend.getLocalTime() API for accurate device timezone detection
- Add YT Music Quick Picks UI with swipeable vertical format
- Fix greeting time showing wrong time due to Goja getTimezoneOffset() returning 0
- Update spotify-web and ytmusic extensions to use getLocalTime()
- Add Turkish language support
- Update CHANGELOG for v3.2.0
2026-01-21 08:30:44 +07:00
Zarz Eleutherius 0d98ada479 New translations app_en.arb (Turkish) 2026-01-21 02:22:48 +07:00
Zarz Eleutherius 5d4fc10ab7 New translations app_en.arb (Hindi) 2026-01-21 02:22:46 +07:00
Zarz Eleutherius e37dfeb080 New translations app_en.arb (Indonesian) 2026-01-21 02:22:45 +07:00
Zarz Eleutherius eddae2a9dd New translations app_en.arb (Chinese Traditional) 2026-01-21 02:22:44 +07:00
Zarz Eleutherius 6bd7eec615 New translations app_en.arb (Chinese Simplified) 2026-01-21 02:22:43 +07:00
Zarz Eleutherius b240e91290 New translations app_en.arb (Russian) 2026-01-21 02:22:42 +07:00
Zarz Eleutherius 4e0149df29 New translations app_en.arb (Portuguese) 2026-01-21 02:22:41 +07:00
Zarz Eleutherius 065872e686 New translations app_en.arb (Dutch) 2026-01-21 02:22:39 +07:00
Zarz Eleutherius 7ab0f5b7c8 New translations app_en.arb (Korean) 2026-01-21 02:22:38 +07:00
Zarz Eleutherius fd31682242 New translations app_en.arb (Japanese) 2026-01-21 02:22:37 +07:00
Zarz Eleutherius 56c8b62fcf New translations app_en.arb (German) 2026-01-21 02:22:36 +07:00
Zarz Eleutherius c3f879346a New translations app_en.arb (Spanish) 2026-01-21 02:22:35 +07:00
Zarz Eleutherius 6da65ed033 New translations app_en.arb (French) 2026-01-21 02:22:34 +07:00
Zarz Eleutherius 553c6b6c4a Update source file app_en.arb 2026-01-21 02:22:31 +07:00
zarzet ac5f74a48f feat: convert GitHub Markdown to Telegram format in release notification 2026-01-20 10:12:18 +07:00
zarzet e725a7be77 feat: convert GitHub Markdown to Telegram format in release notification 2026-01-20 10:12:01 +07:00
zarzet 2d22d85c49 feat: improve Telegram notification - upload IPA, remove redundant links, increase changelog limit 2026-01-20 10:08:53 +07:00
zarzet d960708dac feat: improve Telegram notification - upload IPA, remove redundant links, increase changelog limit 2026-01-20 10:08:35 +07:00
zarzet c62ad005f5 docs: update README and release workflow 2026-01-20 09:58:31 +07:00
zarzet 3edfe8e8bb docs: update README and release workflow 2026-01-20 09:56:38 +07:00
zarzet 68fa1bfdae feat: improve providers, l10n updates, and UI enhancements (testing) 2026-01-20 09:55:46 +07:00
zarzet 6f9722e05b Merge dev: update screenshots, funding, and VirusTotal hash 2026-01-20 05:58:06 +07:00
zarzet bd6b23400e Update screenshots, funding links, and VirusTotal hash 2026-01-20 05:57:43 +07:00
zarzet 066d35967e Merge branch 'dev' 2026-01-20 04:55:27 +07:00
zarzet b6d2fea847 chore: bump version to 3.1.3+62 2026-01-20 04:55:02 +07:00
zarzet 2b932cff70 Merge branch 'dev' 2026-01-20 04:16:26 +07:00
zarzet f356e53f7e feat: auto-enrich genre/label from Deezer for built-in providers
- Add GetExtendedMetadataByISRC function in deezer.go
  - Searches track by ISRC then fetches album extended metadata
- Call enrichment in DownloadWithExtensionFallback before built-in download
  - Only enriches if genre/label are empty and ISRC is available
  - Logs enrichment results for debugging
2026-01-20 04:09:41 +07:00
zarzet bb1ff187a3 fix: include genre, label, copyright in DownloadResponse
Extended metadata was being embedded into FLAC files but not returned
in the response to Flutter, causing history to not store these fields.

Fixed in 3 places in extension_providers.go:
- Source extension download response
- Extension fallback download response
- Built-in provider (Tidal/Qobuz/Amazon) response
2026-01-20 03:59:55 +07:00
zarzet d99a1b1c21 perf: streaming M4A metadata embedding and HTTP client refactor
- Refactor EmbedM4AMetadata to use streaming instead of loading entire file
- Use os.Open + ReadAt instead of os.ReadFile for memory efficiency
- Atomic file replacement via temp file + rename for safer writes
- New helper functions: findAtomInRange, readAtomHeaderAt, copyRange, buildUdtaAtom
- Refactor GetM4AQuality to use streaming with findAudioSampleEntry
- Use NewHTTPClientWithTimeout helper in lyrics.go, qobuz.go, tidal.go
- Update CHANGELOG with performance improvements and MP3 metadata support
2026-01-20 03:46:43 +07:00
zarzet c36497e87c perf: optimize widget rebuilds and reduce allocations
- Cache SharedPreferences instance in DownloadHistoryNotifier and DownloadQueueNotifier
- Precompile regex for folder sanitization and year extraction
- Use indexWhere instead of firstWhere with placeholder object
- Use selective watch for downloadQueueProvider (queuedCount, items)
- Pass Track directly to _buildTrackTile instead of index lookup
- Pass historyItems as parameter to _buildRecentAccess
- Add extended metadata (genre, label, copyright) support for MP3
2026-01-20 03:25:33 +07:00
Zarz Eleutherius a32487ad88 New translations app_en.arb (Hindi) 2026-01-20 02:16:58 +07:00
Zarz Eleutherius bd4946db37 New translations app_en.arb (Indonesian) 2026-01-20 02:16:57 +07:00
Zarz Eleutherius 69f143dd9d New translations app_en.arb (Chinese Traditional) 2026-01-20 02:16:56 +07:00
Zarz Eleutherius 15408bfa1c New translations app_en.arb (Chinese Simplified) 2026-01-20 02:16:55 +07:00
Zarz Eleutherius edc715021d New translations app_en.arb (Russian) 2026-01-20 02:16:54 +07:00
Zarz Eleutherius 392472b027 New translations app_en.arb (Portuguese) 2026-01-20 02:16:53 +07:00
Zarz Eleutherius 69741fa47c New translations app_en.arb (Dutch) 2026-01-20 02:16:52 +07:00
Zarz Eleutherius 484720bcda New translations app_en.arb (Korean) 2026-01-20 02:16:51 +07:00
Zarz Eleutherius f3cc51fb06 New translations app_en.arb (Japanese) 2026-01-20 02:16:50 +07:00
Zarz Eleutherius 452ea7084a New translations app_en.arb (German) 2026-01-20 02:16:49 +07:00
Zarz Eleutherius bba059fc44 New translations app_en.arb (Spanish) 2026-01-20 02:16:48 +07:00
Zarz Eleutherius 3f75cace2b New translations app_en.arb (French) 2026-01-20 02:16:47 +07:00
zarzet 03027813c1 chore: cleanup unused code and dead imports 2026-01-20 02:10:10 +07:00
zarzet 8e9d0c3e9a fix: use path only for JsonCacheInfoRepository
JsonCacheInfoRepository assertion requires either path OR databaseName, not both.
Using path only to ensure database is stored in persistent directory.
2026-01-19 23:26:10 +07:00
zarzet 6c8813c9de fix: ensure CoverCacheManager initializes before app renders
- Move CoverCacheManager.initialize() to run BEFORE other services
- Add debug log to confirm initialization status
- Fixes race condition where widgets render before cache is ready
2026-01-19 23:22:53 +07:00
zarzet ec314eb479 fix: store cache database in persistent directory
- Add path parameter to JsonCacheInfoRepository
- Add fallback to DefaultCacheManager if initialization fails
- Add debug logging for troubleshooting
- Fix issue where cache database was in temp dir while files in persistent
2026-01-19 23:14:33 +07:00
zarzet 77e4457244 feat: add persistent cover image cache
- Add CoverCacheManager service for persistent image caching
- Cache stored in app_flutter/cover_cache/ (not cleared by system)
- Maximum 1000 images cached for up to 365 days
- Update all 11 screens to use persistent cache manager
- Add flutter_cache_manager and path dependencies
- Update CHANGELOG.md with all changes for v3.1.3
2026-01-19 22:55:53 +07:00
zarzet 0119db094d feat: add extended metadata (genre, label, copyright) support
- Add genre, label, copyright fields to ExtTrackMetadata and DownloadResponse
- Add utils.randomUserAgent() for extensions to get random User-Agent strings
- Fix VM race condition panic by adding mutex locks to all provider methods
- Fix Tidal release date fallback when req.ReleaseDate is empty
- Display genre, label, copyright in track metadata screen
- Store extended metadata in download history for persistence
- Add trackGenre, trackLabel, trackCopyright localization strings
2026-01-19 21:13:40 +07:00
zarzet 9c35515d6f docs: add code of conduct and contributing guidelines 2026-01-19 18:58:25 +07:00
zarzet 1546d7da22 feat: add external LRC lyrics file support and fix locale parsing
- Add lyrics mode setting (embed/external/both) for saving lyrics
- Implement SaveLRCFile() in Go backend for all providers (Tidal, Qobuz, Amazon)
- Fix locale parsing in app.dart to handle country codes (e.g., pt_PT -> Locale('pt', 'PT'))
- Change Portuguese label from Portugal to Brasil in language settings
2026-01-19 18:57:27 +07:00
zarzet 61720f3f2a chore(ios): sync FFmpeg service and add palette_generator dependency 2026-01-19 02:55:39 +07:00
zarzet 7749399239 docs: add translator credits to changelog 2026-01-19 02:41:57 +07:00
zarzet d143b82068 fix: add es_ES and pt_PT locale codes to language selector 2026-01-19 02:33:12 +07:00
zarzet 606e7c1079 fix: change translator links from GitHub to Crowdin profiles 2026-01-19 02:28:35 +07:00
zarzet a650632c4e feat: add translators section in about page and fix ARB locale format 2026-01-19 02:25:30 +07:00
zarzet 3c118f74e4 chore: rename ARB files and add Spanish/Portuguese languages 2026-01-19 02:17:32 +07:00
zarzet bc3055f6e1 chore: update supported locales 2026-01-19 02:14:54 +07:00
zarzet 7c86ae0b7e feat: add quick search provider switcher and genre/label for extensions
- Add dropdown menu in search bar for instant provider switching
- Support genre & label metadata for extension downloads
- Bump version to 3.1.2 (build 61)
2026-01-19 02:14:52 +07:00
zarzet 595bfb2711 feat: add button setting type for extension actions
- Add SettingTypeButton for action buttons in extension settings
- Add Action field to ExtensionSetting for JS function name
- Update extension detail page UI to render button settings
- Add InvokeAction method to execute button actions
2026-01-19 02:14:52 +07:00
zarzet 5f39a3d52f fix: use CollapseMode.none for smoother header animation 2026-01-19 02:14:50 +07:00
zarzet e7077781e6 feat: add genre and label metadata to FLAC downloads
- Fetch genre and label from Deezer album API before download
- Add GENRE, ORGANIZATION (label), and COPYRIGHT tags to FLAC files
- Update Go Metadata struct with new fields
- Add GetDeezerExtendedMetadata export function for Flutter
- Register platform channel handlers for Android and iOS
- Pass genre/label through download flow to all services (Tidal/Qobuz/Amazon)
2026-01-19 02:14:50 +07:00
zarzet 42d15db4ca fix: show 'Artist' label for artist items instead of 'Album'
Fixed fallback subtitle in _CollectionItemWidget for artist search results
2026-01-19 02:14:49 +07:00
zarzet c2599981d6 fix: Clear All now hides ALL downloads, not just visible 10
Previously only hid uniqueItems (max 10 visible), now hides all downloadItems
2026-01-19 02:14:48 +07:00
zarzet a1647a41ff fix: use ref.watch for hiddenDownloadIds reactivity
Show All Downloads button now updates immediately without restart
2026-01-19 02:14:47 +07:00
zarzet bf2fc7702b chore: remove debug print statements from recent_access_provider 2026-01-19 02:14:46 +07:00
zarzet f814408702 style: reduce AppBar title font size to 16px for long titles 2026-01-19 02:14:45 +07:00
zarzet 6b1958bfd0 feat: show 'Show All Downloads' button when recents is empty
- Button appears when all items are cleared/hidden
- Clicking resets hidden downloads list
- Clear All button only shows when there are items
- Empty state with visibility_off icon
2026-01-19 02:14:29 +07:00
zarzet bc120ffa76 feat: allow hiding downloads from recents without deleting files
- Add hiddenDownloadIds set to RecentAccessState
- X button on download items hides from recents (not delete file)
- Hidden IDs persisted in SharedPreferences
- Clear All also clears hidden downloads list
- Single track shows as Track, 2+ tracks shows as Album in recents
2026-01-19 02:14:27 +07:00
zarzet 5ea454a0b0 fix: downloaded album navigation from recents 2026-01-19 02:14:26 +07:00
zarzet da574f895c feat: v3.1.2 - MP3 option, dominant color headers, sticky titles, disc separation
Added:
- MP3 quality option with FLAC-to-MP3 conversion (320kbps)
- Dominant color header backgrounds on detail screens
- Spotify-style sticky title on scroll (album, playlist, artist screens)
- Disc separation for multi-disc albums
- Album grouping in recent downloads
- 50% screen width cover art

Changed:
- Improved FFmpeg FLAC-to-MP3 conversion workflow
- AppBar uses theme surface color when collapsed

Fixed:
- Empty catch blocks with proper comments
- Russian plural forms (ICU syntax)

Dependencies:
- Added palette_generator ^0.3.3+4
2026-01-19 02:13:53 +07:00
Zarz Eleutherius 1c445e91d9 Merge pull request #77 from zarzet/l10n_dev
New Crowdin updates
2026-01-19 02:12:44 +07:00
Zarz Eleutherius 5d03eb0656 New translations app_en.arb (Portuguese) 2026-01-19 02:11:51 +07:00
Zarz Eleutherius becb6845a6 Merge pull request #68 from zarzet/l10n_dev
New Crowdin updates
2026-01-19 00:48:32 +07:00
Zarz Eleutherius be3ee3b216 New translations app_en.arb (Chinese Traditional) 2026-01-19 00:29:39 +07:00
Zarz Eleutherius 3747674968 New translations app_en.arb (Russian) 2026-01-19 00:29:37 +07:00
Zarz Eleutherius ff9d088c5f New translations app_en.arb (German) 2026-01-19 00:29:34 +07:00
Zarz Eleutherius 12db11d559 New translations app_en.arb (Spanish) 2026-01-19 00:29:33 +07:00
Zarz Eleutherius 7e1aca33a5 New translations app_en.arb (Hindi) 2026-01-18 03:42:29 +07:00
Zarz Eleutherius 07a1c68354 New translations app_en.arb (Indonesian) 2026-01-18 03:42:28 +07:00
Zarz Eleutherius f4d7c6531f New translations app_en.arb (Chinese Traditional) 2026-01-18 03:42:27 +07:00
Zarz Eleutherius e9ca054682 New translations app_en.arb (Chinese Simplified) 2026-01-18 03:42:27 +07:00
Zarz Eleutherius 1069bdd0d8 New translations app_en.arb (Portuguese) 2026-01-18 03:42:25 +07:00
Zarz Eleutherius ff882a58d7 New translations app_en.arb (Dutch) 2026-01-18 03:42:25 +07:00
Zarz Eleutherius dddc8c3d94 New translations app_en.arb (Korean) 2026-01-18 03:42:24 +07:00
Zarz Eleutherius 720525b67b New translations app_en.arb (German) 2026-01-18 03:42:22 +07:00
Zarz Eleutherius cc12f63d36 New translations app_en.arb (Spanish) 2026-01-18 03:42:21 +07:00
Zarz Eleutherius 5c67553596 New translations app_en.arb (French) 2026-01-18 03:42:20 +07:00
zarzet 0ccda8db58 fix: locale format and translation updates 2026-01-18 03:27:43 +07:00
zarzet 556c0e1db2 Merge dev into main 2026-01-18 03:21:02 +07:00
zarzet 6d7b89b881 v3.1.1: Lyrics caching, duration matching, Deezer cover upgrade, live extension search, Russian language, fix race conditions and scroll exceptions 2026-01-18 03:15:20 +07:00
Zarz Eleutherius 47777b4343 Merge pull request #65 from zarzet/l10n_dev
New Crowdin updates
2026-01-18 01:46:48 +07:00
Zarz Eleutherius 2eb1d2a65d New translations app_en.arb (Russian) 2026-01-18 01:45:39 +07:00
Zarz Eleutherius ce057c6473 New translations app_en.arb (Japanese) 2026-01-18 01:45:36 +07:00
Zarz Eleutherius 46cfe8b632 Merge pull request #58 from zarzet/l10n_dev
New Crowdin updates
2026-01-17 22:01:48 +07:00
zarzet 2e5eff6e3d chore: add cursor files to gitignore 2026-01-17 10:10:56 +07:00
zarzet dd506efeb6 chore: remove .cursorignore from tracking 2026-01-17 10:10:20 +07:00
zarzet 9897d3102e Merge branch 'dev' into main 2026-01-17 10:06:38 +07:00
zarzet 8d92d22fda refactor: more code cleanup 2026-01-17 10:04:21 +07:00
zarzet b99764b1ad refactor: cleanup unused code and imports 2026-01-17 09:50:00 +07:00
zarzet 621582cf11 refactor: additional code cleanup 2026-01-17 09:36:05 +07:00
zarzet b96233f90b refactor: code cleanup and improvements 2026-01-17 09:07:29 +07:00
zarzet 88dfb88bcc docs: add FAQ about mobile app size (FFmpeg bundled) 2026-01-17 07:11:07 +07:00
zarzet 75bfe9b3bf docs: update VirusTotal link for v3.1.0 2026-01-17 06:09:50 +07:00
zarzet be9444c76b fix: revert material_color_utilities to ^0.11.1 (pinned by Flutter SDK) 2026-01-17 05:41:33 +07:00
Zarz Eleutherius 65e21a421d New translations app_en.arb (Hindi) 2026-01-17 05:23:55 +07:00
Zarz Eleutherius 87b33dda7e New translations app_en.arb (Indonesian) 2026-01-17 05:23:54 +07:00
Zarz Eleutherius 2f097c8f6c New translations app_en.arb (Chinese Traditional) 2026-01-17 05:23:53 +07:00
Zarz Eleutherius 8cbdea1417 New translations app_en.arb (Chinese Simplified) 2026-01-17 05:23:52 +07:00
Zarz Eleutherius 48bdd154f6 New translations app_en.arb (Russian) 2026-01-17 05:23:51 +07:00
Zarz Eleutherius ae0e157c34 New translations app_en.arb (Portuguese) 2026-01-17 05:23:50 +07:00
Zarz Eleutherius 53fcdd9a47 New translations app_en.arb (Dutch) 2026-01-17 05:23:49 +07:00
Zarz Eleutherius 3d6be3bf92 New translations app_en.arb (Korean) 2026-01-17 05:23:48 +07:00
Zarz Eleutherius 2d7fba3f52 New translations app_en.arb (Japanese) 2026-01-17 05:23:47 +07:00
Zarz Eleutherius e02d8ff2cd New translations app_en.arb (German) 2026-01-17 05:23:46 +07:00
Zarz Eleutherius f8cee25958 New translations app_en.arb (Spanish) 2026-01-17 05:23:45 +07:00
Zarz Eleutherius 99c133aae1 New translations app_en.arb (French) 2026-01-17 05:23:45 +07:00
zarzet cedb32904e fix(ios): add localization support for iOS build
- Add flutter_localizations and intl to pubspec_ios.yaml

- Add generate: true flag for l10n code generation

- Add CFBundleLocalizations to Info.plist with all supported languages

- Update http to ^1.6.0 and material_color_utilities to ^0.13.0

- Update file_picker to ^10.3.8
2026-01-17 05:20:04 +07:00
zarzet e73f932083 fix: update Crowdin config for Chinese locales and add missing l10n keys
- Change crowdin.yml to use %locale_with_underscore% for proper zh_CN/zh_TW handling
- Add sectionLanguage, appearanceLanguage, appearanceLanguageSubtitle to app_en.arb
- Add app_zh_CN.arb for Simplified Chinese (Crowdin target)
- Update .gitignore to exclude log files and tool/ folder
- Regenerate localization dart files
2026-01-17 05:02:57 +07:00
zarzet 4645d3ac8b fix: correct @@locale values to match filenames (es-ES→es, pt-PT→pt, zh-TW→zh) 2026-01-17 04:51:21 +07:00
Zarz Eleutherius 1cdf8b7f23 Merge pull request #57 from zarzet/l10n_dev
New Crowdin updates
2026-01-17 04:34:21 +07:00
zarzet 1e18f53e6a Merge dev into l10n_dev - resolve conflicts by keeping dev version 2026-01-17 04:33:34 +07:00
zarzet fc8cfb05d0 feat: add recent access history, artist screen redesign, and extension improvements
Recent Access History:
- Quick access to recently visited artists, albums, playlists, and tracks
- Tap search bar to show recent access list
- Stays visible after keyboard dismiss, exit with back button
- Persists across app restarts (SharedPreferences)
- X button to remove items, Clear All button for all

Artist Screen Redesign:
- Full-width header image with gradient overlay
- Monthly listeners display with compact notation
- Popular section with top 5 tracks and download status
- Extension artists skip Spotify/Deezer fetch (no rate limit errors)

Go Backend:
- GetArtistWithExtensionJSON now returns top_tracks, header_image, listeners

Bug Fixes:
- Search bar unfocus when tapping outside
- Keyboard not appearing on Settings navigation return
- Recent access artist navigation uses correct screen for extensions
- Extension artist screen correctly parses and forwards top tracks

Localization:
- Added recentPlaylistInfo, errorGeneric strings
- Multi-language support via Crowdin

Extensions:
- YT Music: v1.5.0 (top_tracks in getArtist)
- Spotify Web: v1.6.0
2026-01-17 04:29:39 +07:00
Zarz Eleutherius fc0c0571fe New translations app_en.arb (Hindi) 2026-01-16 07:24:21 +07:00
Zarz Eleutherius e6ca29e199 New translations app_en.arb (Indonesian) 2026-01-16 07:24:20 +07:00
Zarz Eleutherius 7413a8a698 New translations app_en.arb (Chinese Traditional) 2026-01-16 07:24:19 +07:00
Zarz Eleutherius 205032e094 New translations app_en.arb (Chinese Simplified) 2026-01-16 07:24:18 +07:00
Zarz Eleutherius 9c6f438e22 New translations app_en.arb (Russian) 2026-01-16 07:24:17 +07:00
Zarz Eleutherius 4f2587554a New translations app_en.arb (Portuguese) 2026-01-16 07:24:16 +07:00
Zarz Eleutherius 369fdd84bf New translations app_en.arb (Dutch) 2026-01-16 07:24:15 +07:00
Zarz Eleutherius 5c3b668e92 New translations app_en.arb (Korean) 2026-01-16 07:24:14 +07:00
Zarz Eleutherius 141db45051 New translations app_en.arb (Japanese) 2026-01-16 07:24:13 +07:00
Zarz Eleutherius 8f9bc8f058 New translations app_en.arb (German) 2026-01-16 07:24:11 +07:00
Zarz Eleutherius be372604fe New translations app_en.arb (Spanish) 2026-01-16 07:24:10 +07:00
Zarz Eleutherius 6c25fc6a8d New translations app_en.arb (French) 2026-01-16 07:24:09 +07:00
Zarz Eleutherius 2eef021587 Update source file app_en.arb 2026-01-16 07:24:03 +07:00
zarzet f4fe74f972 docs: add Crowdin translation badge to README 2026-01-16 07:08:49 +07:00
zarzet 9eac6e6e56 docs: add Crowdin translation badge to README 2026-01-16 07:07:46 +07:00
zarzet e5c310f455 fix: update crowdin.yml to use two_letters_code for locale format
- Change from %locale% (id-ID) to %two_letters_code% (id)
- Matches Flutter l10n expected filename format
2026-01-16 06:39:16 +07:00
zarzet d8f73dfa56 feat: add support for 13 languages with improved language selector
- Rename Crowdin ARB files from locale-REGION to locale format
- Fix @@locale values to match filenames
- Update language selector to bottom sheet picker (supports 13 languages)
- Supported: English, Indonesian, German, Spanish, French, Hindi,
  Japanese, Korean, Dutch, Portuguese, Russian, Chinese (Simplified/Traditional)
- Remove duplicate app_id-ID.arb (keep app_id.arb)
2026-01-16 06:38:52 +07:00
zarzet f128d0caf0 Merge branch 'dev' of https://github.com/zarzet/SpotiFLAC-Mobile into dev 2026-01-16 06:35:44 +07:00
zarzet aa499ceba2 refactor: use consistent ViewModeChip for language selector
- Remove duplicate _LanguageChip widget
- Reuse _ViewModeChip for Material Design 3 consistency
- Same font size (12), padding, and styling as other selectors
2026-01-16 06:28:26 +07:00
zarzet 01306afc2d feat: add language selector in Appearance settings
- Add locale field to AppSettings model with 'system' default
- Add Language section in Appearance settings page
- Support System Default, English, and Indonesian options
- App language changes take effect immediately
- Add localization strings for language selector
2026-01-16 06:25:24 +07:00
Zarz Eleutherius 9a3cd0273b Merge pull request #56 from zarzet/l10n_dev
New Crowdin updates
2026-01-16 06:25:02 +07:00
Zarz Eleutherius ac25683f33 New translations app_en.arb (Hindi) 2026-01-16 06:22:51 +07:00
Zarz Eleutherius 624b2112d8 New translations app_en.arb (Indonesian) 2026-01-16 06:22:50 +07:00
Zarz Eleutherius 8bd34dc87e New translations app_en.arb (Chinese Traditional) 2026-01-16 06:22:49 +07:00
Zarz Eleutherius 948779bcfc New translations app_en.arb (Chinese Simplified) 2026-01-16 06:22:48 +07:00
Zarz Eleutherius a74b3a19f7 New translations app_en.arb (Russian) 2026-01-16 06:22:47 +07:00
Zarz Eleutherius 931d9fbf61 New translations app_en.arb (Portuguese) 2026-01-16 06:22:46 +07:00
Zarz Eleutherius a8c76004db New translations app_en.arb (Dutch) 2026-01-16 06:22:45 +07:00
Zarz Eleutherius 0df4596f79 New translations app_en.arb (Korean) 2026-01-16 06:22:44 +07:00
Zarz Eleutherius cf549df049 New translations app_en.arb (Japanese) 2026-01-16 06:22:43 +07:00
Zarz Eleutherius bd3783154b New translations app_en.arb (German) 2026-01-16 06:22:42 +07:00
Zarz Eleutherius 6919408905 New translations app_en.arb (Spanish) 2026-01-16 06:22:41 +07:00
Zarz Eleutherius f4c08a5981 New translations app_en.arb (French) 2026-01-16 06:22:39 +07:00
zarzet 7fff55da96 chore: add crowdin.yml for translation sync 2026-01-16 06:16:28 +07:00
zarzet 3c4dbd1a80 docs: add @key metadata descriptions for Crowdin translators
- Add description field to all 400+ localization strings
- Mark brand names and technical terms as DO NOT TRANSLATE
- Add placeholder descriptions for parameterized strings
- Helps translators understand context for each string
2026-01-16 05:58:36 +07:00
zarzet f26af38c1e feat: add multilanguage support (i18n) for English and Indonesian
- Add flutter_localizations and intl dependencies
- Create l10n.yaml configuration and ARB files (app_en.arb, app_id.arb)
- Add L10n extension for easy context.l10n access
- Localize all active screens:
  - setup_screen, track_metadata_screen, log_screen
  - download_settings_page, options_settings_page, appearance_settings_page
  - extensions_page, extension_detail_page, extension_details_screen
  - about_page, provider_priority_page, metadata_provider_priority_page
  - home_tab, queue_tab, store_tab, main_shell
  - album_screen, artist_screen, playlist_screen
  - downloaded_album_screen, queue_screen
- Localize widgets: update_dialog, download_service_picker
- Technical terms (FLAC, API, Spotify, Tidal, Qobuz, etc.) are NOT translated
- ~900+ localized strings in English, ~660+ in Indonesian
2026-01-16 05:50:11 +07:00
zarzet 7c6705c75c merge: main into dev - resolved conflicts for mime utils, version, gitignore 2026-01-16 03:47:45 +07:00
zarzet b193bc0b8f feat: download cancellation, duplicate detection, progress tracking improvements 2026-01-16 03:46:31 +07:00
zarzet 1a90887465 release: v3.1.0 - fix Separate Singles, extension metadata, YTMusic parsing
Fixes:
- Fix Separate Singles not working (#54) - albumType not extracted from Deezer API
- Fix extension artist/album metadata missing provider IDs and cover URLs
- Fix YTMusic extension not extracting album name and duration from search
- Fix extension collection screens setState after dispose
- Fix search source chips referencing removed badge props

Changes:
- Deezer convertTrack now includes album_type from record_type
- Track creation preserves albumType and source throughout download flow
- Go exports include provider_id in album/artist responses
- Version bump to 3.1.0+59
2026-01-15 18:54:01 +07:00
zarzet 82440affac feat: add home tab enhancements, download queue improvements, and platform bridge updates 2026-01-15 04:31:33 +07:00
zarzet 6d2f75c5dc docs: update screenshots and VirusTotal link for v3.0.0 2026-01-14 21:42:16 +07:00
zarzet 18bc079632 Merge dev into main: v3.0.0 stable release 2026-01-14 18:08:30 +07:00
zarzet 4091a9c499 release: v3.0.0 stable with Extension System 2026-01-14 01:57:30 +07:00
zarzet 9346f2d149 fix: bottom overflow in Folder Organization dialog 2026-01-14 01:00:52 +07:00
zarzet 8ab52959e8 refactor: simplify parallel download result handling in tidal/qobuz 2026-01-14 00:57:04 +07:00
zarzet bad95e99c8 fix: remove unused getDownloadURLSequential from tidal.go
Replaced by parallel version for faster API responses
2026-01-14 00:39:59 +07:00
zarzet dbd7fd70be fix: remove unused function and fix bit shifting warnings
- Remove unused getQobuzDownloadURLSequential (replaced by parallel version)
- Fix bit shifting on byte values in metadata.go (cast to uint32 before shift)
2026-01-14 00:38:46 +07:00
zarzet 125d070cfe fix: remove duplicate --- separator in release notes
Extract changelog now strips trailing --- from CHANGELOG.md sections
2026-01-13 23:51:59 +07:00
zarzet 15acf181d1 fix: back gesture freeze on Android 13+ and add album folder structure setting
- Add PopScope with canPop:true to all settings pages for predictive back gesture support
- Change settings navigation to use PageRouteBuilder instead of MaterialPageRoute
- Add album folder structure setting (artist_album vs album_only)
- Fix extension search result parsing to handle both array and object formats
- Update CHANGELOG

Fixes back gesture freeze issue on OnePlus and other Android 13+ devices with gesture navigation
2026-01-13 23:48:02 +07:00
zarzet e049f9b868 fix: improve artist matching for multi-artist tracks and add cover logging 2026-01-13 20:55:46 +07:00
zarzet 6a886c5276 fix: handle Japanese artist name order in Tidal/Qobuz matching 2026-01-13 20:31:05 +07:00
zarzet 1ec190bfe7 fix: multiple bugfixes for v3.0.0-beta.2 2026-01-13 20:12:35 +07:00
zarzet 7ca032b3f5 fix: remove unnecessary PopScope to prevent back gesture freeze
Removes PopScope wrapper from settings pages that don't need it.
PopScope with canPop: true was causing race condition with Android
gesture navigation, freezing the app.
2026-01-13 18:18:41 +07:00
zarzet 13b917d1a0 fix: preserve directory structure when extracting extension packages 2026-01-13 17:50:12 +07:00
zarzet 961072e2ac security: use per-installation random salt for credential encryption 2026-01-13 17:44:14 +07:00
zarzet 8a7815268b security: improve extension sandbox security
- Add file permission requirement for extensions

- Bump version to 3.0.0-beta.1
2026-01-13 17:41:24 +07:00
zarzet c7e1ffd926 chore: bump version to 3.0.0-alpha.4 2026-01-13 06:01:12 +07:00
zarzet 729ab01a5f feat(extension): add HMAC-SHA1 utility, artist URL handler, and store refresh fix
- Add utils.hmacSHA1(key, message) for extensions
- Add artist type handling in track_provider for extension URL results
- Fix extension store not refreshing after uninstall
- Update CHANGELOG with new features and Spotify Web extension
2026-01-13 05:54:19 +07:00
zarzet 0a16be4395 feat(extension): add HMAC-SHA1 utility, artist URL handler, and store refresh fix
- Add utils.hmacSHA1(key, message) for cryptographic operations in extensions
- Add artist type handling in track_provider for extension URL results
- Fix extension store not refreshing after uninstall
- Update CHANGELOG with new features and Spotify Web extension docs
2026-01-13 05:53:30 +07:00
zarzet 47cdb5564a fix(store): refresh store after extension uninstall to update isInstalled status 2026-01-13 04:30:25 +07:00
zarzet f7d5a24d17 refactor(extension): split extension_runtime.go into multiple files + add HMAC-SHA256 2026-01-13 04:17:00 +07:00
zarzet 8daff4d0a4 feat: improve Extension Store with custom icons and various fixes
- Support custom extension icons from registry (iconUrl field)
- Support both camelCase and snake_case in registry JSON
- Fix download file extension (.spotiflac-ext)
- New extensions start disabled by default
- Preserve enabled state on extension upgrade
- Add toggle to show/hide Store tab in Settings > Options
- Reorder tabs: Home, History, Store, Settings
2026-01-13 01:01:43 +07:00
zarzet a38d66fd41 feat: add Extension Store for browsing and installing extensions 2026-01-13 00:03:39 +07:00
zarzet 0cab01780d fix: gomobile compatibility for HandleURLWithExtension return type 2026-01-12 23:43:57 +07:00
zarzet 4afc14dee8 chore: increase log buffer size from 500 to 1000 entries 2026-01-12 23:18:47 +07:00
zarzet 00753ffe86 chore: increase log buffer size from 500 to 1000 entries 2026-01-12 23:17:30 +07:00
zarzet 523b1edc44 feat(extension): add custom URL handler support for extensions
- Add URLHandlerConfig to extension manifest (Go)
- Add HandleURL method to extension providers (Go)
- Add export functions for URL handling (Go)
- Add URLHandler class to extension_provider.dart (Flutter)
- Add platform bridge methods for URL handling (Flutter)
- Update fetchFromUrl to check extension URL handlers first
- Add Android/iOS native handlers for extension URL routing
- Update CHANGELOG with new feature
2026-01-12 22:22:25 +07:00
zarzet 4966a84614 chore: bump version to 3.0.0-alpha.3 2026-01-12 22:02:29 +07:00
zarzet 9247a775fa feat(extension): add browser-like polyfills for easier library porting
- Add fetch() API with json(), text(), arrayBuffer() methods
- Add atob()/btoa() global Base64 functions
- Add TextEncoder/TextDecoder classes for UTF-8 encoding
- Add URL/URLSearchParams classes for URL parsing
- Update documentation with polyfill usage examples
- All polyfills work within sandbox security model
2026-01-12 21:18:04 +07:00
zarzet b185b51b31 merge: sync bugfixes from main (permission error, Android 13+ storage) 2026-01-12 19:59:09 +07:00
zarzet d98960d053 fix: permission error message and Android 13+ storage permission
- Fixed download showing 'Song not found' when actually permission error
- Added permission error type detection in Go backend
- Android 13+ now requests both MANAGE_EXTERNAL_STORAGE and READ_MEDIA_AUDIO
- MANAGE_EXTERNAL_STORAGE opens Settings (system-level)
- READ_MEDIA_AUDIO shows dialog (app-level, resets on clear data)
- Proper permission check before showing 'granted' status
2026-01-12 19:56:12 +07:00
zarzet d417743654 chore: bump version to 2.2.9 2026-01-12 18:47:53 +07:00
zarzet c4bea124fb perf: parallel API calls for Tidal and Qobuz download URLs
- Tidal: Request download URL from all 8 APIs simultaneously
- Qobuz: Request download URL from all 2 APIs simultaneously
- First successful response wins ('siapa cepat dia dapat')
- Significantly reduces download URL fetch time
- Amazon remains sequential due to rate limiting requirements

This improves download speed by eliminating sequential API fallback delays.
2026-01-12 18:32:59 +07:00
zarzet c37410b5de feat: add Separate Singles Folder option
- Add albumType field to Track model with isSingle getter
- Add separateSingles setting in AppSettings
- Modify _buildOutputDir() to organize into Albums/ and Singles/ folders
- Add UI toggle in download settings page
- Parse album_type/record_type from Spotify and Deezer APIs

When enabled, singles are saved to a separate 'Singles' folder
2026-01-12 18:27:38 +07:00
zarzet b90c94125c merge: sync bugfix from main (duplicate history fix) 2026-01-12 18:26:06 +07:00
zarzet efbf5d4c5b fix: prevent duplicate entries in download history
- Add duplicate detection in addToHistory() by spotifyId, deezerId, or ISRC
- Replace existing entry and move to top when re-downloading same track
- Add _deduplicateHistory() to clean up existing duplicates on app load
- Auto-save after removing duplicates from storage

Fixes duplicate history entries when downloading same track multiple times
2026-01-12 18:25:38 +07:00
zarzet 35532b0c73 feat(extension): Enhanced HTTP API for YouTube Music support
- Add http.put(), http.delete(), http.patch() shortcut methods
- Add persistent cookie jar per extension
- Add http.clearCookies() to clear session
- Fix User-Agent header respect (no longer overwritten)
- Return multi-value headers as arrays (Set-Cookie support)
- Auto-stringify objects in POST/PUT/PATCH body
- Add response.ok and response.status properties
- Update documentation with YouTube Music example
2026-01-12 06:37:18 +07:00
zarzet 4c09b988e4 Merge main into dev (sync v2.2.8 features) 2026-01-12 06:22:22 +07:00
zarzet c673581c32 feat: multi-select batch delete and album grouping in history
- Add multi-select mode with long-press to select tracks
- Add bottom action bar for selection (Material 3 style)
- Add filter tabs: All/Albums/Singles
- Add album grouping view when Albums filter selected
- Add DownloadedAlbumScreen for viewing tracks in an album
- Reactive UI updates when tracks deleted
- Auto-pop when album has <2 tracks
- Update issue templates with (Stable Version) text
- Bump version to 2.2.8
2026-01-12 06:18:32 +07:00
zarzet bcd718b178 fix: reset settings when extension is disabled
- Reset metadata source to Deezer when search provider extension is disabled
- Reset default service to Tidal when download provider extension is disabled
- Check extension enabled state in Options page (Primary Provider)
- Check extension enabled state in Download Settings (Service selector)
- Show extension download providers in service selector when enabled
2026-01-12 02:26:18 +07:00
zarzet 2b9357cb6d feat: remove default Spotify credentials, require user's own API key
- Remove hardcoded Spotify client ID/secret from Go backend
- Spotify now requires user to provide their own credentials
- Deezer remains free (no credentials required)
- Update UI to show 'Free' badge for Deezer, 'API Key' for Spotify
- Show warning card when Spotify selected without credentials
- Add hasSpotifyCredentials check to platform bridge
2026-01-12 02:10:40 +07:00
zarzet 26d84041c7 fix: initialize extension system at app start for proper search hint
- Move extension system initialization to main.dart _EagerInitialization
- Show default search hint until extension system is initialized
- Watch extension state changes to update search hint dynamically
2026-01-12 01:58:44 +07:00
zarzet 93b4047143 fix: persist extension enabled state and clear search provider when disabled
- Save enabled state to settings store when extension is enabled/disabled
- Restore enabled state from settings store when extension is loaded
- Clear searchProvider setting when the extension is disabled
- Update search hint to check if extension is still enabled
2026-01-12 01:56:16 +07:00
zarzet a6d488696b chore: add extension API feature request template and ignore docs folder 2026-01-12 01:22:23 +07:00
zarzet 3dbd131e49 fix: iOS extension auth function names (use ByID suffix) 2026-01-12 01:02:16 +07:00
zarzet 57cb575483 feat: add extension system with skipBuiltInFallback support
- Add extension manager, manifest, runtime, providers, settings
- Add extension provider and UI pages (extensions, detail, priority)
- Add download service picker widget
- Add metadata provider priority page
- Add source field to Track model for extension tracking
- Add skipBuiltInFallback manifest option to skip built-in providers
- Update download queue to use source extension first
- Add extension upgrade support without data loss
2026-01-12 00:17:52 +07:00
zarzet 24ef66be4c Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-01-11 06:41:54 +07:00
zarzet d07a49f605 UI modernization: Global theme update, redesigned Options/Download settings, and smart filename editor 2026-01-11 06:41:34 +07:00
zarzet 4eba28db7a v2.2.7: CSV import metadata enrichment with Deezer fallback 2026-01-11 06:09:48 +07:00
zarzet b73a3f8912 Add CSV import and optimize Appearance settings 2026-01-11 05:56:30 +07:00
zarzet 9f47f2ce85 UI Modernization: Unified app bars, updated logos, improved settings & Deezer support 2026-01-11 04:28:41 +07:00
zarzet f2aca734a3 fix: improve logging for release builds and UI improvements
- Fix Flutter logs not appearing in release mode by bypassing Logger package
- Add detailed logging for Deezer search API calls
- Replace music_note icon with app logo on home screen
- Remove shadow/border from logo in About and Home screens
- Align icon size (40x40) with avatar in About page for consistent layout
2026-01-11 02:27:26 +07:00
Zarz Eleutherius 09cb637a86 Update VirusTotal link in README.md 2026-01-10 19:27:48 +07:00
zarzet 11e7034cec v2.2.5: In-app logging, ISP blocking detection, Latin script fix 2026-01-10 19:03:39 +07:00
zarzet f12c18d76b Add issue templates, update workflow SDK, fix about logo 2026-01-10 16:53:41 +07:00
zarzet 0da39a1b8b chore: update NDK to r27d LTS (27.3.13750724) for 16KB page size support 2026-01-10 16:48:52 +07:00
Zarz Eleutherius f29fe5054c Updated VirusTotal badge link in README
Updated VirusTotal badge link to the latest report.
2026-01-10 16:09:39 +07:00
zarzet c8c0164964 chore: update targetSdk to 36 (Android 16) with timeout handler 2026-01-10 04:51:15 +07:00
zarzet 52dd657913 fix: improve search result parsing robustness for edge cases 2026-01-10 04:48:32 +07:00
zarzet c30f9fe412 fix(ios): add setSpotifyCredentials method to AppDelegate 2026-01-10 04:36:28 +07:00
zarzet bea5dd1d4a v2.2.0: Default to Tidal, faster ISRC matching, ISRC enrichment for search 2026-01-10 04:33:05 +07:00
Zarz Eleutherius 8726a0858a Update VirusTotal link in README.md 2026-01-09 19:03:36 +07:00
zarzet 74bc747599 chore: update to v2.1.7 with new icons 2026-01-09 17:54:50 +07:00
zarzet cbc8fdcb0c feat: add download badges to release page 2026-01-09 17:25:01 +07:00
zarzet 3b79b4f1ca fix: use absolute path for IPA creation 2026-01-09 02:41:19 +07:00
zarzet 5692a76650 Fix: iOS embedMetadata method + Android APK path detection 2026-01-09 02:31:01 +07:00
zarzet 7a009ad0af Fix R8: add dontwarn for Play Core and javax.xml.stream 2026-01-09 02:19:37 +07:00
zarzet e5e75e7092 Fix iOS build: use xcodebuild with CODE_SIGNING_ALLOWED=NO 2026-01-09 02:18:54 +07:00
zarzet 01b8fd2480 Fix metadata consistency (Go->Flutter) and build optimization
- Backend: Return full metadata (Track, Disc, Year) from Tidal/Qobuz/Amazon download results
- Flutter: Use backend metadata for tagging converted M4A and history entries
- Fix: Duplicate convertTrack method in deezer.go
- Fix: Better error message for Deezer fallback failure
- Changed: Default service fallback to Tidal -> Qobuz -> Amazon
- Build: Re-enabled resource shrinking and minification for release build
2026-01-09 02:12:24 +07:00
Zarz Eleutherius ee807a44cc Update instructions for using Spotify API 2026-01-08 13:36:10 +07:00
Zarz Eleutherius c9b905eb18 Update VirusTotal badge link in README.md 2026-01-08 01:08:32 +07:00
zarzet e9c7bf830e Update changelog for v2.1.5 2026-01-08 00:53:38 +07:00
zarzet 8bc97d5bd3 v2.1.5: Deezer API 2.0, Qobuz default, fetch ISRC for search results 2026-01-08 00:52:24 +07:00
zarzet f2c241c323 Fix .tmp permission issue on Android Music folder 2026-01-08 00:26:49 +07:00
zarzet 9c512ffe28 Add migration for Deezer default (skip if custom Spotify enabled) 2026-01-07 23:19:04 +07:00
zarzet 53a1da6249 v2.1.5: Fix progress bar and incomplete downloads
- Fix progress bar jumping from 1% to 100% (threshold-based updates)
- Fix incomplete downloads with temp file + size validation
- Applies to Tidal, Qobuz, and Amazon services
2026-01-07 23:15:48 +07:00
Zarz Eleutherius d4274e8ca8 Include setup instructions for Spotify API usage
Added detailed instructions for setting up Spotify as a search source, including steps for creating a developer account and entering credentials.
2026-01-07 13:55:31 +07:00
Zarz Eleutherius 49a9f12841 Simplify README by removing Spotify setup details
Removed detailed instructions for using Spotify and support section.
2026-01-07 13:53:30 +07:00
zarzet d7fa040e3c fix: Deezer artist/album screen improvements
- Fix album_screen to support Deezer album IDs (deezer:xxx format)
- Use getSpotifyMetadataWithFallback for Spotify albums
- Hide track count in artist discography when not available from Deezer API
- Deezer /artist/{id}/albums endpoint doesn't return nb_tracks
2026-01-07 04:25:35 +07:00
zarzet 9baa1e2088 fix: Replace android-actions/setup-android with direct SDK setup 2026-01-07 03:38:00 +07:00
zarzet 482457205a feat: Add Deezer as alternative metadata source with auto-fallback
- Add Deezer API client (no auth required, rate limit per user IP)
- Add search source selector in Settings (Deezer/Spotify)
- Default to Deezer for better reliability
- Auto-fallback to Deezer when Spotify API is rate limited (429)
- Support fallback for tracks and albums via SongLink API
- Update README with metadata source documentation
- Version 2.1.5-preview (build 42)
2026-01-07 03:25:14 +07:00
zarzet 3b2ec319e2 Fix pubspec version to 2.1.0+41 2026-01-07 00:19:53 +07:00
zarzet a0f7e75a9a Update VirusTotal badge to 2.1.0 2026-01-07 00:15:43 +07:00
zarzet c725e53e4c Release 2.1.0 2026-01-07 00:00:28 +07:00
zarzet 1d7c43a302 v2.1.0-preview: Download speed optimizations 2026-01-06 03:56:26 +07:00
Zarz Eleutherius df7c1c5bb7 Add VirusTotal badge to README
Added VirusTotal badge to README for safety verification.
2026-01-06 02:05:50 +07:00
zarzet bb05353b7e fix(ios): directory picker - add App Documents option for iOS
- iOS limitation: empty folders cannot be selected via document picker
- Added bottom sheet with App Documents Folder as recommended option
- Shows info message explaining iOS limitation
- Files accessible via iOS Files app

Version: 2.0.7-preview2+38
2026-01-06 00:23:19 +07:00
zarzet 7ac92d77e5 chore: add custom FFmpeg AAR to repo for CI builds 2026-01-05 14:23:41 +07:00
zarzet cf00ecb756 feat: use custom FFmpeg AAR for Android, reduce APK size
- Replace ffmpeg_kit_flutter plugin with custom AAR (arm64 + arm7a only)
- Add MethodChannel bridge for FFmpeg in MainActivity
- Create separate pubspec_ios.yaml for iOS builds with ffmpeg_kit plugin
- Update GitHub workflow to swap pubspec for iOS builds
- Reduces Android APK size by ~50MB
2026-01-05 14:09:32 +07:00
zarzet 525f2fd0cd chore: bump version to 2.0.6+36 2026-01-05 12:21:05 +07:00
zarzet 3e841cef06 fix: duration display, audio quality from file, artist verification, metadata case-sensitivity, settings navigation freeze
- Fix duration showing incorrect values (ms to seconds conversion)
- Read audio quality from FLAC file instead of trusting API
- Add artist verification for Tidal/Qobuz/Amazon downloads
- Fix FLAC metadata case-insensitive replacement
- Fix settings navigation freeze on Android 14+ (PopScope handling)
2026-01-05 10:30:57 +07:00
zarzet a8527df80a docs: application stabilized, remove dev notice 2026-01-05 03:12:30 +07:00
zarzet 51b2ad5c77 v2.0.5: Large playlist support + duration verification fix
- Add pagination for playlists (up to 1000 tracks)
- Add duration verification to prevent wrong track downloads
- When Tidal returns wrong version, fallback to Qobuz/Amazon
2026-01-05 03:08:15 +07:00
287 changed files with 240941 additions and 9865 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.41.4"
}
+20
View File
@@ -0,0 +1,20 @@
* text=auto eol=lf
# Windows scripts
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# Binary files
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.zip binary
*.jar binary
*.aar binary
*.keystore binary
*.jks binary
+3
View File
@@ -0,0 +1,3 @@
github: zarzet
ko_fi: zarzet
+123
View File
@@ -0,0 +1,123 @@
name: Bug Report
description: Report a bug or unexpected behavior
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the form below.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have searched existing issues and this bug hasn't been reported yet
required: true
- label: I am using the latest version of SpotiFLAC (Stable Version)
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe what you expected...
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Describe what actually happened...
validations:
required: true
- type: input
id: version
attributes:
label: App Version
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
placeholder: "e.g., v2.2.0"
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: Which platform are you using?
options:
- Android
- iOS
validations:
required: true
- type: input
id: device
attributes:
label: Device & OS Version
description: What device and OS version are you using?
placeholder: "e.g., Samsung Galaxy S24, Android 14"
validations:
required: true
- type: dropdown
id: download-service
attributes:
label: Download Service
description: Which download service were you using when the bug occurred?
options:
- Tidal
- Qobuz
- Amazon Music
- Deezer (search only)
- Not applicable
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / Screenshots
description: |
If applicable, add logs or screenshots to help explain your problem.
**To get logs:**
1. Go to Settings > Options > Detailed Logging (turn ON)
2. Reproduce the bug
3. Go to Settings > Logs
4. Tap Share button to export logs
placeholder: Paste logs or drag & drop screenshots here...
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context about the problem
placeholder: Add any other context...
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: README
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
about: Check the README for setup instructions and FAQ
- name: Extension Development Guide
url: https://zarz.moe/docs
about: Documentation for building SpotiFLAC extensions
+111
View File
@@ -0,0 +1,111 @@
name: Download Issue
description: Report issues with downloading specific tracks or albums
title: "[Download]: "
labels: ["download-issue"]
body:
- type: markdown
attributes:
value: |
Having trouble downloading a specific track or album? Please provide details below.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
required: true
- label: I am using the latest version of SpotiFLAC (Stable Version)
required: true
- type: dropdown
id: issue-type
attributes:
label: Issue Type
description: What kind of download issue are you experiencing?
options:
- Track not found on service
- Wrong track downloaded
- Download fails/errors
- Metadata incorrect
- Audio quality issue
- Other
validations:
required: true
- type: input
id: spotify-url
attributes:
label: Spotify URL
description: The Spotify URL of the track/album you're trying to download
placeholder: "https://open.spotify.com/track/..."
validations:
required: true
- type: input
id: track-info
attributes:
label: Track Info
description: Artist name and track title
placeholder: "Artist - Track Title"
validations:
required: true
- type: dropdown
id: download-service
attributes:
label: Download Service
description: Which service did you try to download from?
options:
- Tidal
- Qobuz
- Amazon Music
- All services
validations:
required: true
- type: dropdown
id: search-service
attributes:
label: Search Service
description: Which search service are you using?
options:
- Spotify
- Deezer
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Describe the issue in detail
placeholder: |
What happened? What did you expect?
If wrong track was downloaded, what track was downloaded instead?
validations:
required: true
- type: input
id: version
attributes:
label: App Version
description: Which version of SpotiFLAC are you using?
placeholder: "e.g., v2.2.0"
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots / Logs
description: |
If applicable, add screenshots or logs.
**To get logs:**
1. Go to Settings > Options > Detailed Logging (turn ON)
2. Try downloading the track again
3. Go to Settings > Logs
4. Tap Share button to export logs
placeholder: Drag & drop screenshots or paste logs here...
@@ -0,0 +1,117 @@
name: Extension API Feature Request
description: Request new API features or capabilities for extension development
title: "[Extension API]: "
labels: ["enhancement", "extension-api"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve the SpotiFLAC Extension API!
This form is for extension developers who need new features or capabilities that don't exist yet.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
required: true
- label: I have searched existing issues and this API feature hasn't been requested yet
required: true
- type: textarea
id: extension_goal
attributes:
label: What are you trying to build?
description: Describe the extension or feature you're developing
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
validations:
required: true
- type: textarea
id: current_limitation
attributes:
label: Current API Limitation
description: What's missing or limiting in the current extension API?
placeholder: |
The current API doesn't support:
- [missing feature 1]
- [missing feature 2]
This prevents me from...
validations:
required: true
- type: textarea
id: proposed_api
attributes:
label: Proposed API / Feature
description: Describe the API or feature you'd like to see added
placeholder: |
I would like to have:
- A new function `api.newFeature()` that does X
- A new manifest field `newOption` that enables Y
- Access to Z capability...
validations:
required: true
- type: textarea
id: use_case
attributes:
label: Use Case Example
description: Provide a code example of how you would use this feature
placeholder: |
```javascript
// Example usage in extension code
function download(request, progressCallback) {
const result = api.proposedFeature(params);
// ...
}
```
validations:
required: false
- type: dropdown
id: api_category
attributes:
label: API Category
description: What category does this feature fall under?
options:
- HTTP/Network API
- File System API
- Storage API
- FFmpeg/Audio Processing
- Manifest Options
- Runtime Functions
- UI Integration
- Authentication
- Other
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How critical is this for your extension?
options:
- Blocker - Cannot build my extension without this
- High - Major functionality depends on this
- Medium - Would significantly improve my extension
- Low - Nice to have
validations:
required: true
- type: textarea
id: workaround
attributes:
label: Current Workaround
description: Are you using any workaround currently? If so, describe it.
placeholder: "Currently I'm working around this by..."
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, links to similar APIs, or examples from other platforms
placeholder: "Similar feature in other platforms: ..."
@@ -0,0 +1,66 @@
name: Feature Request
description: Suggest a new feature or improvement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature! Please fill out the form below.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have searched existing issues and this feature hasn't been requested yet
required: true
- type: textarea
id: problem
attributes:
label: Problem / Motivation
description: Is your feature request related to a problem? Please describe.
placeholder: "A clear description of what the problem is. Ex: I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like
placeholder: A clear description of what you want to happen...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Describe any alternative solutions or features you've considered
placeholder: Other approaches you've thought about...
- type: dropdown
id: category
attributes:
label: Category
description: What category does this feature fall under?
options:
- UI/UX Improvement
- Download Feature
- New Service Integration
- Metadata/Tagging
- Performance
- Settings/Configuration
- Other
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, mockups, or screenshots about the feature request
placeholder: Add any other context or screenshots...
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 2 # Need previous commit to compare
+44
View File
@@ -0,0 +1,44 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
paths:
- 'site/**'
- '.github/workflows/pages.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+313 -84
View File
@@ -3,13 +3,13 @@ name: Release
on:
push:
tags:
- 'v*'
- "v*"
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.0.0)'
description: "Version tag (e.g., v1.0.0)"
required: true
default: 'v1.0.0'
default: "v1.0.0"
jobs:
# Get version first (quick job)
@@ -28,7 +28,7 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
@@ -43,7 +43,7 @@ jobs:
build-android:
runs-on: ubuntu-latest
needs: get-version
steps:
- name: Free disk space
run: |
@@ -60,23 +60,23 @@ jobs:
df -h
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
distribution: "temurin"
java-version: "17"
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: '1.21'
go-version: "1.25.7"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
- name: Cache Gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
@@ -85,7 +85,20 @@ jobs:
restore-keys: gradle-${{ runner.os }}-
- name: Install Android SDK & NDK
uses: android-actions/setup-android@v3
run: |
# Use pre-installed Android SDK on GitHub runners
echo "ANDROID_HOME=$ANDROID_HOME"
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
# Accept licenses
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
# Set NDK path
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
- name: Install gomobile
run: |
@@ -103,7 +116,7 @@ jobs:
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
channel: "stable"
cache: true
- name: Get Flutter dependencies
@@ -113,7 +126,14 @@ jobs:
run: dart run flutter_launcher_icons
- name: Build APK (Release - unsigned)
run: flutter build apk --release --split-per-abi
run: |
flutter build apk --release --split-per-abi || true
# Verify APKs were created
ls -la build/app/outputs/flutter-apk/
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
echo "ERROR: APK not found!"
exit 1
fi
- name: Sign APKs
uses: r0adkll/sign-android-release@v1
@@ -125,7 +145,7 @@ jobs:
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "34.0.0"
BUILD_TOOLS_VERSION: "36.0.0"
- name: Rename APKs
run: |
@@ -138,28 +158,28 @@ jobs:
ls -la
- name: Upload APK artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: android-apk
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
build-ios:
runs-on: macos-latest
needs: get-version # Only depends on version, NOT android build!
needs: get-version # Only depends on version, NOT android build!
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: '1.21'
go-version: "1.25.7"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
- name: Cache CocoaPods
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ios/Pods
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
@@ -174,7 +194,7 @@ jobs:
working-directory: go_backend
run: |
mkdir -p ../ios/Frameworks
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
gomobile bind -target=ios -tags ios -o ../ios/Frameworks/Gobackend.xcframework .
env:
CGO_ENABLED: 1
@@ -182,51 +202,51 @@ jobs:
run: |
ls -la ios/Frameworks/
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
- name: Add XCFramework to Xcode project
run: |
# Install xcodeproj gem for modifying Xcode project
sudo gem install xcodeproj
# Create Ruby script to add framework
cat > add_framework.rb << 'EOF'
require 'xcodeproj'
project_path = 'ios/Runner.xcodeproj'
project = Xcodeproj::Project.open(project_path)
# Get the main target
target = project.targets.find { |t| t.name == 'Runner' }
# Get or create Frameworks group
frameworks_group = project.main_group.find_subpath('Frameworks', true)
frameworks_group ||= project.main_group.new_group('Frameworks')
# Add XCFramework reference
framework_path = 'Frameworks/Gobackend.xcframework'
framework_ref = frameworks_group.new_file(framework_path, :project)
# Add to frameworks build phase
frameworks_build_phase = target.frameworks_build_phase
frameworks_build_phase.add_file_reference(framework_ref)
# Add to embed frameworks build phase
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
if embed_phase
build_file = embed_phase.add_file_reference(framework_ref)
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
end
project.save
puts "Successfully added Gobackend.xcframework to Xcode project"
EOF
ruby add_framework.rb
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
channel: "stable"
cache: true
- name: Get Flutter dependencies
@@ -236,20 +256,46 @@ jobs:
run: dart run flutter_launcher_icons
- name: Build iOS (unsigned)
run: flutter build ios --release --no-codesign
run: |
# Build Flutter iOS without codesigning
flutter build ios --release --no-codesign --config-only
# Use xcodebuild with code signing disabled
cd ios
xcodebuild -workspace Runner.xcworkspace \
-scheme Runner \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-archivePath build/Runner.xcarchive \
archive \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
DEVELOPMENT_TEAM=""
- name: Create IPA
run: |
VERSION=${{ needs.get-version.outputs.version }}
mkdir -p build/ios/ipa
cd build/ios/iphoneos
cd ios/build/Runner.xcarchive/Products/Applications
mkdir Payload
cp -r Runner.app Payload/
zip -r ../ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
# Use absolute path to avoid relative path issues
zip -r $GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
rm -rf Payload
- name: Verify IPA created
run: |
ls -la build/ios/ipa/
VERSION=${{ needs.get-version.outputs.version }}
if [ ! -f "build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa" ]; then
echo "ERROR: IPA not created!"
exit 1
fi
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ios-ipa
path: build/ios/ipa/SpotiFLAC-*.ipa
@@ -259,44 +305,36 @@ jobs:
needs: [get-version, build-android, build-ios]
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0 # Full history needed for git-cliff
- name: Extract changelog for version
- name: Generate changelog with git-cliff
id: changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip header
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OUTPUT: /tmp/changelog.txt
- name: Show generated changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
echo "Looking for version: $VERSION_NUM"
# Extract changelog section for this version using sed
# Find the line with version, then print until next version header or end
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
# If no changelog found, use default message
if [ -z "$CHANGELOG" ]; then
echo "No changelog found for version $VERSION_NUM"
CHANGELOG="See CHANGELOG.md for details."
else
echo "Found changelog content"
fi
# Save to file for multiline support
echo "$CHANGELOG" > /tmp/changelog.txt
echo "Extracted changelog:"
echo "Generated changelog:"
cat /tmp/changelog.txt
- name: Download Android APK
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: android-apk
path: ./release
- name: Download iOS IPA
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: ios-ipa
path: ./release
@@ -304,38 +342,47 @@ jobs:
- name: Prepare release body
run: |
VERSION=${{ needs.get-version.outputs.version }}
cat > /tmp/release_body.txt << 'HEADER'
## SpotiFLAC $VERSION
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
### What's New
HEADER
# Replace $VERSION in header
sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt
cat /tmp/changelog.txt >> /tmp/release_body.txt
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD)
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true)
# Start with git-cliff changelog, but replace its compare footer with a
# deterministic previous-tag lookup from git.
sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt
if [ -n "$PREVIOUS_TAG" ]; then
printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \
"$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \
>> /tmp/release_body.txt
fi
# Append download section
cat >> /tmp/release_body.txt << FOOTER
---
### Downloads
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
#### Android
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
#### iOS
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
### Installation
**Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6)
FOOTER
echo "Release body:"
cat /tmp/release_body.txt
- name: Create Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
@@ -345,3 +392,185 @@ jobs:
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-altstore:
runs-on: ubuntu-latest
needs: [get-version, build-ios, create-release]
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
permissions:
contents: write
steps:
- name: Checkout main branch
uses: actions/checkout@v6
with:
ref: main
- name: Download iOS IPA
uses: actions/download-artifact@v7
with:
name: ios-ipa
path: ./release
- name: Update apps.json
run: |
VERSION="${{ needs.get-version.outputs.version }}"
VERSION_NUM="${VERSION#v}"
DATE=$(date -u +%Y-%m-%d)
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
if [ -z "$IPA_FILE" ]; then
echo "WARNING: IPA file not found, skipping apps.json update"
exit 0
fi
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
if [ ! -f apps.json ]; then
echo "WARNING: apps.json not found on main, skipping"
exit 0
fi
jq --arg ver "$VERSION_NUM" \
--arg date "$DATE" \
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
--argjson size "$IPA_SIZE" \
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
echo "Updated apps.json:"
cat apps.json
- name: Commit and push
run: |
VERSION="${{ needs.get-version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add apps.json
git diff --cached --quiet && echo "No changes to commit" || \
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
notify-telegram:
runs-on: ubuntu-latest
needs: [get-version, create-release]
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download Android APK
uses: actions/download-artifact@v7
with:
name: android-apk
path: ./release
- name: Download iOS IPA
uses: actions/download-artifact@v7
with:
name: ios-ipa
path: ./release
- name: Generate changelog with git-cliff for Telegram
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip all
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OUTPUT: /tmp/cliff_tg.txt
- name: Convert changelog for Telegram
id: changelog
run: |
if [ ! -s /tmp/cliff_tg.txt ]; then
echo "See release notes on GitHub for details." > /tmp/changelog.txt
else
# Convert Markdown to Telegram HTML
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
sed '/^\*\*Full Changelog\*\*/d' | \
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^- /• /g')
# Truncate for Telegram 4096 char limit
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
echo "$CHANGELOG" > /tmp/changelog.txt
fi
echo "Telegram changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
run: |
VERSION=${{ needs.get-version.outputs.version }}
CHANGELOG=$(cat /tmp/changelog.txt)
# Find APK files
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
# Prepare message with changelog (HTML format)
printf '%s\n' \
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
"" \
"<b>What's New:</b>" \
"${CHANGELOG}" \
"" \
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
> /tmp/telegram_message.txt
MESSAGE=$(cat /tmp/telegram_message.txt)
# Send message first (using HTML parse mode)
# Use --data-urlencode for proper encoding of special chars (+, &, etc.)
# Use || true to ensure file uploads continue even if message fails
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
--data-urlencode "text=${MESSAGE}" \
--data-urlencode "parse_mode=HTML" \
--data-urlencode "disable_web_page_preview=true" || true
# Upload arm64 APK to channel
if [ -f "$ARM64_APK" ]; then
echo "Uploading arm64 APK to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM64_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
fi
# Upload arm32 APK to channel
if [ -f "$ARM32_APK" ]; then
echo "Uploading arm32 APK to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM32_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm32"
fi
# Upload iOS IPA to channel
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
if [ -f "$IOS_IPA" ]; then
echo "Uploading iOS IPA to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${IOS_IPA}" \
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
fi
echo "Telegram notification sent!"
+31 -3
View File
@@ -6,15 +6,20 @@ Thumbs.db
.idea/
.vscode/
*.iml
.cursorignore
.cursorrules
# Kiro specs (development only)
.kiro/
# Design assets (banners, mockups)
design/
# Reference folder (development only)
referensi/
# Development notes
COMPARISON_PC_vs_ANDROID.md
# Documentation (development only, published separately)
docs/
# Old spotiflac_android folder (moved to root)
spotiflac_android/
@@ -38,7 +43,7 @@ go_backend/*.xcframework/
# Android
android/.gradle/
android/app/libs/
android/app/libs/gobackend.aar
android/local.properties
android/*.iml
android/key.properties
@@ -52,3 +57,26 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
# Extension folder
extension/
# Agent instructions
AGENTS.md
# Temp/misc
nul
network_requests.txt
# Log files
*.log
hs_err_*.log
flutter_*.log
# Development tools
tool/
.claude/settings.local.json
# FVM Version Cache
.fvm/
Binary file not shown.
+1174 -390
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**[zarzet](https://github.com/zarzet)**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+282
View File
@@ -0,0 +1,282 @@
# Contributing to SpotiFLAC
First off, thank you for considering contributing to SpotiFLAC! 🎉
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [How Can I Contribute?](#how-can-i-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Features](#suggesting-features)
- [Code Contributions](#code-contributions)
- [Translations](#translations)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Guidelines](#coding-guidelines)
- [Commit Guidelines](#commit-guidelines)
- [Pull Request Process](#pull-request-process)
## Code of Conduct
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
## How Can I Contribute?
### Reporting Bugs
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
When creating a bug report, please use the bug report template and include:
- **Clear and descriptive title**
- **Steps to reproduce** the issue
- **Expected behavior** vs **actual behavior**
- **Screenshots or screen recordings** if applicable
- **Device information** (model, OS version)
- **App version**
- **Logs** from Settings > About > View Logs
### Suggesting Features
Feature requests are welcome! Please use the feature request template and:
- **Check existing issues** to avoid duplicates
- **Describe the feature** clearly
- **Explain the use case** - why would this be useful?
- **Consider the scope** - is this a small enhancement or a major feature?
### Code Contributions
1. **Fork the repository** and create your branch from `dev`
2. **Make your changes** following our coding guidelines
3. **Test your changes** thoroughly
4. **Submit a pull request** to the `dev` branch
### Translations
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
2. Select your language or request a new one
3. Start translating!
Translation files are located in `lib/l10n/arb/`.
## Development Setup
### Prerequisites
- **Flutter SDK** 3.10.0 or higher
- **Dart SDK** 3.10.0 or higher
- **Android Studio** or **VS Code** with Flutter extensions
- **Git**
### Getting Started
1. **Clone your fork**
```bash
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
cd SpotiFLAC-Mobile
```
2. **Add upstream remote**
```bash
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Use FVM (Flutter Version: 3.38.1)**
```bash
fvm use
```
4. **Install dependencies**
```bash
flutter pub get
```
5. **Generate code** (for Riverpod, JSON serialization, etc.)
```bash
dart run build_runner build --delete-conflicting-outputs
```
6. **Set up Go environment (Go Version: 1.25.7)**
```bash
cd go_backend
mkdir -p ../android/app/libs
gomobile init
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
cd ..
```
7. **Run the app**
```bash
flutter run
```
### Building
```bash
# Debug build
flutter build apk --debug
# Release build
flutter build apk --release
```
## Project Structure
```
lib/
├── l10n/ # Localization files
│ └── arb/ # ARB translation files
├── models/ # Data models
├── providers/ # Riverpod providers
├── screens/ # UI screens
│ └── settings/ # Settings sub-screens
├── services/ # Business logic services
├── theme/ # App theming
├── utils/ # Utility functions
├── widgets/ # Reusable widgets
├── app.dart # App configuration
└── main.dart # Entry point
```
## Coding Guidelines
### General
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
- Use meaningful variable and function names
- Keep functions small and focused
- Add comments for complex logic
### Formatting
- Use `dart format` before committing
- Maximum line length: 80 characters
- Use trailing commas for better formatting
```bash
dart format .
```
### Linting
Ensure your code passes all lints:
```bash
flutter analyze
```
### State Management
We use **Riverpod** for state management. Follow these patterns:
```dart
// Use code generation with riverpod_annotation
@riverpod
class MyNotifier extends _$MyNotifier {
@override
MyState build() => MyState();
// Methods to update state
}
```
### Localization
All user-facing strings should be localized:
```dart
// Good
Text(AppLocalizations.of(context)!.downloadComplete)
// Bad
Text('Download Complete')
```
To add new strings:
1. Add the key to `lib/l10n/arb/app_en.arb`
2. Run `flutter gen-l10n`
## Commit Guidelines
We follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
```
### Types
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `style`: Code style changes (formatting, etc.)
- `refactor`: Code refactoring
- `perf`: Performance improvements
- `test`: Adding or updating tests
- `chore`: Maintenance tasks
### Examples
```
feat(download): add batch download support
fix(ui): resolve overflow on small screens
docs: update contributing guidelines
chore(deps): update flutter_riverpod to 3.1.0
```
## Pull Request Process
1. **Update your fork**
```bash
git fetch upstream
git rebase upstream/dev
```
2. **Create a feature branch**
```bash
git checkout -b feat/my-new-feature
```
3. **Make your changes** and commit following our guidelines
4. **Push to your fork**
```bash
git push origin feat/my-new-feature
```
5. **Create a Pull Request**
- Target the `dev` branch
- Fill in the PR template
- Link related issues
6. **Address review feedback**
- Make requested changes
- Push additional commits
- Request re-review when ready
### PR Requirements
- [ ] Code follows project conventions
- [ ] All tests pass
- [ ] No new linting errors
- [ ] Documentation updated (if needed)
- [ ] Commit messages follow guidelines
- [ ] PR description is clear and complete
## Questions?
If you have questions, feel free to:
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
Thank you for contributing! 💚
+149 -20
View File
@@ -1,19 +1,29 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
<div align="center">
<img src="icon.png" width="128" />
<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">
</picture>
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
<p align="center">
<a href="https://trendshift.io/repositories/17247">
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
</a>
</p>
</div>
> **Active Development Notice**: This app is under heavy development. New builds may be pushed multiple times daily. If frequent update notifications are annoying, tap "Don't remind" when the update dialog appears, or disable update checks in Settings.
<div align="center">
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
</div>
## Screenshots
@@ -24,22 +34,141 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
## Other project
---
## Extensions
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
### Installing Extensions
1. Open the **Store** tab in the app
2. On first launch, enter an **Extension Repository URL** when prompted
3. Browse and install extensions with one tap
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
5. Configure extension settings if needed
6. Set provider priority under **Settings > Extensions > Provider Priority**
### Developing Extensions
> [!NOTE]
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
---
## Related Projects
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
## Disclaimer
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
---
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
## FAQ
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
<details>
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
<br>
You are solely responsible for:
1. Ensuring your use of this software complies with your local laws.
2. Reading and adhering to the Terms of Service of the respective platforms.
3. Any legal consequences resulting from the misuse of this tool.
Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
</details>
<details>
<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.
</details>
<details>
<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
</details>
<details>
<summary><b>Can I download playlists?</b></summary>
<br>
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
</details>
<details>
<summary><b>Why do I need to grant storage permission?</b></summary>
<br>
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
</details>
<details>
<summary><b>Is this app safe?</b></summary>
<br>
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
</details>
<details>
<summary><b>Why is downloading not working in my country?</b></summary>
<br>
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
</details>
<details>
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
<br>
Yes! Add the official source to receive updates directly within the app. Copy this link:
```
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
```
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
</details>
---
## Contributors
Thanks to everyone who has contributed to SpotiFLAC Mobile!
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
</a>
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
---
## API Credits
| | | | | |
|---|---|---|---|---|
| [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) | |
> [!NOTE]
> If SpotiFLAC is useful to you, consider supporting development:
>
> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
> [!TIP]
> **Star the repo** to get notified about all new releases directly from GitHub.
+19 -11
View File
@@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
@@ -7,9 +10,9 @@ plugins {
// Load keystore properties for local builds
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = java.util.Properties()
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
@@ -32,10 +35,10 @@ android {
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
}
}
}
@@ -43,7 +46,7 @@ android {
defaultConfig {
applicationId = "com.zarz.spotiflac"
minSdk = flutter.minSdkVersion
targetSdk = 34
targetSdk = 36
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
@@ -93,8 +96,13 @@ repositories {
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation(files("libs/gobackend.aar"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// Include all AAR and JAR files from libs folder
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.activity:activity-ktx:1.12.3")
}
+85 -2
View File
@@ -5,29 +5,112 @@
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
-keep class io.flutter.embedding.** { *; }
# Go backend (gobackend.aar)
# Ignore missing Play Core classes (not used, but referenced by Flutter)
-dontwarn com.google.android.play.core.splitcompat.**
-dontwarn com.google.android.play.core.splitinstall.**
-dontwarn com.google.android.play.core.tasks.**
# Ignore missing javax.xml.stream (not used on Android)
-dontwarn javax.xml.stream.**
# Go backend (gobackend.aar) - CRITICAL for release builds
-keep class gobackend.** { *; }
-keep class go.** { *; }
-keep interface gobackend.** { *; }
-keepclassmembers class gobackend.** { *; }
# Go mobile binding internals
-keep class org.golang.** { *; }
-dontwarn org.golang.**
# FFmpeg Kit
-keep class com.arthenica.ffmpegkit.** { *; }
-keep class com.arthenica.smartexception.** { *; }
# FFmpeg Kit (new fork package)
-keep class com.antonkarpenko.ffmpegkit.** { *; }
-keep class com.antonkarpenko.smartexception.** { *; }
# Apache Tika (if used by FFmpeg)
-dontwarn org.apache.tika.**
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Kotlin coroutines
# Kotlin coroutines - expanded rules
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** {
volatile <fields>;
}
-keepclassmembernames class kotlinx.** {
volatile <fields>;
}
-dontwarn kotlinx.coroutines.**
# Kotlin serialization
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
-dontwarn kotlin.**
-keep class kotlin.** { *; }
-keep class kotlin.Metadata { *; }
# Keep MainActivity and related classes
-keep class com.zarz.spotiflac.** { *; }
# Prevent R8 from removing metadata
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
-keepattributes Signature
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes EnclosingMethod
# JSON parsing (used by Go backend responses)
-keep class org.json.** { *; }
# Shared Preferences
-keep class androidx.datastore.** { *; }
-dontwarn androidx.datastore.**
# Flutter Plugins - CRITICAL: Prevent R8 from removing plugin implementations
# Path Provider
-keep class io.flutter.plugins.pathprovider.** { *; }
-keep class dev.flutter.pigeon.** { *; }
# Local Notifications
-keep class com.dexterous.** { *; }
-keep class com.dexterous.flutterlocalnotifications.** { *; }
# Receive Sharing Intent
-keep class com.kasem.receive_sharing_intent.** { *; }
# Permission Handler
-keep class com.baseflow.permissionhandler.** { *; }
# File Picker
-keep class com.mr.flutter.plugin.filepicker.** { *; }
# URL Launcher
-keep class io.flutter.plugins.urllauncher.** { *; }
# Share Plus
-keep class dev.fluttercommunity.plus.share.** { *; }
# Device Info Plus
-keep class dev.fluttercommunity.plus.device_info.** { *; }
# Open File
-keep class com.crazecoder.openfile.** { *; }
# Sqflite
-keep class com.tekartik.sqflite.** { *; }
# Dynamic Color
-keep class io.material.** { *; }
# Keep all Flutter plugin registrants
-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
-keep class ** extends io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
+51 -4
View File
@@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -20,9 +21,10 @@
android:label="SpotiFLAC"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true">
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
android:localeConfig="@xml/locale_config">
<activity
android:name=".MainActivity"
@@ -43,7 +45,7 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Handle Spotify URL sharing -->
<!-- Handle music URL sharing (Spotify, Deezer, Tidal, YT Music) -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -57,6 +59,33 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="open.spotify.com" />
</intent-filter>
<!-- Handle Deezer deep links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="www.deezer.com" />
<data android:scheme="https" android:host="deezer.com" />
<data android:scheme="https" android:host="deezer.page.link" />
</intent-filter>
<!-- Handle Tidal deep links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="tidal.com" />
<data android:scheme="https" android:host="listen.tidal.com" />
</intent-filter>
<!-- Handle YouTube Music deep links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="music.youtube.com" />
</intent-filter>
</activity>
<!-- Download Service -->
@@ -65,6 +94,24 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Audio playback service for media notification / background audio -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- flutter_local_notifications receivers -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
@@ -15,6 +15,9 @@ import androidx.core.app.NotificationCompat
/**
* Foreground service to keep downloads running when app is in background.
* This prevents Android from killing the download process or throttling network.
*
* Note: Android 15+ (API 35+) has a 6-hour timeout for dataSync foreground services.
* The service will be stopped automatically after 6 hours of cumulative runtime in 24 hours.
*/
class DownloadService : Service() {
@@ -106,6 +109,17 @@ class DownloadService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
/**
* Called when the foreground service timeout is reached (Android 15+, API 35+).
* dataSync services have a 6-hour limit in a 24-hour period.
* We must call stopSelf() within a few seconds to avoid a crash.
*/
override fun onTimeout(startId: Int, fgsType: Int) {
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
stopForegroundService()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

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

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 70 KiB

+103
View File
@@ -0,0 +1,103 @@
# git-cliff configuration for SpotiFLAC Mobile
# https://git-cliff.org/docs/configuration
[changelog]
# Template for the changelog body
body = """
{%- macro remote_url() -%}
https://github.com/zarzet/SpotiFLAC-Mobile
{%- endmacro -%}
{% if version %}\
## {{ version | trim_start_matches(pat="v") }}
{% else %}\
## Unreleased
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
{{ commit.message | upper_first }}\
{% if commit.github.pr_number %} \
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
{% endif %}\
{%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
{%- endfor %}
{% endfor %}
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
### New Contributors
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution
{%- if contributor.pr_number %} in \
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
{%- endif %}
{%- endfor %}
{%- endif -%}
{% if version %}
{% if previous.version %}
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
{% endif %}
{% else -%}
{% raw %}\n{% endraw %}
{% endif %}
"""
# Remove leading and trailing whitespace
trim = true
[git]
# Parse conventional commits
conventional_commits = true
filter_unconventional = true
# Process each line of a commit as an individual commit
split_commits = false
# Regex for preprocessing the commit messages
commit_preprocessors = [
# Strip conventional commit prefix for cleaner messages
# (group header already shows the type)
]
# Regex for parsing and grouping commits
commit_parsers = [
# Skip noise: translation commits from Crowdin
{ message = "^New translations", skip = true },
{ message = "^Update source file", skip = true },
# Skip merge commits
{ message = "^Merge", skip = true },
# Skip version bump commits
{ message = "^v\\d+", skip = true },
{ message = "^chore: update VirusTotal", skip = true },
# Group by conventional commit type
{ message = "^feat", group = "<!-- 0 -->New Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 2 -->Performance" },
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
{ message = "^doc", group = "<!-- 4 -->Documentation" },
{ message = "^style", group = "<!-- 5 -->Styling" },
{ message = "^test", group = "<!-- 6 -->Testing" },
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
{ message = "^chore\\(l10n\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
]
# Protect breaking changes from being skipped
protect_breaking_commits = true
# Filter out commits by matching patterns
filter_commits = false
# Tag pattern for version detection
tag_pattern = "v[0-9].*"
# Sort commits by newest first
sort_commits = "newest"
[remote.github]
owner = "zarzet"
repo = "SpotiFLAC-Mobile"
+19
View File
@@ -0,0 +1,19 @@
files:
- source: /lib/l10n/arb/app_en.arb
translation: /lib/l10n/arb/app_%locale%.arb
languages_mapping:
locale:
# Short codes for single-variant languages
de: de
es: es
fr: fr
hi: hi
id: id
ja: ja
ko: ko
nl: nl
pt: pt
ru: ru
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
-403
View File
@@ -1,403 +0,0 @@
package gobackend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
type AmazonDownloader struct {
client *http.Client
regions []string // us, eu regions for DoubleDouble service
}
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
type DoubleDoubleSubmitResponse struct {
Success bool `json:"success"`
ID string `json:"id"`
}
// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint
type DoubleDoubleStatusResponse struct {
Status string `json:"status"`
FriendlyStatus string `json:"friendlyStatus"`
URL string `json:"url"`
Current struct {
Name string `json:"name"`
Artist string `json:"artist"`
} `json:"current"`
}
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
regions: []string{"us", "eu"}, // Same regions as PC
}
}
// GetAvailableAPIs returns list of available DoubleDouble regions
// Uses same service as PC version (doubledouble.top)
func (a *AmazonDownloader) GetAvailableAPIs() []string {
// DoubleDouble service regions (same as PC)
// Format: https://{region}.doubledouble.top
var apis []string
for _, region := range a.regions {
apis = append(apis, fmt.Sprintf("https://%s.doubledouble.top", region))
}
return apis
}
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
// This uses submit → poll → download mechanism
// Internal function - not exported to gomobile
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir string) (string, string, string, error) {
var lastError error
for _, region := range a.regions {
fmt.Printf("[Amazon] Trying region: %s...\n", region)
// Build base URL for DoubleDouble service
// Decode base64 service URL (same as PC)
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
// Step 1: Submit download request
encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
req, err := http.NewRequest("GET", submitURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create request: %w", err)
continue
}
req.Header.Set("User-Agent", getRandomUserAgent())
fmt.Println("[Amazon] Submitting download request...")
resp, err := a.client.Do(req)
if err != nil {
lastError = fmt.Errorf("failed to submit request: %w", err)
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
continue
}
var submitResp DoubleDoubleSubmitResponse
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
resp.Body.Close()
lastError = fmt.Errorf("failed to decode submit response: %w", err)
continue
}
resp.Body.Close()
if !submitResp.Success || submitResp.ID == "" {
lastError = fmt.Errorf("submit request failed")
continue
}
downloadID := submitResp.ID
fmt.Printf("[Amazon] Download ID: %s\n", downloadID)
// Step 2: Poll for completion
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
fmt.Println("[Amazon] Waiting for download to complete...")
maxWait := 300 * time.Second // 5 minutes max wait
elapsed := time.Duration(0)
pollInterval := 3 * time.Second
for elapsed < maxWait {
time.Sleep(pollInterval)
elapsed += pollInterval
statusReq, err := http.NewRequest("GET", statusURL, nil)
if err != nil {
continue
}
statusReq.Header.Set("User-Agent", getRandomUserAgent())
statusResp, err := a.client.Do(statusReq)
if err != nil {
fmt.Printf("\r[Amazon] Status check failed, retrying...")
continue
}
if statusResp.StatusCode != 200 {
statusResp.Body.Close()
fmt.Printf("\r[Amazon] Status check failed (status %d), retrying...", statusResp.StatusCode)
continue
}
var status DoubleDoubleStatusResponse
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
statusResp.Body.Close()
fmt.Printf("\r[Amazon] Invalid JSON response, retrying...")
continue
}
statusResp.Body.Close()
if status.Status == "done" {
fmt.Println("\n[Amazon] Download ready!")
// Build download URL
fileURL := status.URL
if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
} else if strings.HasPrefix(fileURL, "/") {
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
}
trackName := status.Current.Name
artist := status.Current.Artist
fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName)
return fileURL, trackName, artist, nil
} else if status.Status == "error" {
errorMsg := status.FriendlyStatus
if errorMsg == "" {
errorMsg = "Unknown error"
}
lastError = fmt.Errorf("processing failed: %s", errorMsg)
break
} else {
// Still processing
friendlyStatus := status.FriendlyStatus
if friendlyStatus == "" {
friendlyStatus = status.Status
}
fmt.Printf("\r[Amazon] %s...", friendlyStatus)
}
}
if elapsed >= maxWait {
lastError = fmt.Errorf("download timeout")
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
continue
}
if lastError != nil {
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
}
}
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
}
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Initialize item progress (required for all downloads)
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := a.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
// Set total bytes if available
if resp.ContentLength > 0 && itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
defer out.Close()
// Use item progress writer
var bytesWritten int64
if itemID != "" {
pw := NewItemProgressWriter(out, itemID)
bytesWritten, err = io.Copy(pw, resp.Body)
} else {
// Fallback: direct copy without progress tracking
bytesWritten, err = io.Copy(out, resp.Body)
}
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(1024*1024))
return nil
}
// AmazonDownloadResult contains download result with quality info
type AmazonDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
}
// downloadFromAmazon downloads a track using the request parameters
// Uses DoubleDouble service (same as PC version)
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Get Amazon URL from SongLink
songlink := NewSongLinkClient()
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
if !availability.Amazon || availability.AmazonURL == "" {
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
// Create output directory if needed
if req.OutputDir != "." {
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
}
}
// Download using DoubleDouble service (same as PC)
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
// Build filename using Spotify metadata (more accurate)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
// Download file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Log track info from DoubleDouble (for debugging)
if trackName != "" && artistName != "" {
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
}
// Embed metadata using Spotify data (more accurate than DoubleDouble)
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
}
// Download cover to memory (avoids file permission issues on Android)
var coverData []byte
if req.CoverURL != "" {
fmt.Println("[Amazon] Downloading cover to memory...")
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
if err == nil {
coverData = data
fmt.Printf("[Amazon] Cover downloaded successfully (%d bytes)\n", len(coverData))
} else {
fmt.Printf("[Amazon] Warning: failed to download cover: %v\n", err)
}
}
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics if enabled
if req.EmbedLyrics {
fmt.Println("[Amazon] Fetching lyrics...")
lyricsClient := NewLyricsClient()
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
if lyricsErr != nil {
fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr)
} else if lyrics == nil || len(lyrics.Lines) == 0 {
fmt.Println("[Amazon] No lyrics found for this track")
} else {
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
lrcContent := convertToLRC(lyrics)
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Amazon] Lyrics embedded successfully")
}
}
}
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
// Read actual quality from the downloaded FLAC file
// Amazon API doesn't provide quality info, but we can read it from the file itself
quality, err := GetAudioQuality(outputPath)
if err != nil {
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
// Return 0 to indicate unknown quality
return AmazonDownloadResult{
FilePath: outputPath,
BitDepth: 0,
SampleRate: 0,
}, nil
}
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
return AmazonDownloadResult{
FilePath: outputPath,
BitDepth: quality.BitDepth,
SampleRate: quality.SampleRate,
}, nil
}
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
package gobackend
import (
"context"
"errors"
"sync"
)
// ErrDownloadCancelled is returned when a download is cancelled by the user.
var ErrDownloadCancelled = errors.New("download cancelled")
type cancelEntry struct {
cancel context.CancelFunc
canceled bool
}
var (
cancelMu sync.Mutex
cancelMap = make(map[string]*cancelEntry)
)
func initDownloadCancel(itemID string) context.Context {
if itemID == "" {
return context.Background()
}
cancelMu.Lock()
defer cancelMu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{
cancel: cancel,
canceled: false,
}
return ctx
}
func cancelDownload(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
if ok {
entry.canceled = true
if entry.cancel != nil {
entry.cancel()
}
} else {
cancelMap[itemID] = &cancelEntry{canceled: true}
}
cancelMu.Unlock()
RemoveItemProgress(itemID)
}
func isDownloadCancelled(itemID string) bool {
if itemID == "" {
return false
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
canceled := ok && entry.canceled
cancelMu.Unlock()
return canceled
}
func clearDownloadCancel(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
delete(cancelMap, itemID)
cancelMu.Unlock()
}
+63 -37
View File
@@ -4,36 +4,53 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
)
// Spotify image size codes (same as PC version)
const (
spotifySize640 = "ab67616d0000b273" // 640x640
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
spotifySize300 = "ab67616d00001e02"
spotifySize640 = "ab67616d0000b273"
spotifySizeMax = "ab67616d000082c1"
)
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
// This avoids file permission issues on Android
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
}
return imageURL
}
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
if coverURL == "" {
return nil, fmt.Errorf("no cover URL provided")
}
fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL)
GoLog("[Cover] Original URL: %s", coverURL)
downloadURL := convertSmallToMedium(coverURL)
if downloadURL != coverURL {
GoLog("[Cover] Upgraded 300x300 → 640x640")
}
// Upgrade to max quality if requested
downloadURL := coverURL
if maxQuality {
downloadURL = upgradeToMaxQuality(coverURL)
if downloadURL != coverURL {
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL)
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
// Log already printed by upgradeToMaxQuality for Deezer
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
}
}
GoLog("[Cover] Final URL: %s", downloadURL)
client := NewHTTPClientWithTimeout(DefaultTimeout)
// Create request with User-Agent (required by Spotify CDN)
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -54,48 +71,57 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("failed to read cover data: %w", err)
}
fmt.Printf("[Cover] Downloaded %d bytes\n", len(data))
sizeKB := len(data) / 1024
var resolution string
if sizeKB > 200 {
resolution = "~2000x2000 (hi-res)"
} else if sizeKB > 50 {
resolution = "~640x640"
} else {
resolution = "~300x300"
}
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
return data, nil
}
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
// Uses same logic as PC version - replaces 640x640 size code with max resolution
func upgradeToMaxQuality(coverURL string) string {
// Spotify image URLs can be upgraded by changing the size parameter
// Format: https://i.scdn.co/image/ab67616d0000b273...
// ab67616d0000b273 = 640x640
// ab67616d000082c1 = Max resolution (~2000x2000)
// Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) {
// Try max resolution first
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
// Verify max resolution URL is available
client := NewHTTPClientWithTimeout(DefaultTimeout)
req, err := http.NewRequest("HEAD", maxURL, nil)
if err == nil {
resp, err := DoRequestWithUserAgent(client, req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return maxURL
}
}
}
// Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
}
return coverURL
}
// GetCoverFromSpotify gets cover URL from Spotify metadata
func upgradeDeezerCover(coverURL string) string {
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return coverURL
}
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
if upgraded != coverURL {
GoLog("[Cover] Deezer: upgraded to 1800x1800")
}
return upgraded
}
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
return ""
}
// Always upgrade small to medium first
result := convertSmallToMedium(imageURL)
if maxQuality {
return upgradeToMaxQuality(imageURL)
result = upgradeToMaxQuality(result)
}
return imageURL
return result
}
+590
View File
@@ -0,0 +1,590 @@
package gobackend
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// CueSheet represents a parsed .cue file
type CueSheet struct {
// Album-level metadata
Performer string `json:"performer"`
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
Genre string `json:"genre,omitempty"`
Date string `json:"date,omitempty"`
Comment string `json:"comment,omitempty"`
Composer string `json:"composer,omitempty"`
Tracks []CueTrack `json:"tracks"`
}
// CueTrack represents a single track in a cue sheet
type CueTrack struct {
Number int `json:"number"`
Title string `json:"title"`
Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
// Index positions in seconds (fractional)
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
}
// CueSplitInfo represents the information needed to split a CUE+audio file
type CueSplitInfo struct {
CuePath string `json:"cue_path"`
AudioPath string `json:"audio_path"`
Album string `json:"album"`
Artist string `json:"artist"`
Genre string `json:"genre,omitempty"`
Date string `json:"date,omitempty"`
Tracks []CueSplitTrack `json:"tracks"`
}
// CueSplitTrack has the FFmpeg split parameters for a single track
type CueSplitTrack struct {
Number int `json:"number"`
Title string `json:"title"`
Artist string `json:"artist"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
StartSec float64 `json:"start_sec"`
EndSec float64 `json:"end_sec"` // -1 means until end of file
}
var (
reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`)
reQuoted = regexp.MustCompile(`"([^"]*)"`)
)
// ParseCueFile parses a .cue file and returns a CueSheet
func ParseCueFile(cuePath string) (*CueSheet, error) {
f, err := os.Open(cuePath)
if err != nil {
return nil, fmt.Errorf("failed to open cue file: %w", err)
}
defer f.Close()
sheet := &CueSheet{}
var currentTrack *CueTrack
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// Handle BOM at start of file
if strings.HasPrefix(line, "\xef\xbb\xbf") {
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
line = strings.TrimSpace(line)
}
upper := strings.ToUpper(line)
// REM commands (album-level metadata)
if strings.HasPrefix(upper, "REM ") {
matches := reRemCommand.FindStringSubmatch(line)
if len(matches) == 3 {
key := strings.ToUpper(matches[1])
value := unquoteCue(matches[2])
switch key {
case "GENRE":
sheet.Genre = value
case "DATE":
sheet.Date = value
case "COMMENT":
sheet.Comment = value
case "COMPOSER":
if currentTrack != nil {
currentTrack.Composer = value
} else {
sheet.Composer = value
}
}
}
continue
}
if strings.HasPrefix(upper, "PERFORMER ") {
value := unquoteCue(line[len("PERFORMER "):])
if currentTrack != nil {
currentTrack.Performer = value
} else {
sheet.Performer = value
}
continue
}
if strings.HasPrefix(upper, "TITLE ") {
value := unquoteCue(line[len("TITLE "):])
if currentTrack != nil {
currentTrack.Title = value
} else {
sheet.Title = value
}
continue
}
if strings.HasPrefix(upper, "FILE ") {
rest := line[len("FILE "):]
// Extract filename and type
// Format: FILE "filename.flac" WAVE
// or: FILE filename.flac WAVE
fname, ftype := parseCueFileLine(rest)
sheet.FileName = fname
sheet.FileType = ftype
continue
}
if strings.HasPrefix(upper, "TRACK ") {
// Save previous track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
parts := strings.Fields(line)
trackNum := 0
if len(parts) >= 2 {
trackNum, _ = strconv.Atoi(parts[1])
}
currentTrack = &CueTrack{
Number: trackNum,
PreGap: -1,
}
continue
}
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
parts := strings.Fields(line)
if len(parts) >= 3 {
indexNum, _ := strconv.Atoi(parts[1])
timeSec := parseCueTimestamp(parts[2])
switch indexNum {
case 0:
currentTrack.PreGap = timeSec
case 1:
currentTrack.StartTime = timeSec
}
}
continue
}
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
continue
}
// SONGWRITER (used as composer sometimes)
if strings.HasPrefix(upper, "SONGWRITER ") {
value := unquoteCue(line[len("SONGWRITER "):])
if currentTrack != nil {
currentTrack.Composer = value
} else {
sheet.Composer = value
}
continue
}
}
// Don't forget the last track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading cue file: %w", err)
}
if len(sheet.Tracks) == 0 {
return nil, fmt.Errorf("no tracks found in cue file")
}
return sheet, nil
}
// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
func parseCueTimestamp(ts string) float64 {
parts := strings.Split(ts, ":")
if len(parts) != 3 {
return 0
}
minutes, _ := strconv.Atoi(parts[0])
seconds, _ := strconv.Atoi(parts[1])
frames, _ := strconv.Atoi(parts[2])
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
}
// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg
func formatCueTimestamp(seconds float64) string {
if seconds < 0 {
return "0"
}
hours := int(seconds) / 3600
mins := (int(seconds) % 3600) / 60
secs := seconds - float64(hours*3600) - float64(mins*60)
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
}
// unquoteCue removes surrounding quotes from a CUE value
func unquoteCue(s string) string {
s = strings.TrimSpace(s)
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
return matches[1]
}
return s
}
// parseCueFileLine parses the FILE command's filename and type
func parseCueFileLine(rest string) (string, string) {
rest = strings.TrimSpace(rest)
var filename, ftype string
if strings.HasPrefix(rest, "\"") {
// Quoted filename
endQuote := strings.Index(rest[1:], "\"")
if endQuote >= 0 {
filename = rest[1 : endQuote+1]
remaining := strings.TrimSpace(rest[endQuote+2:])
ftype = remaining
} else {
filename = rest
}
} else {
// Unquoted filename - last word is the type
parts := strings.Fields(rest)
if len(parts) >= 2 {
ftype = parts[len(parts)-1]
filename = strings.Join(parts[:len(parts)-1], " ")
} else if len(parts) == 1 {
filename = parts[0]
}
}
return filename, strings.TrimSpace(ftype)
}
// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet.
// It checks relative to the cue file's directory.
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
cueDir := filepath.Dir(cuePath)
// 1. Try the exact filename from the .cue
candidate := filepath.Join(cueDir, cueFileName)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
// 2. Try common case variations
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, baseName+ext)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
// Try uppercase ext
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
// 3. Try to find any audio file with the same base name as the .cue file
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, cueBase+ext)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
// 4. If there's only one audio file in the directory, use that
entries, err := os.ReadDir(cueDir)
if err == nil {
audioExts := map[string]bool{
".flac": true, ".wav": true, ".ape": true, ".mp3": true,
".ogg": true, ".wv": true, ".m4a": true, ".aiff": true,
}
var audioFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if audioExts[ext] {
audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name()))
}
}
if len(audioFiles) == 1 {
return audioFiles[0]
}
}
return ""
}
// BuildCueSplitInfo creates the split information from a parsed CUE sheet.
// This is returned to the Dart side so FFmpeg can perform the splitting.
// audioDir, if non-empty, overrides the directory for audio file resolution.
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
resolveDir := cuePath
if audioDir != "" {
// Create a virtual path in audioDir so ResolveCueAudioPath looks there
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
}
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
if audioPath == "" {
return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName)
}
info := &CueSplitInfo{
CuePath: cuePath,
AudioPath: audioPath,
Album: sheet.Title,
Artist: sheet.Performer,
Genre: sheet.Genre,
Date: sheet.Date,
}
for i, track := range sheet.Tracks {
performer := track.Performer
if performer == "" {
performer = sheet.Performer
}
composer := track.Composer
if composer == "" {
composer = sheet.Composer
}
// End time is the start of the next track, or -1 for the last track
endSec := float64(-1)
if i+1 < len(sheet.Tracks) {
nextTrack := sheet.Tracks[i+1]
// Use pre-gap of next track if available, otherwise its start time
if nextTrack.PreGap >= 0 {
endSec = nextTrack.PreGap
} else {
endSec = nextTrack.StartTime
}
}
info.Tracks = append(info.Tracks, CueSplitTrack{
Number: track.Number,
Title: track.Title,
Artist: performer,
ISRC: track.ISRC,
Composer: composer,
StartSec: track.StartTime,
EndSec: endSec,
})
}
return info, nil
}
// ParseCueFileJSON parses a .cue file and returns JSON with split info.
// This is the main entry point called from Dart via the platform bridge.
// audioDir, if non-empty, overrides the directory used for resolving the
// referenced audio file (useful when the .cue was copied to a temp dir
// but the audio still lives in the original location, e.g. SAF).
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
return "", fmt.Errorf("failed to parse cue file: %w", err)
}
info, err := BuildCueSplitInfo(cuePath, sheet, audioDir)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(info)
if err != nil {
return "", fmt.Errorf("failed to marshal cue split info: %w", err)
}
return string(jsonBytes), nil
}
// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult
// entries, one per track. This is used by the library scanner to populate the
// library with individual track entries from a single CUE+FLAC album.
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
return nil, err
}
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "")
if err != nil {
return nil, err
}
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime)
}
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
// for SAF (Storage Access Framework) scenarios:
// - audioDir: if non-empty, overrides the directory used to find the audio file
// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for
// virtual file paths (e.g. a content:// URI). IDs are also based on this.
// - fileModTime: if > 0, used as the FileModTime for all results instead of
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
return nil, err
}
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir)
if err != nil {
return nil, err
}
return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime)
}
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
if sheet == nil {
return "", fmt.Errorf("cue sheet is nil for %s", cuePath)
}
resolveBase := cuePath
if audioDir != "" {
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
}
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
if audioPath == "" {
return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
}
return audioPath, nil
}
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
if sheet == nil {
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
}
// Try to get quality info from the audio file
var bitDepth, sampleRate int
var totalDurationSec float64
audioExt := strings.ToLower(filepath.Ext(audioPath))
switch audioExt {
case ".flac":
quality, qErr := GetAudioQuality(audioPath)
if qErr == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate)
}
}
case ".mp3":
quality, qErr := GetMP3Quality(audioPath)
if qErr == nil {
sampleRate = quality.SampleRate
totalDurationSec = float64(quality.Duration)
}
}
// Extract cover from audio file for all tracks
var coverPath string
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" {
cp, err := SaveCoverToCache(audioPath, coverCacheDir)
if err == nil && cp != "" {
coverPath = cp
}
}
// Determine the base path for virtual paths and IDs
pathBase := cuePath
if virtualPathPrefix != "" {
pathBase = virtualPathPrefix
}
// Determine fileModTime
modTime := fileModTime
if modTime <= 0 {
if info, err := os.Stat(cuePath); err == nil {
modTime = info.ModTime().UnixMilli()
}
}
var results []LibraryScanResult
for i, track := range sheet.Tracks {
performer := track.Performer
if performer == "" {
performer = sheet.Performer
}
if performer == "" {
performer = "Unknown Artist"
}
title := track.Title
if title == "" {
title = fmt.Sprintf("Track %02d", track.Number)
}
album := sheet.Title
if album == "" {
album = "Unknown Album"
}
// Calculate duration for this track
var duration int
if i+1 < len(sheet.Tracks) {
nextStart := sheet.Tracks[i+1].StartTime
if sheet.Tracks[i+1].PreGap >= 0 {
nextStart = sheet.Tracks[i+1].PreGap
}
duration = int(nextStart - track.StartTime)
} else if totalDurationSec > 0 {
duration = int(totalDurationSec - track.StartTime)
}
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
// Use a virtual file path that includes the track number to ensure
// uniqueness in the database (file_path has a UNIQUE constraint).
// Format: /path/to/album.cue#track01 or content://...album.cue#track01
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
result := LibraryScanResult{
ID: id,
TrackName: title,
ArtistName: performer,
AlbumName: album,
AlbumArtist: sheet.Performer,
FilePath: virtualFilePath,
CoverPath: coverPath,
ScannedAt: scanTime,
ISRC: track.ISRC,
TrackNumber: track.Number,
DiscNumber: 1,
Duration: duration,
ReleaseDate: sheet.Date,
BitDepth: bitDepth,
SampleRate: sampleRate,
Genre: sheet.Genre,
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
}
result.FileModTime = modTime
results = append(results, result)
}
return results, nil
}
+1275
View File
File diff suppressed because it is too large Load Diff
+606
View File
@@ -0,0 +1,606 @@
package gobackend
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
const deezerMusicDLURL = "https://www.musicdl.me/api/download"
type YoinkifyRequest struct {
URL string `json:"url"`
Format string `json:"format"`
GenreSource string `json:"genreSource"`
}
type DeezerDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
}
func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) {
rawSpotify := strings.TrimSpace(req.SpotifyID)
if rawSpotify != "" {
if isLikelySpotifyTrackID(rawSpotify) {
return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil
}
if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" {
return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil
}
}
deezerID := strings.TrimSpace(req.DeezerID)
if deezerID == "" {
if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found {
deezerID = strings.TrimSpace(prefixed)
}
}
if deezerID != "" {
songlink := NewSongLinkClient()
spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID)
if err != nil {
return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err)
}
spotifyID = strings.TrimSpace(spotifyID)
if spotifyID == "" {
return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID)
}
return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil
}
return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify")
}
func isLikelySpotifyTrackID(value string) bool {
if len(value) != 22 {
return false
}
for _, r := range value {
switch {
case r >= 'A' && r <= 'Z':
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
default:
return false
}
}
return true
}
func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error {
payload := YoinkifyRequest{
URL: spotifyURL,
Format: "flac",
GenreSource: "spotify",
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to encode Yoinkify request: %w", err)
}
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create Yoinkify request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("failed to call Yoinkify: %w", err)
}
defer resp.Body.Close()
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
bodyText := strings.TrimSpace(string(bodyBytes))
if bodyText != "" {
return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText)
}
return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode)
}
if strings.Contains(contentType, "application/json") {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
bodyText := strings.TrimSpace(string(bodyBytes))
if bodyText == "" {
bodyText = "empty JSON payload"
}
return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return err
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush output: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close output: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024))
return nil
}
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
deezerID := strings.TrimSpace(req.DeezerID)
if deezerID == "" {
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
deezerID = strings.TrimSpace(prefixed)
}
}
if deezerID != "" {
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
if err := verifyDeezerTrack(req, deezerID); err != nil {
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
// Don't reject direct IDs from request payload — they're presumably correct.
}
return trackURL, nil
}
// Try SongLink
spotifyID := strings.TrimSpace(req.SpotifyID)
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
songlink := NewSongLinkClient()
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
if err == nil && availability.Deezer && availability.DeezerURL != "" {
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
// Fall through to ISRC search instead of using wrong track.
} else {
return availability.DeezerURL, nil
}
} else {
return availability.DeezerURL, nil
}
}
}
// Try ISRC
isrc := strings.TrimSpace(req.ISRC)
if isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
if err == nil && track != nil {
resolvedID := songLinkExtractDeezerTrackID(track)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
}
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
}
}
}
return "", fmt.Errorf("could not resolve Deezer track URL")
}
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
if err != nil {
return nil // Can't verify — don't block the download.
}
resolved := resolvedTrackInfo{
Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists,
Duration: trackResp.Track.DurationMS / 1000,
}
if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
}
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
return nil
}
type deezerMusicDLRequest struct {
Platform string `json:"platform"`
URL string `json:"url"`
}
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
payload := deezerMusicDLRequest{
Platform: "deezer",
URL: deezerTrackURL,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("MusicDL request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
}
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
return "", fmt.Errorf("MusicDL error: %s", errMsg)
}
// Try various response fields for download URL
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
}
if data, ok := raw["data"].(map[string]any); ok {
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
}
}
return "", fmt.Errorf("no download URL found in MusicDL response")
}
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
if err != nil {
return err
}
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return err
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush output: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close output: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
return nil
}
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
deezerClient := GetDeezerClient()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
var outputPath string
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
}
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
coverURL := req.CoverURL
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
embedLyrics = false
}
parallelResult = FetchCoverAndLyricsParallel(
coverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
embedLyrics,
int64(req.DurationMS),
)
}()
// Try MusicDL first (better quality), fallback to Yoinkify
var downloadErr error
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
if deezerURLErr == nil {
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
return DeezerDownloadResult{}, ErrDownloadCancelled
}
GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr)
}
} else {
GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr)
}
if downloadErr != nil || deezerURLErr != nil {
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
if err != nil {
if deezerURLErr != nil {
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w",
deezerURLErr,
err,
)
}
return DeezerDownloadResult{}, err
}
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
return DeezerDownloadResult{}, ErrDownloadCancelled
}
return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr)
}
}
<-parallelDone
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
}
if isSafOutput || !req.EmbedMetadata {
if !req.EmbedMetadata {
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
} else {
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
}
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
}
}
}
}
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
}
bitDepth, sampleRate := 0, 0
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
}
lyricsLRC := ""
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return DeezerDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
}, nil
}
+229 -34
View File
@@ -1,59 +1,171 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
type ISRCIndex struct {
index map[string]string // ISRC (uppercase) -> file path
outputDir string
buildTime time.Time
mu sync.RWMutex
}
var (
isrcIndexCache = make(map[string]*ISRCIndex)
isrcIndexCacheMu sync.RWMutex
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
isrcIndexTTL = 5 * time.Minute
)
func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first
isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
mu := buildLock.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
// Double-check cache after acquiring lock (another goroutine may have built it)
isrcIndexCacheMu.RLock()
idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
return buildISRCIndex(outputDir)
}
func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{
index: make(map[string]string),
outputDir: outputDir,
buildTime: time.Now(),
}
if outputDir == "" {
return idx
}
startTime := time.Now()
fileCount := 0
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".flac" {
return nil
}
metadata, err := ReadMetadata(path)
if err != nil || metadata.ISRC == "" {
return nil
}
idx.index[strings.ToUpper(metadata.ISRC)] = path
fileCount++
return nil
})
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
isrcIndexCacheMu.Lock()
isrcIndexCache[outputDir] = idx
isrcIndexCacheMu.Unlock()
return idx
}
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
if isrc == "" {
return "", false
}
idx.mu.RLock()
defer idx.mu.RUnlock()
path, exists := idx.index[strings.ToUpper(isrc)]
return path, exists
}
func (idx *ISRCIndex) remove(isrc string) {
if isrc == "" {
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
delete(idx.index, strings.ToUpper(isrc))
}
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
path, _ := idx.lookup(isrc)
return path, nil
}
func (idx *ISRCIndex) Add(isrc, filePath string) {
if isrc == "" || filePath == "" {
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
idx.index[strings.ToUpper(isrc)] = filePath
}
func InvalidateISRCCache(outputDir string) {
isrcIndexCacheMu.Lock()
delete(isrcIndexCache, outputDir)
isrcIndexCacheMu.Unlock()
}
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
if isrc == "" || outputDir == "" {
return "", false
}
// Walk through directory looking for FLAC files
var foundFile string
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
// Only check FLAC files
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".flac") {
return nil
}
// Read metadata from file
metadata, err := ReadMetadata(path)
if err != nil {
return nil
}
// Check if ISRC matches
if metadata.ISRC == isrc {
foundFile = path
return filepath.SkipAll // Stop walking
}
return nil
})
if foundFile != "" {
return foundFile, true
idx := GetISRCIndex(outputDir)
filePath, exists := idx.lookup(isrc)
if !exists {
return "", false
}
return "", false
if !CheckFileExists(filePath) {
// Stale index entry; remove it and return not found.
idx.remove(isrc)
return "", false
}
return filePath, true
}
// CheckISRCExists is the exported version for gomobile (returns string, error)
// Returns the filepath if exists, empty string if not
func CheckISRCExists(outputDir, isrc string) (string, error) {
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
return filepath, nil
}
// CheckFileExists checks if a file with the given name exists
func CheckFileExists(filePath string) bool {
info, err := os.Stat(filePath)
if err != nil {
@@ -61,3 +173,86 @@ func CheckFileExists(filePath string) bool {
}
return !info.IsDir() && info.Size() > 0
}
type FileExistenceResult struct {
ISRC string `json:"isrc"`
Exists bool `json:"exists"`
FilePath string `json:"file_path,omitempty"`
TrackName string `json:"track_name,omitempty"`
ArtistName string `json:"artist_name,omitempty"`
}
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
}
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
return "", fmt.Errorf("failed to parse tracks JSON: %w", err)
}
results := make([]FileExistenceResult, len(tracks))
isrcIdx := GetISRCIndex(outputDir)
var wg sync.WaitGroup
for i, track := range tracks {
wg.Add(1)
go func(resultIdx int, t struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
}) {
defer wg.Done()
result := FileExistenceResult{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
Exists: false,
}
if t.ISRC != "" {
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
result.Exists = true
result.FilePath = filePath
}
}
results[resultIdx] = result
}(i, track)
}
wg.Wait()
resultJSON, err := json.Marshal(results)
if err != nil {
return "", fmt.Errorf("failed to marshal results: %w", err)
}
return string(resultJSON), nil
}
func PreBuildISRCIndex(outputDir string) error {
if outputDir == "" {
return fmt.Errorf("output directory is required")
}
buildISRCIndex(outputDir)
return nil
}
func AddToISRCIndex(outputDir, isrc, filePath string) {
if outputDir == "" || isrc == "" || filePath == "" {
return
}
isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists {
idx.Add(isrc, filePath)
}
}
+3133 -221
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
package gobackend
import "testing"
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
result := buildDeezerExtendedMetadataResult(nil)
if result["genre"] != "" {
t.Fatalf("expected empty genre, got %q", result["genre"])
}
if result["label"] != "" {
t.Fatalf("expected empty label, got %q", result["label"])
}
if result["copyright"] != "" {
t.Fatalf("expected empty copyright, got %q", result["copyright"])
}
}
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
Genre: "Rock",
Label: "EMI",
Copyright: "(C) Queen",
})
if result["genre"] != "Rock" {
t.Fatalf("unexpected genre: %q", result["genre"])
}
if result["label"] != "EMI" {
t.Fatalf("unexpected label: %q", result["label"])
}
if result["copyright"] != "(C) Queen" {
t.Fatalf("unexpected copyright: %q", result["copyright"])
}
}
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
result := buildDeezerISRCSearchResult(&TrackMetadata{
SpotifyID: "deezer:3135556",
Name: "Love Of My Life",
Artists: "Queen",
AlbumName: "A Night at the Opera",
ISRC: "GBUM71029604",
ReleaseDate: "1975-11-21",
})
if result["spotify_id"] != "deezer:3135556" {
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
}
if result["id"] != "3135556" {
t.Fatalf("unexpected id: %v", result["id"])
}
if result["track_id"] != "3135556" {
t.Fatalf("unexpected track_id: %v", result["track_id"])
}
if result["success"] != true {
t.Fatalf("expected success=true, got %v", result["success"])
}
}
+115
View File
@@ -0,0 +1,115 @@
package gobackend
import "testing"
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{
TrackName: "Bonus Track",
ArtistName: "Artist",
AlbumName: "Album (Deluxe)",
AlbumArtist: "Artist",
ReleaseDate: "2024-01-01",
TrackNumber: 14,
DiscNumber: 1,
ISRC: "REQ123",
CoverURL: "https://example.com/cover.jpg",
Genre: "Pop",
Label: "Label",
Copyright: "Copyright",
}
result := DownloadResult{
Title: "Bonus Track",
Artist: "Artist",
Album: "Album",
ReleaseDate: "2023-12-01",
TrackNumber: 2,
DiscNumber: 9,
ISRC: "RES456",
}
resp := buildDownloadSuccessResponse(
req,
result,
"tidal",
"ok",
"/tmp/test.flac",
false,
)
if resp.Album != req.AlbumName {
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
}
if resp.ReleaseDate != req.ReleaseDate {
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
}
if resp.TrackNumber != req.TrackNumber {
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
}
if resp.DiscNumber != req.DiscNumber {
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
}
if resp.Artist != result.Artist {
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
}
if resp.ISRC != result.ISRC {
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
}
}
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
DownloadRequest{
AlbumName: "Album (Deluxe Edition)",
ReleaseDate: "2024-01-01",
TrackNumber: 13,
DiscNumber: 2,
},
"Album",
"2023-01-01",
3,
1,
)
if album != "Album (Deluxe Edition)" {
t.Fatalf("album = %q", album)
}
if releaseDate != "2024-01-01" {
t.Fatalf("release date = %q", releaseDate)
}
if trackNumber != 13 {
t.Fatalf("track number = %d", trackNumber)
}
if discNumber != 2 {
t.Fatalf("disc number = %d", discNumber)
}
}
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
req := DownloadRequest{
TrackName: "Track",
ArtistName: "Artist",
AlbumName: "Album",
AlbumArtist: "Artist",
}
result := DownloadResult{
Title: "Track",
Artist: "Artist",
Album: "Album",
CoverURL: "https://cdn.qobuz.test/cover.jpg",
}
resp := buildDownloadSuccessResponse(
req,
result,
"qobuz",
"ok",
"/tmp/test.flac",
false,
)
if resp.CoverURL != result.CoverURL {
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
}
}
+963
View File
@@ -0,0 +1,963 @@
package gobackend
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/dop251/goja"
)
func compareVersions(v1, v2 string) int {
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for i := 0; i < maxLen; i++ {
var n1, n2 int
if i < len(parts1) {
n1, _ = strconv.Atoi(parts1[i])
}
if i < len(parts2) {
n2, _ = strconv.Atoi(parts2[i])
}
if n1 < n2 {
return -1
}
if n1 > n2 {
return 1
}
}
return 0
}
type LoadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *ExtensionRuntime
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
type ExtensionManager struct {
mu sync.RWMutex
extensions map[string]*LoadedExtension
extensionsDir string
dataDir string
}
var (
globalExtManager *ExtensionManager
globalExtManagerOnce sync.Once
)
func GetExtensionManager() *ExtensionManager {
globalExtManagerOnce.Do(func() {
globalExtManager = &ExtensionManager{
extensions: make(map[string]*LoadedExtension),
}
})
return globalExtManager
}
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.extensionsDir = extensionsDir
m.dataDir = dataDir
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
return fmt.Errorf("failed to create extensions directory: %w", err)
}
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
return nil
}
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
zipReader, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
}
defer zipReader.Close()
var manifestData []byte
var hasIndexJS bool
for _, file := range zipReader.File {
name := filepath.Base(file.Name)
if name == "manifest.json" {
rc, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
}
manifestData, err = io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
}
}
if name == "index.js" {
hasIndexJS = true
}
}
if manifestData == nil {
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
}
if !hasIndexJS {
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)
}
m.mu.RLock()
existing, exists := m.extensions[manifest.Name]
var existingVersion string
var existingDisplayName string
if exists {
existingVersion = existing.Manifest.Version
existingDisplayName = existing.Manifest.DisplayName
}
m.mu.RUnlock()
if exists {
versionCompare := compareVersions(manifest.Version, existingVersion)
if versionCompare > 0 {
return m.UpgradeExtension(filePath)
} else if versionCompare == 0 {
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)
}
}
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.extensions[manifest.Name]; exists {
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
}
extDir := filepath.Join(m.extensionsDir, manifest.Name)
if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err)
}
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
continue
}
destPath := filepath.Join(extDir, relPath)
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
}
destFile, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
}
srcFile, err := file.Open()
if err != nil {
destFile.Close()
return nil, fmt.Errorf("failed to open file in archive: %w", err)
}
_, err = io.Copy(destFile, srcFile)
srcFile.Close()
destFile.Close()
if err != nil {
return nil, fmt.Errorf("failed to extract file: %w", err)
}
}
extDataDir := filepath.Join(m.dataDir, manifest.Name)
if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
ext := &LoadedExtension{
ID: manifest.Name,
Manifest: manifest,
Enabled: false, // New extensions start disabled
DataDir: extDataDir,
SourceDir: extDir,
}
if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
}
m.extensions[manifest.Name] = ext
GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version)
return ext, nil
}
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
vm := goja.New()
ext.VM = vm
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return fmt.Errorf("failed to read index.js: %w", err)
}
runtime := NewExtensionRuntime(ext)
ext.runtime = runtime
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()
})
_, err = vm.RunString(string(jsCode))
if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err)
}
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
return fmt.Errorf("extension did not call registerExtension()")
}
return nil
}
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("Extension not found")
}
if ext.VM != nil {
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
if err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
GoLog("[Extension] Cleanup called for %s\n", extensionID)
}
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
}
ext.runtime.closeStorageFlusher()
ext.runtime = nil
}
delete(m.extensions, extensionID)
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
return nil
}
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
m.mu.RLock()
defer m.mu.RUnlock()
ext, exists := m.extensions[extensionID]
if !exists {
return nil, fmt.Errorf("Extension not found")
}
return ext, nil
}
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*LoadedExtension, 0, len(m.extensions))
for _, ext := range m.extensions {
result = append(result, ext)
}
return result
}
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("Extension not found")
}
ext.Enabled = enabled
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
store := GetExtensionSettingsStore()
if err := store.Set(extensionID, "_enabled", enabled); err != nil {
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
}
return nil
}
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
var loaded []string
var errors []error
entries, err := os.ReadDir(dirPath)
if err != nil {
if os.IsNotExist(err) {
return loaded, errors
}
return nil, []error{fmt.Errorf("failed to read extensions directory: %w", err)}
}
for _, entry := range entries {
if entry.IsDir() {
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
if _, err := os.Stat(manifestPath); err == nil {
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
if err != nil {
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err))
} else {
loaded = append(loaded, ext.ID)
}
}
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
if err != nil {
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err))
} else {
loaded = append(loaded, ext.ID)
}
}
}
return loaded, errors
}
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
m.mu.Lock()
defer m.mu.Unlock()
manifestPath := filepath.Join(dirPath, "manifest.json")
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
}
manifest, err := ParseManifest(manifestData)
if err != nil {
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")
}
if existing, exists := m.extensions[manifest.Name]; exists {
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
return existing, nil
}
extDataDir := filepath.Join(m.dataDir, manifest.Name)
if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
ext := &LoadedExtension{
ID: manifest.Name,
Manifest: manifest,
Enabled: false, // Will be restored from settings store
DataDir: extDataDir,
SourceDir: dirPath,
}
store := GetExtensionSettingsStore()
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
if enabled, ok := enabledVal.(bool); ok {
ext.Enabled = enabled
GoLog("[Extension] Restored enabled state for %s: %v\n", manifest.Name, enabled)
}
}
if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
}
m.extensions[manifest.Name] = ext
GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version)
return ext, nil
}
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
ext, err := m.GetExtension(extensionID)
if err != nil {
return err
}
if err := m.UnloadExtension(extensionID); err != nil {
return err
}
if ext.SourceDir != "" {
if err := os.RemoveAll(ext.SourceDir); err != nil {
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
}
}
return nil
}
// Only allows upgrades (new version > current version), not downgrades
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
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")
}
defer zipReader.Close()
var manifestData []byte
var hasIndexJS bool
for _, file := range zipReader.File {
name := filepath.Base(file.Name)
if name == "manifest.json" {
rc, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
}
manifestData, err = io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
}
}
if name == "index.js" {
hasIndexJS = true
}
}
if manifestData == nil {
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
}
if !hasIndexJS {
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)
}
m.mu.RLock()
existing, exists := m.extensions[newManifest.Name]
m.mu.RUnlock()
if !exists {
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)
}
if versionCompare == 0 {
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)
extDataDir := existing.DataDir
extDir := existing.SourceDir
wasEnabled := existing.Enabled
m.UnloadExtension(existing.ID)
if extDir != "" {
if err := os.RemoveAll(extDir); err != nil {
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
}
}
if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err)
}
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
continue
}
destPath := filepath.Join(extDir, relPath)
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
}
destFile, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
}
srcFile, err := file.Open()
if err != nil {
destFile.Close()
return nil, fmt.Errorf("failed to open file in archive: %w", err)
}
_, err = io.Copy(destFile, srcFile)
srcFile.Close()
destFile.Close()
if err != nil {
return nil, fmt.Errorf("failed to extract file: %w", err)
}
}
ext := &LoadedExtension{
ID: newManifest.Name,
Manifest: newManifest,
Enabled: wasEnabled, // Preserve enabled state from before upgrade
DataDir: extDataDir,
SourceDir: extDir,
}
if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
}
m.mu.Lock()
m.extensions[newManifest.Name] = ext
m.mu.Unlock()
GoLog("[Extension] Upgraded extension: %s to v%s\n", newManifest.DisplayName, newManifest.Version)
return ext, nil
}
type ExtensionUpgradeInfo struct {
ExtensionID string `json:"extension_id"`
CurrentVersion string `json:"current_version"`
NewVersion string `json:"new_version"`
CanUpgrade bool `json:"can_upgrade"`
IsInstalled bool `json:"is_installed"`
}
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
zipReader, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("Cannot open extension file")
}
defer zipReader.Close()
var manifestData []byte
for _, file := range zipReader.File {
name := filepath.Base(file.Name)
if name == "manifest.json" {
rc, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to open manifest.json")
}
manifestData, err = io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("failed to read manifest.json")
}
break
}
}
if manifestData == nil {
return nil, fmt.Errorf("manifest.json not found")
}
newManifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid manifest: %w", err)
}
m.mu.RLock()
existing, exists := m.extensions[newManifest.Name]
m.mu.RUnlock()
info := &ExtensionUpgradeInfo{
ExtensionID: newManifest.Name,
NewVersion: newManifest.Version,
IsInstalled: exists,
}
if !exists {
info.CurrentVersion = ""
info.CanUpgrade = false
} else {
info.CurrentVersion = existing.Manifest.Version
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
}
return info, nil
}
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
info, err := m.checkExtensionUpgradeInternal(filePath)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(info)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions()
type ExtensionInfo struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"`
Types []ExtensionType `json:"types"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
Error string `json:"error_message,omitempty"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"quality_options,omitempty"`
Permissions []string `json:"permissions"`
HasMetadataProvider bool `json:"has_metadata_provider"`
HasDownloadProvider bool `json:"has_download_provider"`
HasLyricsProvider bool `json:"has_lyrics_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
infos := make([]ExtensionInfo, len(extensions))
for i, ext := range extensions {
permissions := []string{}
for _, domain := range ext.Manifest.Permissions.Network {
permissions = append(permissions, "network:"+domain)
}
if ext.Manifest.Permissions.Storage {
permissions = append(permissions, "storage:enabled")
}
status := "loaded"
if ext.Error != "" {
status = "error"
} else if !ext.Enabled {
status = "disabled"
}
iconPath := ""
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
if _, err := os.Stat(possibleIcon); err == nil {
iconPath = possibleIcon
}
}
if iconPath == "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
if _, err := os.Stat(possibleIcon); err == nil {
iconPath = possibleIcon
}
}
infos[i] = ExtensionInfo{
ID: ext.ID,
Name: ext.Manifest.Name,
DisplayName: ext.Manifest.DisplayName,
Version: ext.Manifest.Version,
Author: ext.Manifest.Author,
Description: ext.Manifest.Description,
Homepage: ext.Manifest.Homepage,
IconPath: iconPath,
Types: ext.Manifest.Types,
Enabled: ext.Enabled,
Status: status,
Error: ext.Error,
Settings: ext.Manifest.Settings,
QualityOptions: ext.Manifest.QualityOptions,
Permissions: permissions,
HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching,
PostProcessing: ext.Manifest.PostProcessing,
Capabilities: ext.Manifest.Capabilities,
}
}
jsonBytes, err := json.Marshal(infos)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("Extension not found")
}
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")
}
script := fmt.Sprintf(`
(function() {
var settings = %s;
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
try {
extension.initialize(settings);
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no initialize function' };
})()
`, string(settingsJSON))
result, err := ext.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", extensionID, err)
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
}
ext.Error = errMsg
ext.Enabled = false
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
GoLog("[Extension] Initialized %s\n", extensionID)
return nil
}
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return fmt.Errorf("Extension not found")
}
if ext.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 := ext.VM.RunString(script)
if err != nil {
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
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
}
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
GoLog("[Extension] Cleaned up %s\n", extensionID)
return nil
}
func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Lock()
extensionIDs := make([]string, 0, len(m.extensions))
for id := range m.extensions {
extensionIDs = append(extensionIDs, id)
}
m.mu.Unlock()
for _, id := range extensionIDs {
m.UnloadExtension(id)
}
GoLog("[Extension] All extensions unloaded\n")
}
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return nil, fmt.Errorf("extension not found: %s", extensionID)
}
if ext.VM == nil {
return nil, fmt.Errorf("extension VM not initialized")
}
if !ext.Enabled {
return nil, fmt.Errorf("extension is disabled")
}
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
// Handle promise - return pending status
return { success: true, pending: true, message: 'Action started' };
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: false, error: 'Action function not found: %s' };
})()
`, actionName, actionName, actionName)
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
if err != nil {
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
return nil, fmt.Errorf("action failed: %v", err)
}
if result == nil || goja.IsUndefined(result) {
return map[string]interface{}{"success": true}, nil
}
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
return resultMap, nil
}
return map[string]interface{}{"success": true, "result": exported}, nil
}
+291
View File
@@ -0,0 +1,291 @@
package gobackend
import (
"encoding/json"
"fmt"
"strings"
)
type ExtensionType string
const (
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
)
type SettingType string
const (
SettingTypeString SettingType = "string"
SettingTypeNumber SettingType = "number"
SettingTypeBool SettingType = "boolean"
SettingTypeSelect SettingType = "select"
SettingTypeButton SettingType = "button" // Action button that calls a JS function
)
type ExtensionPermissions struct {
Network []string `json:"network"`
Storage bool `json:"storage"`
File bool `json:"file"`
}
type ExtensionSetting struct {
Key string `json:"key"`
Type SettingType `json:"type"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"`
Action string `json:"action,omitempty"`
}
type QualityOption struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
Settings []QualitySpecificSetting `json:"settings,omitempty"`
}
type QualitySpecificSetting struct {
Key string `json:"key"`
Type SettingType `json:"type"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"`
}
type SearchFilter struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Icon string `json:"icon,omitempty"`
}
type SearchBehaviorConfig struct {
Enabled bool `json:"enabled"`
Placeholder string `json:"placeholder,omitempty"`
Primary bool `json:"primary,omitempty"`
Icon string `json:"icon,omitempty"`
ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
Filters []SearchFilter `json:"filters,omitempty"`
}
type URLHandlerConfig struct {
Enabled bool `json:"enabled"`
Patterns []string `json:"patterns,omitempty"`
}
type TrackMatchingConfig struct {
CustomMatching bool `json:"customMatching"`
Strategy string `json:"strategy,omitempty"`
DurationTolerance int `json:"durationTolerance,omitempty"`
}
type PostProcessingHook struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
DefaultEnabled bool `json:"defaultEnabled,omitempty"`
SupportedFormats []string `json:"supportedFormats,omitempty"`
}
type PostProcessingConfig struct {
Enabled bool `json:"enabled"`
Hooks []PostProcessingHook `json:"hooks,omitempty"`
}
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
type ManifestValidationError struct {
Field string
Message string
}
func (e *ManifestValidationError) Error() string {
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
}
func ParseManifest(data []byte) (*ExtensionManifest, error) {
var manifest ExtensionManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
}
if err := manifest.Validate(); err != nil {
return nil, err
}
return &manifest, nil
}
func (m *ExtensionManifest) Validate() error {
if strings.TrimSpace(m.Name) == "" {
return &ManifestValidationError{Field: "name", Message: "name is required"}
}
if strings.TrimSpace(m.Version) == "" {
return &ManifestValidationError{Field: "version", Message: "version is required"}
}
if strings.TrimSpace(m.Author) == "" {
return &ManifestValidationError{Field: "author", Message: "author is required"}
}
if strings.TrimSpace(m.Description) == "" {
return &ManifestValidationError{Field: "description", Message: "description is required"}
}
if len(m.Types) == 0 {
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
}
for _, t := range m.Types {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
return &ManifestValidationError{
Field: "type",
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t),
}
}
}
for i, setting := range m.Settings {
if strings.TrimSpace(setting.Key) == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].key", i),
Message: "setting key is required",
}
}
if setting.Type == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].type", i),
Message: "setting type is required",
}
}
// Select type requires options
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].options", i),
Message: "select type requires options",
}
}
if setting.Type == SettingTypeButton && setting.Action == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].action", i),
Message: "button type requires action (JS function name)",
}
}
}
return nil
}
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
for _, et := range m.Types {
if et == t {
return true
}
}
return false
}
func (m *ExtensionManifest) IsMetadataProvider() bool {
return m.HasType(ExtensionTypeMetadataProvider)
}
func (m *ExtensionManifest) IsDownloadProvider() bool {
return m.HasType(ExtensionTypeDownloadProvider)
}
func (m *ExtensionManifest) IsLyricsProvider() bool {
return m.HasType(ExtensionTypeLyricsProvider)
}
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain))
for _, allowed := range m.Permissions.Network {
allowed = strings.ToLower(strings.TrimSpace(allowed))
if allowed == domain {
return true
}
// Support wildcard subdomains (e.g., *.example.com)
if strings.HasPrefix(allowed, "*.") {
suffix := allowed[1:]
if strings.HasSuffix(domain, suffix) {
return true
}
}
}
return false
}
func (m *ExtensionManifest) HasCustomSearch() bool {
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
}
func (m *ExtensionManifest) HasCustomMatching() bool {
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
}
func (m *ExtensionManifest) HasPostProcessing() bool {
return m.PostProcessing != nil && m.PostProcessing.Enabled
}
func (m *ExtensionManifest) HasURLHandler() bool {
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
}
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
if !m.HasURLHandler() {
return false
}
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
for _, pattern := range m.URLHandler.Patterns {
pattern = strings.ToLower(strings.TrimSpace(pattern))
if strings.Contains(urlStr, pattern) {
return true
}
}
return false
}
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
if m.PostProcessing == nil {
return nil
}
return m.PostProcessing.Hooks
}
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
return json.Marshal(m)
}
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
package gobackend
import "testing"
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
original := GetMetadataProviderPriority()
defer SetMetadataProviderPriority(original)
SetMetadataProviderPriority([]string{"tidal"})
got := GetMetadataProviderPriority()
want := []string{"tidal", "deezer", "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)
}
}
}
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
originalPriority := GetMetadataProviderPriority()
originalSearch := searchBuiltInMetadataTracksFunc
defer func() {
SetMetadataProviderPriority(originalPriority)
searchBuiltInMetadataTracksFunc = originalSearch
}()
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
var calls []string
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
calls = append(calls, providerID)
switch providerID {
case "qobuz":
return []ExtTrackMetadata{
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
}, nil
case "tidal":
return []ExtTrackMetadata{
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
}, nil
case "deezer":
return []ExtTrackMetadata{
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
}, nil
default:
return nil, nil
}
}
manager := GetExtensionManager()
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
if err != nil {
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
}
if len(tracks) != 3 {
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
}
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
t.Fatalf("unexpected track provider order: %+v", tracks)
}
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
t.Fatalf("unexpected provider call order: %v", calls)
}
}
+414
View File
@@ -0,0 +1,414 @@
package gobackend
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
const DefaultJSTimeout = 30 * time.Second
var (
extensionAuthState = make(map[string]*ExtensionAuthState)
extensionAuthStateMu sync.RWMutex
)
type ExtensionAuthState struct {
PendingAuthURL string
AuthCode string
AccessToken string
RefreshToken string
ExpiresAt time.Time
IsAuthenticated bool
PKCEVerifier string
PKCEChallenge string
}
type PendingAuthRequest struct {
ExtensionID string
AuthURL string
CallbackURL string
}
var (
pendingAuthRequests = make(map[string]*PendingAuthRequest)
pendingAuthRequestsMu sync.RWMutex
)
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
pendingAuthRequestsMu.RLock()
defer pendingAuthRequestsMu.RUnlock()
return pendingAuthRequests[extensionID]
}
func ClearPendingAuthRequest(extensionID string) {
pendingAuthRequestsMu.Lock()
defer pendingAuthRequestsMu.Unlock()
delete(pendingAuthRequests, extensionID)
}
func SetExtensionAuthCode(extensionID string, authCode string) {
extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock()
state, exists := extensionAuthState[extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[extensionID] = state
}
state.AuthCode = authCode
}
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock()
state, exists := extensionAuthState[extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[extensionID] = state
}
state.AccessToken = accessToken
state.RefreshToken = refreshToken
state.ExpiresAt = expiresAt
state.IsAuthenticated = accessToken != ""
}
type ExtensionRuntime struct {
extensionID string
manifest *ExtensionManifest
settings map[string]interface{}
httpClient *http.Client
downloadClient *http.Client
cookieJar http.CookieJar
dataDir string
vm *goja.Runtime
storageMu sync.RWMutex
storageCache map[string]interface{}
storageLoaded bool
storageDirty bool
storageClosed bool
storageTimer *time.Timer
storageWriteMu sync.Mutex
credentialsMu sync.RWMutex
credentialsCache map[string]interface{}
credentialsLoaded bool
storageFlushDelay time.Duration
}
type privateIPCacheEntry struct {
isPrivate bool
expiresAt time.Time
}
const (
privateIPCacheTTL = 5 * time.Minute
privateIPErrorCacheTTL = 30 * time.Second
maxPrivateIPCacheSize = 1024
)
var (
privateIPCache = make(map[string]privateIPCacheEntry)
privateIPCacheMu sync.RWMutex
)
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
jar, _ := newSimpleCookieJar()
runtime := &ExtensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
cookieJar: jar,
dataDir: ext.DataDir,
vm: ext.VM,
storageFlushDelay: defaultStorageFlushDelay,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
return runtime
}
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops.
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
client := &http.Client{
Transport: sharedTransport,
Timeout: timeout,
Jar: jar,
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
return fmt.Errorf("redirect blocked: only https is allowed")
}
domain := req.URL.Hostname()
if domain == "" {
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
return fmt.Errorf("redirect blocked: hostname is required")
}
if !ext.Manifest.IsDomainAllowed(domain) {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
}
if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
}
if len(via) >= 10 {
return http.ErrUseLastResponse
}
return nil
}
return client
}
type RedirectBlockedError struct {
Domain string
IsPrivate bool
}
func (e *RedirectBlockedError) Error() string {
if e.IsPrivate {
return "redirect blocked: private/local network access denied"
}
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
}
func isPrivateIP(host string) bool {
hostLower := strings.ToLower(strings.TrimSpace(host))
if hostLower == "" {
return false
}
if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
return true
}
if ip := net.ParseIP(hostLower); ip != nil {
return isPrivateIPAddr(ip)
}
if cached, ok := getPrivateIPCache(hostLower); ok {
return cached
}
ips, err := net.LookupIP(hostLower)
if err != nil {
setPrivateIPCache(hostLower, false, privateIPErrorCacheTTL)
return false
}
isPrivate := false
for _, ip := range ips {
if isPrivateIPAddr(ip) {
isPrivate = true
break
}
}
setPrivateIPCache(hostLower, isPrivate, privateIPCacheTTL)
return isPrivate
}
func getPrivateIPCache(host string) (bool, bool) {
now := time.Now()
privateIPCacheMu.RLock()
entry, exists := privateIPCache[host]
privateIPCacheMu.RUnlock()
if !exists {
return false, false
}
if now.Before(entry.expiresAt) {
return entry.isPrivate, true
}
privateIPCacheMu.Lock()
delete(privateIPCache, host)
privateIPCacheMu.Unlock()
return false, false
}
func setPrivateIPCache(host string, isPrivate bool, ttl time.Duration) {
expiresAt := time.Now().Add(ttl)
privateIPCacheMu.Lock()
if len(privateIPCache) >= maxPrivateIPCacheSize {
now := time.Now()
for key, entry := range privateIPCache {
if now.After(entry.expiresAt) {
delete(privateIPCache, key)
}
}
if len(privateIPCache) >= maxPrivateIPCacheSize {
privateIPCache = make(map[string]privateIPCacheEntry)
}
}
privateIPCache[host] = privateIPCacheEntry{
isPrivate: isPrivate,
expiresAt: expiresAt,
}
privateIPCacheMu.Unlock()
}
func isPrivateIPAddr(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified() {
return true
}
if !ip.IsGlobalUnicast() {
return true
}
return false
}
type simpleCookieJar struct {
cookies map[string][]*http.Cookie
mu sync.RWMutex
}
func newSimpleCookieJar() (*simpleCookieJar, error) {
return &simpleCookieJar{
cookies: make(map[string][]*http.Cookie),
}, nil
}
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
j.mu.Lock()
defer j.mu.Unlock()
key := u.Host
j.cookies[key] = append(j.cookies[key], cookies...)
}
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
j.mu.RLock()
defer j.mu.RUnlock()
return j.cookies[u.Host]
}
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings
}
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
r.vm = vm
httpObj := vm.NewObject()
httpObj.Set("get", r.httpGet)
httpObj.Set("post", r.httpPost)
httpObj.Set("put", r.httpPut)
httpObj.Set("delete", r.httpDelete)
httpObj.Set("patch", r.httpPatch)
httpObj.Set("request", r.httpRequest)
httpObj.Set("clearCookies", r.httpClearCookies)
vm.Set("http", httpObj)
storageObj := vm.NewObject()
storageObj.Set("get", r.storageGet)
storageObj.Set("set", r.storageSet)
storageObj.Set("remove", r.storageRemove)
vm.Set("storage", storageObj)
credentialsObj := vm.NewObject()
credentialsObj.Set("store", r.credentialsStore)
credentialsObj.Set("get", r.credentialsGet)
credentialsObj.Set("remove", r.credentialsRemove)
credentialsObj.Set("has", r.credentialsHas)
vm.Set("credentials", credentialsObj)
authObj := vm.NewObject()
authObj.Set("openAuthUrl", r.authOpenUrl)
authObj.Set("getAuthCode", r.authGetCode)
authObj.Set("setAuthCode", r.authSetCode)
authObj.Set("clearAuth", r.authClear)
authObj.Set("isAuthenticated", r.authIsAuthenticated)
authObj.Set("getTokens", r.authGetTokens)
authObj.Set("generatePKCE", r.authGeneratePKCE)
authObj.Set("getPKCE", r.authGetPKCE)
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
vm.Set("auth", authObj)
fileObj := vm.NewObject()
fileObj.Set("download", r.fileDownload)
fileObj.Set("exists", r.fileExists)
fileObj.Set("delete", r.fileDelete)
fileObj.Set("read", r.fileRead)
fileObj.Set("write", r.fileWrite)
fileObj.Set("copy", r.fileCopy)
fileObj.Set("move", r.fileMove)
fileObj.Set("getSize", r.fileGetSize)
vm.Set("file", fileObj)
ffmpegObj := vm.NewObject()
ffmpegObj.Set("execute", r.ffmpegExecute)
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
ffmpegObj.Set("convert", r.ffmpegConvert)
vm.Set("ffmpeg", ffmpegObj)
matchingObj := vm.NewObject()
matchingObj.Set("compareStrings", r.matchingCompareStrings)
matchingObj.Set("compareDuration", r.matchingCompareDuration)
matchingObj.Set("normalizeString", r.matchingNormalizeString)
vm.Set("matching", matchingObj)
utilsObj := vm.NewObject()
utilsObj.Set("base64Encode", r.base64Encode)
utilsObj.Set("base64Decode", r.base64Decode)
utilsObj.Set("md5", r.md5Hash)
utilsObj.Set("sha256", r.sha256Hash)
utilsObj.Set("hmacSHA256", r.hmacSHA256)
utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64)
utilsObj.Set("hmacSHA1", r.hmacSHA1)
utilsObj.Set("parseJSON", r.parseJSON)
utilsObj.Set("stringifyJSON", r.stringifyJSON)
utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
vm.Set("utils", utilsObj)
logObj := vm.NewObject()
logObj.Set("debug", r.logDebug)
logObj.Set("info", r.logInfo)
logObj.Set("warn", r.logWarn)
logObj.Set("error", r.logError)
vm.Set("log", logObj)
gobackendObj := vm.NewObject()
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
vm.Set("gobackend", gobackendObj)
vm.Set("fetch", r.fetchPolyfill)
vm.Set("atob", r.atobPolyfill)
vm.Set("btoa", r.btoaPolyfill)
r.registerTextEncoderDecoder(vm)
r.registerURLClass(vm)
r.registerJSONGlobal(vm)
}
+552
View File
@@ -0,0 +1,552 @@
package gobackend
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/dop251/goja"
)
func validateExtensionAuthURL(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid auth URL: %w", err)
}
if parsed.Scheme != "https" {
return fmt.Errorf("invalid auth URL: only https is allowed")
}
host := parsed.Hostname()
if host == "" {
return fmt.Errorf("invalid auth URL: hostname is required")
}
if parsed.User != nil {
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
}
if isPrivateIP(host) {
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
}
return nil
}
func summarizeURLForLog(urlStr string) string {
parsed, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
if parsed.Host == "" {
return parsed.Scheme + "://"
}
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
}
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "auth URL is required",
})
}
authURL := call.Arguments[0].String()
callbackURL := ""
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
callbackURL = call.Arguments[1].String()
}
if err := validateExtensionAuthURL(authURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: authURL,
CallbackURL: callbackURL,
}
pendingAuthRequestsMu.Unlock()
extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[r.extensionID] = state
}
state.PendingAuthURL = authURL
state.AuthCode = ""
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
return r.vm.ToValue(map[string]interface{}{
"success": true,
"message": "Auth URL will be opened by the app",
})
}
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
state, exists := extensionAuthState[r.extensionID]
if !exists || state.AuthCode == "" {
return goja.Undefined()
}
return r.vm.ToValue(state.AuthCode)
}
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
arg := call.Arguments[0].Export()
extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[r.extensionID] = state
}
switch v := arg.(type) {
case string:
state.AuthCode = v
case map[string]interface{}:
if code, ok := v["code"].(string); ok {
state.AuthCode = code
}
if accessToken, ok := v["access_token"].(string); ok {
state.AccessToken = accessToken
state.IsAuthenticated = true
}
if refreshToken, ok := v["refresh_token"].(string); ok {
state.RefreshToken = refreshToken
}
if expiresIn, ok := v["expires_in"].(float64); ok {
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
}
}
return r.vm.ToValue(true)
}
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.Lock()
delete(extensionAuthState, r.extensionID)
extensionAuthStateMu.Unlock()
pendingAuthRequestsMu.Lock()
delete(pendingAuthRequests, r.extensionID)
pendingAuthRequestsMu.Unlock()
GoLog("[Extension:%s] Auth state cleared\n", r.extensionID)
return r.vm.ToValue(true)
}
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
return r.vm.ToValue(false)
}
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
return r.vm.ToValue(false)
}
return r.vm.ToValue(state.IsAuthenticated)
}
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
return r.vm.ToValue(map[string]interface{}{})
}
result := map[string]interface{}{
"access_token": state.AccessToken,
"refresh_token": state.RefreshToken,
"is_authenticated": state.IsAuthenticated,
}
if !state.ExpiresAt.IsZero() {
result["expires_at"] = state.ExpiresAt.Unix()
result["is_expired"] = time.Now().After(state.ExpiresAt)
}
return r.vm.ToValue(result)
}
// Length should be between 43-128 characters (RFC 7636)
func generatePKCEVerifier(length int) (string, error) {
if length < 43 {
length = 43
}
if length > 128 {
length = 128
}
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
verifier := base64.RawURLEncoding.EncodeToString(bytes)
if len(verifier) > length {
verifier = verifier[:length]
}
return verifier, nil
}
func generatePKCEChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
// Base64url encode without padding (RFC 7636)
return base64.RawURLEncoding.EncodeToString(hash[:])
}
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
length := 64
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
length = int(l)
}
}
verifier, err := generatePKCEVerifier(length)
if err != nil {
GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
challenge := generatePKCEChallenge(verifier)
extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[r.extensionID] = state
}
state.PKCEVerifier = verifier
state.PKCEChallenge = challenge
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier))
return r.vm.ToValue(map[string]interface{}{
"verifier": verifier,
"challenge": challenge,
"method": "S256",
})
}
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
state, exists := extensionAuthState[r.extensionID]
if !exists || state.PKCEVerifier == "" {
return r.vm.ToValue(map[string]interface{}{})
}
return r.vm.ToValue(map[string]interface{}{
"verifier": state.PKCEVerifier,
"challenge": state.PKCEChallenge,
"method": "S256",
})
}
// config: { authUrl, clientId, redirectUri, scope, extraParams }
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "config object is required",
})
}
configObj := call.Arguments[0].Export()
config, ok := configObj.(map[string]interface{})
if !ok {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "config must be an object",
})
}
authURL, _ := config["authUrl"].(string)
clientID, _ := config["clientId"].(string)
redirectURI, _ := config["redirectUri"].(string)
if authURL == "" || clientID == "" || redirectURI == "" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "authUrl, clientId, and redirectUri are required",
})
}
if err := validateExtensionAuthURL(authURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
scope, _ := config["scope"].(string)
extraParams, _ := config["extraParams"].(map[string]interface{})
verifier, err := generatePKCEVerifier(64)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to generate PKCE: %v", err),
})
}
challenge := generatePKCEChallenge(verifier)
extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[r.extensionID] = state
}
state.PKCEVerifier = verifier
state.PKCEChallenge = challenge
state.AuthCode = ""
extensionAuthStateMu.Unlock()
parsedURL, err := url.Parse(authURL)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("invalid authUrl: %v", err),
})
}
query := parsedURL.Query()
query.Set("client_id", clientID)
query.Set("redirect_uri", redirectURI)
query.Set("response_type", "code")
query.Set("code_challenge", challenge)
query.Set("code_challenge_method", "S256")
if scope != "" {
query.Set("scope", scope)
}
for k, v := range extraParams {
query.Set(k, fmt.Sprintf("%v", v))
}
parsedURL.RawQuery = query.Encode()
fullAuthURL := parsedURL.String()
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: fullAuthURL,
CallbackURL: redirectURI,
}
pendingAuthRequestsMu.Unlock()
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
return r.vm.ToValue(map[string]interface{}{
"success": true,
"authUrl": fullAuthURL,
"pkce": map[string]interface{}{
"verifier": verifier,
"challenge": challenge,
"method": "S256",
},
})
}
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "config object is required",
})
}
configObj := call.Arguments[0].Export()
config, ok := configObj.(map[string]interface{})
if !ok {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "config must be an object",
})
}
tokenURL, _ := config["tokenUrl"].(string)
clientID, _ := config["clientId"].(string)
redirectURI, _ := config["redirectUri"].(string)
code, _ := config["code"].(string)
if tokenURL == "" || clientID == "" || code == "" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "tokenUrl, clientId, and code are required",
})
}
extensionAuthStateMu.RLock()
state, exists := extensionAuthState[r.extensionID]
var verifier string
if exists {
verifier = state.PKCEVerifier
}
extensionAuthStateMu.RUnlock()
if verifier == "" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first",
})
}
if err := r.validateDomain(tokenURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("client_id", clientID)
formData.Set("code", code)
formData.Set("code_verifier", verifier)
if redirectURI != "" {
formData.Set("redirect_uri", redirectURI)
}
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
for k, v := range extraParams {
formData.Set(k, fmt.Sprintf("%v", v))
}
}
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
bodyPreview := sanitizeSensitiveLogText(string(body))
if len(bodyPreview) > 1000 {
bodyPreview = bodyPreview[:1000] + "...[truncated]"
}
var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to parse token response: %v", err),
"body": bodyPreview,
})
}
if errMsg, ok := tokenResp["error"].(string); ok {
errDesc, _ := tokenResp["error_description"].(string)
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": errMsg,
"error_description": errDesc,
})
}
accessToken, _ := tokenResp["access_token"].(string)
refreshToken, _ := tokenResp["refresh_token"].(string)
expiresIn, _ := tokenResp["expires_in"].(float64)
if accessToken == "" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "no access_token in response",
"body": bodyPreview,
})
}
extensionAuthStateMu.Lock()
state, exists = extensionAuthState[r.extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[r.extensionID] = state
}
state.AccessToken = accessToken
state.RefreshToken = refreshToken
state.IsAuthenticated = true
if expiresIn > 0 {
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
}
state.PKCEVerifier = ""
state.PKCEChallenge = ""
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
result := map[string]interface{}{
"success": true,
"access_token": accessToken,
"refresh_token": refreshToken,
"token_type": tokenResp["token_type"],
}
if expiresIn > 0 {
result["expires_in"] = expiresIn
}
if scope, ok := tokenResp["scope"].(string); ok {
result["scope"] = scope
}
return r.vm.ToValue(result)
}
+182
View File
@@ -0,0 +1,182 @@
package gobackend
import (
"fmt"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute.
type FFmpegCommand struct {
ExtensionID string
Command string
InputPath string
OutputPath string
Completed bool
Success bool
Error string
Output string
}
var (
ffmpegCommands = make(map[string]*FFmpegCommand)
ffmpegCommandsMu sync.RWMutex
ffmpegCommandID int64
)
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
ffmpegCommandsMu.RLock()
defer ffmpegCommandsMu.RUnlock()
return ffmpegCommands[commandID]
}
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock()
if cmd, exists := ffmpegCommands[commandID]; exists {
cmd.Completed = true
cmd.Success = success
cmd.Output = output
cmd.Error = errorMsg
}
}
func ClearFFmpegCommand(commandID string) {
ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock()
delete(ffmpegCommands, commandID)
}
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "command is required",
})
}
command := call.Arguments[0].String()
ffmpegCommandsMu.Lock()
ffmpegCommandID++
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
ffmpegCommands[cmdID] = &FFmpegCommand{
ExtensionID: r.extensionID,
Command: command,
Completed: false,
}
ffmpegCommandsMu.Unlock()
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
timeout := 5 * time.Minute
start := time.Now()
for {
ffmpegCommandsMu.RLock()
cmd := ffmpegCommands[cmdID]
completed := cmd != nil && cmd.Completed
ffmpegCommandsMu.RUnlock()
if completed {
ffmpegCommandsMu.RLock()
result := map[string]interface{}{
"success": cmd.Success,
"output": cmd.Output,
}
if cmd.Error != "" {
result["error"] = cmd.Error
}
ffmpegCommandsMu.RUnlock()
ClearFFmpegCommand(cmdID)
return r.vm.ToValue(result)
}
if time.Since(start) > timeout {
ClearFFmpegCommand(cmdID)
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "FFmpeg command timed out",
})
}
time.Sleep(100 * time.Millisecond)
}
}
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "file path is required",
})
}
filePath := call.Arguments[0].String()
quality, err := GetAudioQuality(filePath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"bit_depth": quality.BitDepth,
"sample_rate": quality.SampleRate,
"total_samples": quality.TotalSamples,
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
})
}
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "input and output paths are required",
})
}
inputPath := call.Arguments[0].String()
outputPath := call.Arguments[1].String()
options := map[string]interface{}{}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
options = opts
}
}
var cmdParts []string
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
if codec, ok := options["codec"].(string); ok {
cmdParts = append(cmdParts, "-c:a", codec)
}
if bitrate, ok := options["bitrate"].(string); ok {
cmdParts = append(cmdParts, "-b:a", bitrate)
}
if sampleRate, ok := options["sample_rate"].(float64); ok {
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
}
if channels, ok := options["channels"].(float64); ok {
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
}
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
command := strings.Join(cmdParts, " ")
execCall := goja.FunctionCall{
Arguments: []goja.Value{r.vm.ToValue(command)},
}
return r.ffmpegExecute(execCall)
}
+524
View File
@@ -0,0 +1,524 @@
package gobackend
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/dop251/goja"
)
var (
allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex
)
func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
allowedDownloadDirs = dirs
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
}
func AddAllowedDownloadDir(dir string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
absDir, err := filepath.Abs(dir)
if err == nil {
allowedDownloadDirs = append(allowedDownloadDirs, absDir)
}
}
func isPathInAllowedDirs(absPath string) bool {
allowedDownloadDirsMu.RLock()
defer allowedDownloadDirsMu.RUnlock()
for _, allowedDir := range allowedDownloadDirs {
if isPathWithinBase(allowedDir, absPath) {
return true
}
}
return false
}
func isPathWithinBase(baseDir, targetPath string) bool {
baseAbs, err := filepath.Abs(baseDir)
if err != nil {
return false
}
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return false
}
rel, err := filepath.Rel(baseAbs, targetAbs)
if err != nil {
return false
}
rel = filepath.Clean(rel)
if rel == "." {
return true
}
prefix := ".." + string(filepath.Separator)
if rel == ".." || strings.HasPrefix(rel, prefix) {
return false
}
return true
}
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
if !r.manifest.Permissions.File {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
}
cleanPath := filepath.Clean(path)
if filepath.IsAbs(cleanPath) {
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
if isPathInAllowedDirs(absPath) {
return absPath, nil
}
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
}
fullPath := filepath.Join(r.dataDir, cleanPath)
absPath, err := filepath.Abs(fullPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
absDataDir, _ := filepath.Abs(r.dataDir)
if !isPathWithinBase(absDataDir, absPath) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
}
return absPath, nil
}
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "URL and output path are required",
})
}
urlStr := call.Arguments[0].String()
outputPath := call.Arguments[1].String()
if err := r.validateDomain(urlStr); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
fullPath, err := r.validatePath(outputPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
var onProgress goja.Callable
var headers map[string]string
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 {
if h, ok := opts["headers"].(map[string]interface{}); ok {
headers = make(map[string]string)
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
if progressVal, ok := opts["onProgress"]; ok {
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
onProgress = callable
}
}
}
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
for k, v := range headers {
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
}
resp, err := client.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
})
}
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()
contentLength := resp.ContentLength
var written int64
buf := make([]byte, 32*1024)
for {
nr, er := resp.Body.Read(buf)
if nr > 0 {
nw, ew := out.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
ew = fmt.Errorf("invalid write result")
}
}
written += int64(nw)
if ew != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to write file: %v", ew),
})
}
if nr != nw {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "short write",
})
}
if onProgress != nil && contentLength > 0 {
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
}
}
if er != nil {
if er != io.EOF {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read response: %v", er),
})
}
break
}
}
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullPath,
"size": written,
})
}
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(false)
}
_, err = os.Stat(fullPath)
return r.vm.ToValue(err == nil)
}
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
if err := os.Remove(fullPath); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
})
}
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
data, err := os.ReadFile(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": string(data),
})
}
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path and data are required",
})
}
path := call.Arguments[0].String()
data := call.Arguments[1].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullPath,
})
}
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "source and destination paths are required",
})
}
srcPath := call.Arguments[0].String()
dstPath := call.Arguments[1].String()
fullSrc, err := r.validatePath(srcPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
fullDst, err := r.validatePath(dstPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
srcFile, err := os.Open(fullSrc)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read source: %v", err),
})
}
defer srcFile.Close()
dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
dstFile, err := os.OpenFile(fullDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to open destination: %v", err),
})
}
if _, err := io.Copy(dstFile, srcFile); err != nil {
_ = dstFile.Close()
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to copy file: %v", err),
})
}
if err := dstFile.Close(); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to finalize destination: %v", err),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullDst,
})
}
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "source and destination paths are required",
})
}
srcPath := call.Arguments[0].String()
dstPath := call.Arguments[1].String()
fullSrc, err := r.validatePath(srcPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
fullDst, err := r.validatePath(dstPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
if err := os.Rename(fullSrc, fullDst); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to move file: %v", err),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullDst,
})
}
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
info, err := os.Stat(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"size": info.Size(),
})
}
+463
View File
@@ -0,0 +1,463 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/dop251/goja"
)
type HTTPResponse struct {
StatusCode int `json:"statusCode"`
Body string `json:"body"`
Headers map[string]string `json:"headers"`
}
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if parsed.Scheme == "" {
return fmt.Errorf("invalid URL: scheme is required")
}
if parsed.Scheme != "https" {
return fmt.Errorf("network access denied: only https is allowed")
}
if parsed.User != nil {
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
}
domain := parsed.Hostname()
if domain == "" {
return fmt.Errorf("invalid URL: hostname is required")
}
if isPrivateIP(domain) {
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
}
if !r.manifest.IsDomainAllowed(domain) {
return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain)
}
return nil
}
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
headers := make(map[string]string)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
}
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
var bodyStr string
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export()
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
bodyStr = string(jsonBytes)
default:
bodyStr = call.Arguments[1].String()
}
}
headers := make(map[string]string)
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
headersObj := call.Arguments[2].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
if req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
}
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
method := "GET"
var bodyStr string
headers := make(map[string]string)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m)
}
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
bodyStr = string(jsonBytes)
default:
bodyStr = fmt.Sprintf("%v", v)
}
}
if h, ok := opts["headers"].(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
}
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
req, err := http.NewRequest(method, urlStr, reqBody)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
}
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call)
}
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call)
}
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call)
}
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
var bodyStr string
headers := make(map[string]string)
if method == "DELETE" {
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
} else {
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export()
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
bodyStr = string(jsonBytes)
default:
bodyStr = call.Arguments[1].String()
}
}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
headersObj := call.Arguments[2].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
}
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
req, err := http.NewRequest(method, urlStr, reqBody)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
}
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock()
jar.cookies = make(map[string][]*http.Cookie)
jar.mu.Unlock()
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
return r.vm.ToValue(true)
}
return r.vm.ToValue(false)
}
+133
View File
@@ -0,0 +1,133 @@
package gobackend
import (
"strings"
"github.com/dop251/goja"
)
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(0.0)
}
str1 := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
str2 := strings.ToLower(strings.TrimSpace(call.Arguments[1].String()))
if str1 == str2 {
return r.vm.ToValue(1.0)
}
similarity := calculateStringSimilarity(str1, str2)
return r.vm.ToValue(similarity)
}
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(false)
}
dur1 := int(call.Arguments[0].ToInteger())
dur2 := int(call.Arguments[1].ToInteger())
tolerance := 3000
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
tolerance = int(call.Arguments[2].ToInteger())
}
diff := dur1 - dur2
if diff < 0 {
diff = -diff
}
return r.vm.ToValue(diff <= tolerance)
}
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
str := call.Arguments[0].String()
normalized := normalizeStringForMatching(str)
return r.vm.ToValue(normalized)
}
func calculateStringSimilarity(s1, s2 string) float64 {
if len(s1) == 0 && len(s2) == 0 {
return 1.0
}
if len(s1) == 0 || len(s2) == 0 {
return 0.0
}
distance := levenshteinDistance(s1, s2)
maxLen := len(s1)
if len(s2) > maxLen {
maxLen = len(s2)
}
return 1.0 - float64(distance)/float64(maxLen)
}
func levenshteinDistance(s1, s2 string) int {
if len(s1) == 0 {
return len(s2)
}
if len(s2) == 0 {
return len(s1)
}
matrix := make([][]int, len(s1)+1)
for i := range matrix {
matrix[i] = make([]int, len(s2)+1)
matrix[i][0] = i
}
for j := range matrix[0] {
matrix[0][j] = j
}
for i := 1; i <= len(s1); i++ {
for j := 1; j <= len(s2); j++ {
cost := 1
if s1[i-1] == s2[j-1] {
cost = 0
}
matrix[i][j] = min(
matrix[i-1][j]+1,
matrix[i][j-1]+1,
matrix[i-1][j-1]+cost,
)
}
}
return matrix[len(s1)][len(s2)]
}
func normalizeStringForMatching(s string) string {
s = strings.ToLower(s)
suffixes := []string{
" (remastered)", " (remaster)", " - remastered", " - remaster",
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
" (explicit)", " (clean)", " [explicit]", " [clean]",
" (album version)", " (single version)", " (radio edit)",
" (feat.", " (ft.", " feat.", " ft.",
}
for _, suffix := range suffixes {
if idx := strings.Index(s, suffix); idx != -1 {
s = s[:idx]
}
}
var result strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
result.WriteRune(r)
}
}
s = strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(s)
}
+440
View File
@@ -0,0 +1,440 @@
package gobackend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/dop251/goja"
)
// These polyfills make porting browser/Node.js libraries easier
// without compromising sandbox security.
// Returns a Promise-like object with json(), text() methods.
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.createFetchError("URL is required")
}
urlStr := call.Arguments[0].String()
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
return r.createFetchError(err.Error())
}
method := "GET"
var bodyStr string
headers := make(map[string]string)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m)
}
// Body - support string, object (auto-stringify), or nil
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err))
}
bodyStr = string(jsonBytes)
default:
bodyStr = fmt.Sprintf("%v", v)
}
}
if h, ok := opts["headers"]; ok && h != nil {
switch hv := h.(type) {
case map[string]interface{}:
for k, v := range hv {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
}
}
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
req, err := http.NewRequest(method, urlStr, reqBody)
if err != nil {
return r.createFetchError(err.Error())
}
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return r.createFetchError(err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.createFetchError(err.Error())
}
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
responseObj := r.vm.NewObject()
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
responseObj.Set("status", resp.StatusCode)
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
responseObj.Set("headers", respHeaders)
responseObj.Set("url", urlStr)
bodyString := string(body)
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(bodyString)
})
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
var result interface{}
if err := json.Unmarshal(body, &result); err != nil {
GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err)
return goja.Undefined()
}
return r.vm.ToValue(result)
})
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
byteArray := make([]interface{}, len(body))
for i, b := range body {
byteArray[i] = int(b)
}
return r.vm.ToValue(byteArray)
})
return responseObj
}
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
errorObj := r.vm.NewObject()
errorObj.Set("ok", false)
errorObj.Set("status", 0)
errorObj.Set("statusText", "Network Error")
errorObj.Set("error", message)
errorObj.Set("text", func(call goja.FunctionCall) goja.Value {
return r.vm.ToValue("")
})
errorObj.Set("json", func(call goja.FunctionCall) goja.Value {
return goja.Undefined()
})
return errorObj
}
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
input := call.Arguments[0].String()
decoded, err := base64.StdEncoding.DecodeString(input)
if err != nil {
decoded, err = base64.URLEncoding.DecodeString(input)
if err != nil {
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
return r.vm.ToValue("")
}
}
return r.vm.ToValue(string(decoded))
}
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
input := call.Arguments[0].String()
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
}
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
encoder := call.This
encoder.Set("encoding", "utf-8")
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue([]byte{})
}
input := call.Arguments[0].String()
bytes := []byte(input)
result := make([]interface{}, len(bytes))
for i, b := range bytes {
result[i] = int(b)
}
return vm.ToValue(result)
})
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
// Simplified implementation
if len(call.Arguments) < 2 {
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
}
input := call.Arguments[0].String()
return vm.ToValue(map[string]interface{}{
"read": len(input),
"written": len([]byte(input)),
})
})
return nil
})
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
decoder := call.This
encoding := "utf-8"
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
encoding = call.Arguments[0].String()
}
decoder.Set("encoding", encoding)
decoder.Set("fatal", false)
decoder.Set("ignoreBOM", false)
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue("")
}
input := call.Arguments[0].Export()
var bytes []byte
switch v := input.(type) {
case []byte:
bytes = v
case []interface{}:
bytes = make([]byte, len(v))
for i, val := range v {
switch n := val.(type) {
case int64:
bytes[i] = byte(n)
case float64:
bytes[i] = byte(n)
case int:
bytes[i] = byte(n)
}
}
case string:
return vm.ToValue(v)
default:
return vm.ToValue("")
}
return vm.ToValue(string(bytes))
})
return nil
})
}
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
urlObj := call.This
if len(call.Arguments) < 1 {
urlObj.Set("href", "")
return nil
}
urlStr := call.Arguments[0].String()
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
baseStr := call.Arguments[1].String()
baseURL, err := url.Parse(baseStr)
if err == nil {
relURL, err := url.Parse(urlStr)
if err == nil {
urlStr = baseURL.ResolveReference(relURL).String()
}
}
}
parsed, err := url.Parse(urlStr)
if err != nil {
urlObj.Set("href", urlStr)
return nil
}
urlObj.Set("href", parsed.String())
urlObj.Set("protocol", parsed.Scheme+":")
urlObj.Set("host", parsed.Host)
urlObj.Set("hostname", parsed.Hostname())
urlObj.Set("port", parsed.Port())
urlObj.Set("pathname", parsed.Path)
urlObj.Set("search", "")
if parsed.RawQuery != "" {
urlObj.Set("search", "?"+parsed.RawQuery)
}
urlObj.Set("hash", "")
if parsed.Fragment != "" {
urlObj.Set("hash", "#"+parsed.Fragment)
}
urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host)
urlObj.Set("username", parsed.User.Username())
password, _ := parsed.User.Password()
urlObj.Set("password", password)
queryValues := parsed.Query()
searchParams := vm.NewObject()
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Null()
}
key := call.Arguments[0].String()
if val := queryValues.Get(key); val != "" {
return vm.ToValue(val)
}
return goja.Null()
})
searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue([]string{})
}
key := call.Arguments[0].String()
return vm.ToValue(queryValues[key])
})
searchParams.Set("has", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue(false)
}
key := call.Arguments[0].String()
return vm.ToValue(queryValues.Has(key))
})
searchParams.Set("toString", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(queryValues.Encode())
})
urlObj.Set("searchParams", searchParams)
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(parsed.String())
})
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(parsed.String())
})
return nil
})
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
paramsObj := call.This
values := url.Values{}
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
init := call.Arguments[0].Export()
switch v := init.(type) {
case string:
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
values = parsed
case map[string]interface{}:
for k, val := range v {
values.Set(k, fmt.Sprintf("%v", val))
}
}
}
paramsObj.Set("append", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) >= 2 {
values.Add(call.Arguments[0].String(), call.Arguments[1].String())
}
return goja.Undefined()
})
paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) >= 1 {
values.Del(call.Arguments[0].String())
}
return goja.Undefined()
})
paramsObj.Set("get", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Null()
}
if val := values.Get(call.Arguments[0].String()); val != "" {
return vm.ToValue(val)
}
return goja.Null()
})
paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue([]string{})
}
return vm.ToValue(values[call.Arguments[0].String()])
})
paramsObj.Set("has", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue(false)
}
return vm.ToValue(values.Has(call.Arguments[0].String()))
})
paramsObj.Set("set", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) >= 2 {
values.Set(call.Arguments[0].String(), call.Arguments[1].String())
}
return goja.Undefined()
})
paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(values.Encode())
})
return nil
})
}
// JSON is already built-in to Goja; this ensures a fallback exists.
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
jsonScript := `
if (typeof JSON === 'undefined') {
var JSON = {
parse: function(text) {
return utils.parseJSON(text);
},
stringify: function(value, replacer, space) {
return utils.stringifyJSON(value);
}
};
}
`
_, _ = vm.RunString(jsonScript)
}
+527
View File
@@ -0,0 +1,527 @@
package gobackend
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"time"
"github.com/dop251/goja"
)
const (
defaultStorageFlushDelay = 400 * time.Millisecond
storageFlushRetryDelay = 2 * time.Second
)
func (r *ExtensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json")
}
func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
if len(src) == 0 {
return make(map[string]interface{})
}
dst := make(map[string]interface{}, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func (r *ExtensionRuntime) ensureStorageLoaded() error {
r.storageMu.RLock()
if r.storageLoaded {
r.storageMu.RUnlock()
return nil
}
r.storageMu.RUnlock()
r.storageMu.Lock()
defer r.storageMu.Unlock()
if r.storageLoaded {
return nil
}
storagePath := r.getStoragePath()
data, err := os.ReadFile(storagePath)
if err != nil {
if os.IsNotExist(err) {
r.storageCache = make(map[string]interface{})
r.storageLoaded = true
return nil
}
return err
}
var storage map[string]interface{}
if err := json.Unmarshal(data, &storage); err != nil {
return err
}
if storage == nil {
storage = make(map[string]interface{})
}
r.storageCache = storage
r.storageLoaded = true
return nil
}
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
if err := r.ensureStorageLoaded(); err != nil {
return nil, err
}
r.storageMu.RLock()
defer r.storageMu.RUnlock()
return cloneInterfaceMap(r.storageCache), nil
}
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
if r.storageClosed {
return
}
if r.storageTimer != nil {
return
}
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
}
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
data, err := json.Marshal(storage)
if err != nil {
return err
}
r.storageWriteMu.Lock()
defer r.storageWriteMu.Unlock()
return os.WriteFile(r.getStoragePath(), data, 0600)
}
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
if err := r.flushStorageDirty(); err != nil {
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
}
}
func (r *ExtensionRuntime) flushStorageDirty() error {
r.storageMu.Lock()
if r.storageClosed {
r.storageTimer = nil
r.storageMu.Unlock()
return nil
}
if !r.storageDirty {
r.storageTimer = nil
r.storageMu.Unlock()
return nil
}
snapshot := cloneInterfaceMap(r.storageCache)
r.storageDirty = false
r.storageTimer = nil
r.storageMu.Unlock()
if err := r.persistStorageSnapshot(snapshot); err != nil {
r.storageMu.Lock()
r.storageDirty = true
r.queueStorageFlushLocked(storageFlushRetryDelay)
r.storageMu.Unlock()
return err
}
return nil
}
func (r *ExtensionRuntime) flushStorageNow() error {
r.storageMu.Lock()
if r.storageTimer != nil {
r.storageTimer.Stop()
r.storageTimer = nil
}
if !r.storageLoaded || r.storageClosed {
r.storageMu.Unlock()
return nil
}
snapshot := cloneInterfaceMap(r.storageCache)
r.storageDirty = false
r.storageMu.Unlock()
return r.persistStorageSnapshot(snapshot)
}
func (r *ExtensionRuntime) closeStorageFlusher() {
r.storageMu.Lock()
r.storageClosed = true
r.storageDirty = false
if r.storageTimer != nil {
r.storageTimer.Stop()
r.storageTimer = nil
}
r.storageMu.Unlock()
}
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
}
key := call.Arguments[0].String()
if err := r.ensureStorageLoaded(); err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
return goja.Undefined()
}
r.storageMu.RLock()
value, exists := r.storageCache[key]
r.storageMu.RUnlock()
if !exists {
if len(call.Arguments) > 1 {
return call.Arguments[1]
}
return goja.Undefined()
}
return r.vm.ToValue(value)
}
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(false)
}
key := call.Arguments[0].String()
value := call.Arguments[1].Export()
if err := r.ensureStorageLoaded(); err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
r.storageMu.Lock()
if r.storageClosed {
r.storageMu.Unlock()
return r.vm.ToValue(false)
}
if existing, exists := r.storageCache[key]; exists {
if reflect.DeepEqual(existing, value) {
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
}
r.storageCache[key] = value
r.storageDirty = true
r.queueStorageFlushLocked(r.storageFlushDelay)
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
key := call.Arguments[0].String()
if err := r.ensureStorageLoaded(); err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
r.storageMu.Lock()
if r.storageClosed {
r.storageMu.Unlock()
return r.vm.ToValue(false)
}
if _, exists := r.storageCache[key]; !exists {
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
delete(r.storageCache, key)
r.storageDirty = true
r.queueStorageFlushLocked(r.storageFlushDelay)
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
func (r *ExtensionRuntime) getCredentialsPath() string {
return filepath.Join(r.dataDir, ".credentials.enc")
}
func (r *ExtensionRuntime) getSaltPath() string {
return filepath.Join(r.dataDir, ".cred_salt")
}
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath()
salt, err := os.ReadFile(saltPath)
if err == nil && len(salt) == 32 {
return salt, nil
}
salt = make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
return nil, fmt.Errorf("failed to save salt: %w", err)
}
return salt, nil
}
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
salt, err := r.getOrCreateSalt()
if err != nil {
return nil, err
}
combined := append([]byte(r.extensionID), salt...)
hash := sha256.Sum256(combined)
return hash[:], nil
}
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
r.credentialsMu.RLock()
if r.credentialsLoaded {
r.credentialsMu.RUnlock()
return nil
}
r.credentialsMu.RUnlock()
r.credentialsMu.Lock()
defer r.credentialsMu.Unlock()
if r.credentialsLoaded {
return nil
}
credPath := r.getCredentialsPath()
data, err := os.ReadFile(credPath)
if err != nil {
if os.IsNotExist(err) {
r.credentialsCache = make(map[string]interface{})
r.credentialsLoaded = true
return nil
}
return err
}
key, err := r.getEncryptionKey()
if err != nil {
return fmt.Errorf("failed to get encryption key: %w", err)
}
decrypted, err := decryptAES(data, key)
if err != nil {
return fmt.Errorf("failed to decrypt credentials: %w", err)
}
var creds map[string]interface{}
if err := json.Unmarshal(decrypted, &creds); err != nil {
return err
}
if creds == nil {
creds = make(map[string]interface{})
}
r.credentialsCache = creds
r.credentialsLoaded = true
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 {
return err
}
key, err := r.getEncryptionKey()
if err != nil {
return fmt.Errorf("failed to get encryption key: %w", err)
}
encrypted, err := encryptAES(data, key)
if err != nil {
return fmt.Errorf("failed to encrypt credentials: %w", err)
}
credPath := r.getCredentialsPath()
if err := os.WriteFile(credPath, encrypted, 0600); err != nil {
return err
}
r.credentialsMu.Lock()
r.credentialsCache = cloneInterfaceMap(creds)
r.credentialsLoaded = true
r.credentialsMu.Unlock()
return nil
}
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "key and value are required",
})
}
key := call.Arguments[0].String()
value := call.Arguments[1].Export()
if err := r.ensureCredentialsLoaded(); err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
r.credentialsMu.RLock()
nextCreds := cloneInterfaceMap(r.credentialsCache)
r.credentialsMu.RUnlock()
nextCreds[key] = value
if err := r.saveCredentials(nextCreds); err != nil {
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
})
}
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
}
key := call.Arguments[0].String()
if err := r.ensureCredentialsLoaded(); err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return goja.Undefined()
}
r.credentialsMu.RLock()
value, exists := r.credentialsCache[key]
r.credentialsMu.RUnlock()
if !exists {
if len(call.Arguments) > 1 {
return call.Arguments[1]
}
return goja.Undefined()
}
return r.vm.ToValue(value)
}
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
key := call.Arguments[0].String()
if err := r.ensureCredentialsLoaded(); err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
r.credentialsMu.RLock()
nextCreds := cloneInterfaceMap(r.credentialsCache)
r.credentialsMu.RUnlock()
delete(nextCreds, key)
if err := r.saveCredentials(nextCreds); err != nil {
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
return r.vm.ToValue(true)
}
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
key := call.Arguments[0].String()
if err := r.ensureCredentialsLoaded(); err != nil {
return r.vm.ToValue(false)
}
r.credentialsMu.RLock()
_, exists := r.credentialsCache[key]
r.credentialsMu.RUnlock()
return r.vm.ToValue(exists)
}
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
@@ -0,0 +1,120 @@
package gobackend
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/dop251/goja"
)
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) {
t.Helper()
result := runtime.storageSet(goja.FunctionCall{
Arguments: []goja.Value{
runtime.vm.ToValue(key),
runtime.vm.ToValue(value),
},
})
if !result.ToBoolean() {
t.Fatalf("storage.set(%q) returned false", key)
}
}
func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
t.Helper()
data, err := os.ReadFile(storagePath)
if err != nil {
t.Fatalf("failed to read storage file: %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal storage file: %v", err)
}
return parsed
}
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
ext := &LoadedExtension{
ID: "storage-test",
Manifest: &ExtensionManifest{
Name: "storage-test",
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
runtime.storageFlushDelay = 25 * time.Millisecond
runtime.RegisterAPIs(goja.New())
setStorageValue(t, runtime, "k1", "v1")
setStorageValue(t, runtime, "k2", 2)
storagePath := filepath.Join(ext.DataDir, "storage.json")
deadline := time.Now().Add(1500 * time.Millisecond)
var raw []byte
for time.Now().Before(deadline) {
data, err := os.ReadFile(storagePath)
if err == nil {
raw = data
break
}
time.Sleep(20 * time.Millisecond)
}
if len(raw) == 0 {
t.Fatalf("storage.json was not written within timeout")
}
var parsed map[string]interface{}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("failed to unmarshal storage file: %v", err)
}
if parsed["k1"] != "v1" {
t.Fatalf("expected k1=v1, got %v", parsed["k1"])
}
if parsed["k2"] != float64(2) {
t.Fatalf("expected k2=2, got %v", parsed["k2"])
}
if bytes.Contains(raw, []byte("\n")) {
t.Fatalf("expected compact JSON without indentation, got: %q", string(raw))
}
}
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
ext := &LoadedExtension{
ID: "unload-storage-test",
Manifest: &ExtensionManifest{
Name: "unload-storage-test",
},
DataDir: t.TempDir(),
VM: goja.New(),
}
runtime := NewExtensionRuntime(ext)
runtime.storageFlushDelay = time.Hour
runtime.RegisterAPIs(ext.VM)
ext.runtime = runtime
manager := &ExtensionManager{
extensions: map[string]*LoadedExtension{
ext.ID: ext,
},
}
setStorageValue(t, runtime, "persist_on_unload", true)
if err := manager.UnloadExtension(ext.ID); err != nil {
t.Fatalf("UnloadExtension failed: %v", err)
}
storagePath := filepath.Join(ext.DataDir, "storage.json")
parsed := readStorageMap(t, storagePath)
if parsed["persist_on_unload"] != true {
t.Fatalf("expected pending storage value to be flushed on unload, got %v", parsed["persist_on_unload"])
}
}
+364
View File
@@ -0,0 +1,364 @@
package gobackend
import (
"crypto/hmac"
"crypto/md5"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/dop251/goja"
)
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
input := call.Arguments[0].String()
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
}
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
input := call.Arguments[0].String()
decoded, err := base64.StdEncoding.DecodeString(input)
if err != nil {
return r.vm.ToValue("")
}
return r.vm.ToValue(string(decoded))
}
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
input := call.Arguments[0].String()
hash := md5.Sum([]byte(input))
return r.vm.ToValue(hex.EncodeToString(hash[:]))
}
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
input := call.Arguments[0].String()
hash := sha256.Sum256([]byte(input))
return r.vm.ToValue(hex.EncodeToString(hash[:]))
}
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue("")
}
message := call.Arguments[0].String()
key := call.Arguments[1].String()
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(message))
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
}
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue("")
}
message := call.Arguments[0].String()
key := call.Arguments[1].String()
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(message))
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
}
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue([]byte{})
}
var keyBytes []byte
keyArg := call.Arguments[0].Export()
switch k := keyArg.(type) {
case string:
keyBytes = []byte(k)
case []interface{}:
keyBytes = make([]byte, len(k))
for i, v := range k {
if num, ok := v.(int64); ok {
keyBytes[i] = byte(num)
} else if num, ok := v.(float64); ok {
keyBytes[i] = byte(int(num))
}
}
default:
return r.vm.ToValue([]byte{})
}
var msgBytes []byte
msgArg := call.Arguments[1].Export()
switch m := msgArg.(type) {
case string:
msgBytes = []byte(m)
case []interface{}:
msgBytes = make([]byte, len(m))
for i, v := range m {
if num, ok := v.(int64); ok {
msgBytes[i] = byte(num)
} else if num, ok := v.(float64); ok {
msgBytes[i] = byte(int(num))
}
}
default:
return r.vm.ToValue([]byte{})
}
mac := hmac.New(sha1.New, keyBytes)
mac.Write(msgBytes)
result := mac.Sum(nil)
jsArray := make([]interface{}, len(result))
for i, b := range result {
jsArray[i] = int(b)
}
return r.vm.ToValue(jsArray)
}
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
}
input := call.Arguments[0].String()
var result interface{}
if err := json.Unmarshal([]byte(input), &result); err != nil {
GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err)
return goja.Undefined()
}
return r.vm.ToValue(result)
}
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
input := call.Arguments[0].Export()
data, err := json.Marshal(input)
if err != nil {
GoLog("[Extension:%s] JSON stringify error: %v\n", r.extensionID, err)
return r.vm.ToValue("")
}
return r.vm.ToValue(string(data))
}
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "plaintext and key are required",
})
}
plaintext := call.Arguments[0].String()
keyStr := call.Arguments[1].String()
keyHash := sha256.Sum256([]byte(keyStr))
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": base64.StdEncoding.EncodeToString(encrypted),
})
}
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "ciphertext and key are required",
})
}
ciphertextB64 := call.Arguments[0].String()
keyStr := call.Arguments[1].String()
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "invalid base64 ciphertext",
})
}
keyHash := sha256.Sum256([]byte(keyStr))
decrypted, err := decryptAES(ciphertext, keyHash[:])
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "invalid base64 ciphertext",
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": string(decrypted),
})
}
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
length := 32
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok {
length = int(l)
}
}
key := make([]byte, length)
if _, err := rand.Read(key); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"key": base64.StdEncoding.EncodeToString(key),
"hex": hex.EncodeToString(key),
})
}
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent())
}
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
parts := make([]string, len(args))
for i, arg := range args {
parts[i] = fmt.Sprintf("%v", arg.Export())
}
return strings.Join(parts, " ")
}
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
input := call.Arguments[0].String()
return r.vm.ToValue(sanitizeFilename(input))
}
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
gobackendObj := vm.Get("gobackend")
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
gobackendObj = vm.NewObject()
vm.Set("gobackend", gobackendObj)
}
obj := gobackendObj.(*goja.Object)
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue("")
}
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
})
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue(map[string]interface{}{
"error": "file path is required",
})
}
filePath := call.Arguments[0].String()
quality, err := GetAudioQuality(filePath)
if err != nil {
return vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
return vm.ToValue(map[string]interface{}{
"bitDepth": quality.BitDepth,
"sampleRate": quality.SampleRate,
"totalSamples": quality.TotalSamples,
})
})
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return vm.ToValue("")
}
template := call.Arguments[0].String()
metadataObj := call.Arguments[1].Export()
metadata, ok := metadataObj.(map[string]interface{})
if !ok {
return vm.ToValue("")
}
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
})
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
now := time.Now()
_, offsetSeconds := now.Zone()
offsetMinutes := offsetSeconds / 60
return vm.ToValue(map[string]interface{}{
"year": now.Year(),
"month": int(now.Month()),
"day": now.Day(),
"hour": now.Hour(),
"minute": now.Minute(),
"second": now.Second(),
"weekday": int(now.Weekday()),
"offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
"timezone": now.Location().String(),
"timestamp": now.Unix(),
})
})
}
+197
View File
@@ -0,0 +1,197 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
type ExtensionSettingsStore struct {
mu sync.RWMutex
dataDir string
settings map[string]map[string]interface{} // extensionID -> settings
}
var (
globalSettingsStore *ExtensionSettingsStore
globalSettingsStoreOnce sync.Once
)
func GetExtensionSettingsStore() *ExtensionSettingsStore {
globalSettingsStoreOnce.Do(func() {
globalSettingsStore = &ExtensionSettingsStore{
settings: make(map[string]map[string]interface{}),
}
})
return globalSettingsStore
}
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.dataDir = dataDir
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("failed to create settings directory: %w", err)
}
return s.loadAllSettings()
}
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
return filepath.Join(s.dataDir, extensionID, "settings.json")
}
func (s *ExtensionSettingsStore) loadAllSettings() error {
entries, err := os.ReadDir(s.dataDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() {
extensionID := entry.Name()
settings, err := s.loadSettings(extensionID)
if err != nil {
GoLog("[ExtensionSettings] Failed to load settings for %s: %v\n", extensionID, err)
continue
}
s.settings[extensionID] = settings
}
}
return nil
}
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
settingsPath := s.getSettingsPath(extensionID)
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]interface{}), nil
}
return nil, err
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
return nil, err
}
return settings, nil
}
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
settingsPath := s.getSettingsPath(extensionID)
dir := filepath.Dir(settingsPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return os.WriteFile(settingsPath, data, 0644)
}
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
extSettings, exists := s.settings[extensionID]
if !exists {
return nil, fmt.Errorf("extension '%s' settings not found", extensionID)
}
value, exists := extSettings[key]
if !exists {
return nil, fmt.Errorf("setting '%s' not found for extension '%s'", key, extensionID)
}
return value, nil
}
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
extSettings, exists := s.settings[extensionID]
if !exists {
return make(map[string]interface{})
}
result := make(map[string]interface{})
for k, v := range extSettings {
result[k] = v
}
return result
}
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.settings[extensionID]; !exists {
s.settings[extensionID] = make(map[string]interface{})
}
s.settings[extensionID][key] = value
return s.saveSettings(extensionID, s.settings[extensionID])
}
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
s.settings[extensionID] = settings
return s.saveSettings(extensionID, settings)
}
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
extSettings, exists := s.settings[extensionID]
if !exists {
return nil
}
delete(extSettings, key)
return s.saveSettings(extensionID, extSettings)
}
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.settings, extensionID)
settingsPath := s.getSettingsPath(extensionID)
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := json.Marshal(s.settings)
if err != nil {
return "", err
}
return string(data), nil
}
+558
View File
@@ -0,0 +1,558 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
CategoryMetadata = "metadata"
CategoryDownload = "download"
CategoryUtility = "utility"
CategoryLyrics = "lyrics"
CategoryIntegration = "integration"
)
type StoreExtension struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"`
Category string `json:"category"`
Tags []string `json:"tags,omitempty"`
Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"`
DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"`
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
}
func (e *StoreExtension) getDisplayName() string {
if e.DisplayName != "" {
return e.DisplayName
}
if e.DisplayNameAlt != "" {
return e.DisplayNameAlt
}
return e.Name
}
func (e *StoreExtension) getDownloadURL() string {
if e.DownloadURL != "" {
return e.DownloadURL
}
return e.DownloadURLAlt
}
func (e *StoreExtension) getIconURL() string {
if e.IconURL != "" {
return e.IconURL
}
return e.IconURLAlt
}
func (e *StoreExtension) getMinAppVersion() string {
if e.MinAppVersion != "" {
return e.MinAppVersion
}
return e.MinAppVersionAlt
}
type StoreRegistry struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
Extensions []StoreExtension `json:"extensions"`
}
type StoreExtensionResponse struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url"`
IconURL string `json:"icon_url,omitempty"`
Category string `json:"category"`
Tags []string `json:"tags,omitempty"`
Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"`
IsInstalled bool `json:"is_installed"`
InstalledVersion string `json:"installed_version,omitempty"`
HasUpdate bool `json:"has_update"`
}
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
return StoreExtensionResponse{
ID: e.ID,
Name: e.Name,
DisplayName: e.getDisplayName(),
Version: e.Version,
Author: e.Author,
Description: e.Description,
DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(),
Category: e.Category,
Tags: e.Tags,
Downloads: e.Downloads,
UpdatedAt: e.UpdatedAt,
MinAppVersion: e.getMinAppVersion(),
}
}
type ExtensionStore struct {
registryURL string
cacheDir string
cache *StoreRegistry
cacheMu sync.RWMutex
cacheTime time.Time
cacheTTL time.Duration
}
var (
extensionStore *ExtensionStore
extensionStoreMu sync.Mutex
)
const (
cacheTTL = 30 * time.Minute
cacheFileName = "store_cache.json"
)
func InitExtensionStore(cacheDir string) *ExtensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
if extensionStore == nil {
extensionStore = &ExtensionStore{
registryURL: "", // No default - user must provide a registry URL
cacheDir: cacheDir,
cacheTTL: cacheTTL,
}
extensionStore.loadDiskCache()
}
return extensionStore
}
// SetRegistryURL updates the registry URL and clears the in-memory cache
// so the next fetch will use the new URL. Disk cache is also cleared.
func (s *ExtensionStore) SetRegistryURL(registryURL string) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
if s.registryURL == registryURL {
return
}
s.registryURL = registryURL
s.cache = nil
s.cacheTime = time.Time{}
// Clear disk cache since it's from a different registry
if s.cacheDir != "" {
cachePath := filepath.Join(s.cacheDir, cacheFileName)
os.Remove(cachePath)
}
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
}
// GetRegistryURL returns the currently configured registry URL.
func (s *ExtensionStore) GetRegistryURL() string {
s.cacheMu.RLock()
defer s.cacheMu.RUnlock()
return s.registryURL
}
func GetExtensionStore() *ExtensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
return extensionStore
}
func (s *ExtensionStore) loadDiskCache() {
if s.cacheDir == "" {
return
}
cachePath := filepath.Join(s.cacheDir, cacheFileName)
data, err := os.ReadFile(cachePath)
if err != nil {
return
}
var cacheData struct {
Registry StoreRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}
if err := json.Unmarshal(data, &cacheData); err != nil {
return
}
s.cache = &cacheData.Registry
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
}
func (s *ExtensionStore) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil {
return
}
cacheData := struct {
Registry StoreRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}{
Registry: *s.cache,
CacheTime: s.cacheTime.Unix(),
}
data, err := json.Marshal(cacheData)
if err != nil {
return
}
cachePath := filepath.Join(s.cacheDir, cacheFileName)
os.WriteFile(cachePath, data, 0644)
}
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
// Check if a registry URL has been configured
if s.registryURL == "" {
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
}
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
return s.cache, nil
}
if err := requireHTTPSURL(s.registryURL, "registry"); err != nil {
return nil, err
}
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
client := NewHTTPClientWithTimeout(30 * time.Second)
resp, err := client.Get(s.registryURL)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
return s.cache, nil
}
return nil, fmt.Errorf("failed to fetch registry: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if s.cache != nil {
LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode)
return s.cache, nil
}
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read registry: %w", err)
}
var registry StoreRegistry
if err := json.Unmarshal(body, &registry); err != nil {
return nil, fmt.Errorf("failed to parse registry: %w", err)
}
s.cache = &registry
s.cacheTime = time.Now()
s.saveDiskCache()
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
return &registry, nil
}
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(false)
if err != nil {
return nil, err
}
manager := GetExtensionManager()
installed := make(map[string]string) // id -> version
if manager != nil {
for _, ext := range manager.GetAllExtensions() {
installed[ext.ID] = ext.Manifest.Version
}
}
result := make([]StoreExtensionResponse, len(registry.Extensions))
for i, ext := range registry.Extensions {
resp := ext.ToResponse()
if installedVersion, ok := installed[ext.ID]; ok {
resp.IsInstalled = true
resp.InstalledVersion = installedVersion
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
}
result[i] = resp
}
return result, nil
}
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false)
if err != nil {
return err
}
var ext *StoreExtension
for _, e := range registry.Extensions {
if e.ID == extensionID {
ext = &e
break
}
}
if ext == nil {
return fmt.Errorf("extension %s not found in store", extensionID)
}
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
return err
}
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := NewHTTPClientWithTimeout(5 * time.Minute)
resp, err := client.Get(ext.getDownloadURL())
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
os.Remove(destPath)
return fmt.Errorf("failed to write file: %w", err)
}
LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath)
return nil
}
// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL.
//
// Accepted formats:
// - https://raw.githubusercontent.com/owner/repo/<branch>/registry.json → returned as-is
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
// the GitHub API to discover the default branch, then converted to the raw URL
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
func ResolveRegistryURL(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", fmt.Errorf("registry URL is empty")
}
// Already a fully-qualified raw URL keep it.
if strings.Contains(input, "raw.githubusercontent.com") {
return input, nil
}
// Try to match https://github.com/<owner>/<repo>[/...]
const ghPrefix = "https://github.com/"
if !strings.HasPrefix(input, ghPrefix) {
// Also accept http:// and upgrade silently.
const ghPrefixHTTP = "http://github.com/"
if strings.HasPrefix(input, ghPrefixHTTP) {
input = "https://github.com/" + input[len(ghPrefixHTTP):]
} else {
// Not a GitHub URL return as-is.
return input, nil
}
}
path := input[len(ghPrefix):]
parts := strings.SplitN(path, "/", 3) // owner, repo, [rest]
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", fmt.Errorf("invalid GitHub URL: expected github.com/<owner>/<repo>")
}
owner := parts[0]
repo := strings.TrimSuffix(parts[1], ".git")
branch := resolveGitHubDefaultBranch(owner, repo)
resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch)
return resolved, nil
}
// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's
// default branch. Falls back to "main" on any error.
func resolveGitHubDefaultBranch(owner, repo string) string {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
client := NewHTTPClientWithTimeout(10 * time.Second)
resp, err := client.Get(apiURL)
if err != nil {
LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v falling back to main", owner, repo, err)
return "main"
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s falling back to main", resp.StatusCode, owner, repo)
return "main"
}
var info struct {
DefaultBranch string `json:"default_branch"`
}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s falling back to main", owner, repo)
return "main"
}
return info.DefaultBranch
}
func requireHTTPSURL(rawURL string, context string) error {
if rawURL == "" {
return fmt.Errorf("%s URL is empty", context)
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
}
if parsed.Scheme != "https" {
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
}
return nil
}
func (s *ExtensionStore) GetCategories() []string {
return []string{
CategoryMetadata,
CategoryDownload,
CategoryUtility,
CategoryLyrics,
CategoryIntegration,
}
}
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus()
if err != nil {
return nil, err
}
if query == "" && category == "" {
return extensions, nil
}
var result []StoreExtensionResponse
queryLower := toLower(query)
for _, ext := range extensions {
if category != "" && ext.Category != category {
continue
}
if query != "" {
if !containsIgnoreCase(ext.Name, queryLower) &&
!containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) &&
!containsIgnoreCase(ext.Author, queryLower) {
// Check tags
found := false
for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) {
found = true
break
}
}
if !found {
continue
}
}
}
result = append(result, ext)
}
return result, nil
}
func (s *ExtensionStore) ClearCache() {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
s.cache = nil
s.cacheTime = time.Time{}
if s.cacheDir != "" {
cachePath := filepath.Join(s.cacheDir, cacheFileName)
os.Remove(cachePath)
}
LogInfo("ExtensionStore", "Cache cleared")
}
func containsIgnoreCase(s, substr string) bool {
return containsStr(toLower(s), substr)
}
func toLower(s string) string {
result := make([]byte, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
result[i] = c
}
return string(result)
}
func containsStr(s, substr string) bool {
return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0)
}
func findSubstring(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
+314
View File
@@ -0,0 +1,314 @@
package gobackend
import (
"path/filepath"
"testing"
"github.com/dop251/goja"
)
func TestParseManifest_Valid(t *testing.T) {
validManifest := `{
"name": "test-provider",
"displayName": "Test Provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"],
"permissions": {
"network": ["api.test.com"],
"storage": true
}
}`
manifest, err := ParseManifest([]byte(validManifest))
if err != nil {
t.Fatalf("Expected valid manifest to parse, got error: %v", err)
}
if manifest.Name != "test-provider" {
t.Errorf("Expected name 'test-provider', got '%s'", manifest.Name)
}
if manifest.Version != "1.0.0" {
t.Errorf("Expected version '1.0.0', got '%s'", manifest.Version)
}
if !manifest.IsMetadataProvider() {
t.Error("Expected IsMetadataProvider() to return true")
}
if manifest.IsDownloadProvider() {
t.Error("Expected IsDownloadProvider() to return false")
}
}
func TestParseManifest_MissingName(t *testing.T) {
invalidManifest := `{
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"]
}`
_, err := ParseManifest([]byte(invalidManifest))
if err == nil {
t.Fatal("Expected error for missing name")
}
}
func TestParseManifest_MissingType(t *testing.T) {
invalidManifest := `{
"name": "test-provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension"
}`
_, err := ParseManifest([]byte(invalidManifest))
if err == nil {
t.Fatal("Expected error for missing type")
}
}
func TestIsDomainAllowed(t *testing.T) {
manifest := &ExtensionManifest{
Permissions: ExtensionPermissions{
Network: []string{"api.test.com", "*.example.com"},
},
}
tests := []struct {
domain string
expected bool
}{
{"api.test.com", true},
{"api.example.com", true},
{"sub.example.com", true},
{"notallowed.com", false},
{"test.com", false},
}
for _, tt := range tests {
result := manifest.IsDomainAllowed(tt.domain)
if result != tt.expected {
t.Errorf("IsDomainAllowed(%s) = %v, expected %v", tt.domain, result, tt.expected)
}
}
}
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
// Create a mock extension with limited network permissions
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
Network: []string{"api.allowed.com", "*.wildcard.com"},
},
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
}
if err := runtime.validateDomain("https://sub.wildcard.com/path"); err != nil {
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
}
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
t.Error("Expected blocked.com to be denied")
}
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
t.Error("Expected notallowed.com to be denied")
}
}
func TestExtensionRuntime_FileSandbox(t *testing.T) {
tempDir := t.TempDir()
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
File: true,
},
},
DataDir: tempDir,
}
runtime := NewExtensionRuntime(ext)
validPath, err := runtime.validatePath("test.txt")
if err != nil {
t.Errorf("Expected relative path to be valid, got error: %v", err)
}
if validPath == "" {
t.Error("Expected non-empty path")
}
_, err = runtime.validatePath("../../../etc/passwd")
if err == nil {
t.Error("Expected path traversal to be blocked")
}
nestedPath, err := runtime.validatePath("subdir/file.txt")
if err != nil {
t.Errorf("Expected nested path to be valid, got error: %v", err)
}
if nestedPath == "" {
t.Error("Expected non-empty nested path")
}
var absPath string
if filepath.IsAbs("C:\\Windows\\System32") {
absPath = "C:\\Windows\\System32\\test.txt"
} else {
absPath = "/etc/passwd"
}
_, err = runtime.validatePath(absPath)
if err == nil {
t.Error("Expected absolute path to be blocked")
}
extNoFile := &LoadedExtension{
ID: "test-ext-no-file",
Manifest: &ExtensionManifest{
Name: "test-ext-no-file",
Permissions: ExtensionPermissions{
File: false,
},
},
DataDir: tempDir,
}
runtimeNoFile := NewExtensionRuntime(extNoFile)
_, err = runtimeNoFile.validatePath("test.txt")
if err == nil {
t.Error("Expected file access to be denied without file permission")
}
}
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
vm := goja.New()
runtime.RegisterAPIs(vm)
result, err := vm.RunString(`utils.base64Encode("hello")`)
if err != nil {
t.Fatalf("base64Encode failed: %v", err)
}
if result.String() != "aGVsbG8=" {
t.Errorf("Expected 'aGVsbG8=', got '%s'", result.String())
}
result, err = vm.RunString(`utils.base64Decode("aGVsbG8=")`)
if err != nil {
t.Fatalf("base64Decode failed: %v", err)
}
if result.String() != "hello" {
t.Errorf("Expected 'hello', got '%s'", result.String())
}
result, err = vm.RunString(`utils.md5("hello")`)
if err != nil {
t.Fatalf("md5 failed: %v", err)
}
if result.String() != "5d41402abc4b2a76b9719d911017c592" {
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
}
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
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")
}
}
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
// Create extension with limited network permissions
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
Network: []string{"api.example.com"},
},
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
privateIPs := []string{
"http://localhost/admin",
"http://127.0.0.1/admin",
"http://192.168.1.1/admin",
"http://10.0.0.1/admin",
"http://172.16.0.1/admin",
"http://169.254.169.254/latest/meta-data/", // AWS metadata
"http://router.local/admin",
}
for _, url := range privateIPs {
err := runtime.validateDomain(url)
if err == nil {
t.Errorf("Expected private IP/host '%s' to be blocked", url)
}
}
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
}
}
func TestIsPrivateIP(t *testing.T) {
tests := []struct {
host string
expected bool
}{
{"localhost", true},
{"127.0.0.1", true},
{"127.0.0.2", true},
{"10.0.0.1", true},
{"10.255.255.255", true},
{"172.16.0.1", true},
{"172.31.255.255", true},
{"192.168.0.1", true},
{"192.168.255.255", true},
{"169.254.169.254", true},
{"router.local", true},
{"mydevice.local", true},
{"8.8.8.8", false},
{"1.1.1.1", false},
{"api.example.com", false},
{"google.com", false},
{"172.15.0.1", false},
{"172.32.0.1", false},
{"192.167.0.1", false},
}
for _, tt := range tests {
result := isPrivateIP(tt.host)
if result != tt.expected {
t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected)
}
}
}
+106
View File
@@ -0,0 +1,106 @@
package gobackend
import (
"context"
"fmt"
"runtime/debug"
"sync"
"time"
"github.com/dop251/goja"
)
type JSExecutionError struct {
Message string
IsTimeout bool
}
func (e *JSExecutionError) Error() string {
return e.Message
}
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
if timeout <= 0 {
timeout = DefaultJSTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
type result struct {
value goja.Value
err error
}
resultCh := make(chan result, 1)
var interrupted bool
var interruptMu sync.Mutex
go func() {
defer func() {
if r := recover(); r != nil {
interruptMu.Lock()
wasInterrupted := interrupted
interruptMu.Unlock()
if wasInterrupted {
resultCh <- result{nil, &JSExecutionError{
Message: "execution timeout exceeded",
IsTimeout: true,
}}
} else {
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
}
}
}()
val, err := vm.RunString(script)
resultCh <- result{val, err}
}()
select {
case res := <-resultCh:
return res.value, res.err
case <-ctx.Done():
interruptMu.Lock()
interrupted = true
interruptMu.Unlock()
vm.Interrupt("execution timeout")
select {
case res := <-resultCh:
if res.err != nil {
return nil, res.err
}
return nil, &JSExecutionError{
Message: "execution timeout exceeded",
IsTimeout: true,
}
case <-time.After(1 * time.Second):
return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)",
IsTimeout: true,
}
}
}
}
// 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)
// Clear any interrupt state so VM can be reused
vm.ClearInterrupt()
return result, err
}
func IsTimeoutError(err error) bool {
if jsErr, ok := err.(*JSExecutionError); ok {
return jsErr.IsTimeout
}
return false
}
+234 -40
View File
@@ -3,84 +3,156 @@ package gobackend
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
// Invalid filename characters for Android/Windows/Linux
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
var (
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
multiUnderscore = regexp.MustCompile(`_+`)
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
yearPattern = regexp.MustCompile(`\d{4}`)
)
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(filename string) string {
// Replace invalid characters with underscore
sanitized := invalidChars.ReplaceAllString(filename, "_")
// Remove leading/trailing spaces and dots
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".")
// Collapse multiple underscores
multiUnderscore := regexp.MustCompile(`_+`)
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
// Limit length (Android has 255 byte limit for filenames)
if len(sanitized) > 200 {
sanitized = sanitized[:200]
}
// Ensure not empty
if sanitized == "" {
sanitized = "untitled"
}
return sanitized
}
// buildFilenameFromTemplate builds a filename from template and metadata
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
if template == "" {
template = "{artist} - {title}"
}
result := template
// Replace placeholders
placeholders := map[string]string{
"{title}": getString(metadata, "title"),
"{artist}": getString(metadata, "artist"),
"{album}": getString(metadata, "album"),
"{track}": formatTrackNumber(getInt(metadata, "track")),
"{year}": getString(metadata, "year"),
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
result := replaceFormattedNumberPlaceholders(template, metadata)
result = replaceDateFormatPlaceholders(result, metadata)
dateValue := getDateValue(metadata)
yearValue := getString(metadata, "year")
if yearValue == "" {
yearValue = extractYear(dateValue)
}
placeholders := map[string]string{
"{title}": getString(metadata, "title"),
"{artist}": getString(metadata, "artist"),
"{album}": getString(metadata, "album"),
"{track}": formatTrackNumber(getInt(metadata, "track")),
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
"{year}": yearValue,
"{date}": dateValue,
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
}
for placeholder, value := range placeholders {
result = strings.ReplaceAll(result, placeholder, value)
}
return result
}
func replaceFormattedNumberPlaceholders(template string, metadata map[string]interface{}) string {
return formattedNumberPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
parts := formattedNumberPlaceholderExpr.FindStringSubmatch(match)
if len(parts) != 3 {
return ""
}
number := getInt(metadata, parts[1])
width, err := strconv.Atoi(parts[2])
if err != nil {
return ""
}
return formatNumberWithWidth(number, width)
})
}
func replaceDateFormatPlaceholders(template string, metadata map[string]interface{}) string {
return dateFormatPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
parts := dateFormatPlaceholderExpr.FindStringSubmatch(match)
if len(parts) != 2 {
return ""
}
return formatDateWithPattern(getDateValue(metadata), parts[1])
})
}
func getDateValue(metadata map[string]interface{}) string {
date := getString(metadata, "date")
if date != "" {
return date
}
releaseDate := getString(metadata, "release_date")
if releaseDate != "" {
return releaseDate
}
return getString(metadata, "year")
}
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
// Trim leading/trailing whitespace to prevent filename issues
return strings.TrimSpace(s)
switch value := v.(type) {
case string:
return strings.TrimSpace(value)
case int:
return strconv.Itoa(value)
case int64:
return strconv.FormatInt(value, 10)
case float64:
return strconv.Itoa(int(value))
}
}
return ""
}
func getInt(m map[string]interface{}, key string) int {
if v, ok := m[key]; ok {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
candidateKeys := []string{key}
switch key {
case "track":
candidateKeys = append(candidateKeys, "track_number")
case "disc":
candidateKeys = append(candidateKeys, "disc_number")
}
for _, candidate := range candidateKeys {
if v, ok := m[candidate]; ok {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(n))
if err == nil {
return parsed
}
}
}
}
return 0
}
@@ -98,7 +170,129 @@ func formatDiscNumber(n int) string {
return fmt.Sprintf("%d", n)
}
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
func formatRawNumber(n int) string {
if n <= 0 {
return ""
}
return fmt.Sprintf("%d", n)
}
func formatNumberWithWidth(n int, width int) string {
if n <= 0 || width <= 0 {
return ""
}
if width <= 1 {
return formatRawNumber(n)
}
return fmt.Sprintf("%0*d", width, n)
}
func formatDateWithPattern(rawDate string, strftimePattern string) string {
if rawDate == "" || strftimePattern == "" {
return ""
}
parsedDate, ok := parseMetadataDate(rawDate)
if !ok {
return ""
}
goLayout := convertStrftimeToGoLayout(strftimePattern)
if goLayout == "" {
return ""
}
return parsedDate.Format(goLayout)
}
func parseMetadataDate(rawDate string) (time.Time, bool) {
clean := strings.TrimSpace(rawDate)
if clean == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02",
"2006-01",
"2006",
"2006/01/02",
"2006/01",
"2006.01.02",
"2006.01",
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, clean)
if err == nil {
return parsed, true
}
}
if len(clean) >= 10 {
parsed, err := time.Parse("2006-01-02", clean[:10])
if err == nil {
return parsed, true
}
}
yearMatch := yearPattern.FindString(clean)
if yearMatch == "" {
return time.Time{}, false
}
year, err := strconv.Atoi(yearMatch)
if err != nil || year <= 0 {
return time.Time{}, false
}
return time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), true
}
func convertStrftimeToGoLayout(pattern string) string {
if pattern == "" {
return ""
}
var builder strings.Builder
for i := 0; i < len(pattern); i++ {
ch := pattern[i]
if ch != '%' {
builder.WriteByte(ch)
continue
}
if i+1 >= len(pattern) {
builder.WriteByte('%')
break
}
i++
switch pattern[i] {
case 'Y':
builder.WriteString("2006")
case 'y':
builder.WriteString("06")
case 'm':
builder.WriteString("01")
case 'd':
builder.WriteString("02")
case 'b':
builder.WriteString("Jan")
case 'B':
builder.WriteString("January")
case '%':
builder.WriteByte('%')
default:
builder.WriteByte('%')
builder.WriteByte(pattern[i])
}
}
return builder.String()
}
func extractYear(date string) string {
if len(date) >= 4 {
return date[:4]
+85
View File
@@ -0,0 +1,85 @@
package gobackend
import "testing"
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
metadata := map[string]interface{}{
"title": "Song Name",
"artist": "Artist Name",
"album": "Album Name",
"track": 1,
"disc": 2,
"year": "2025",
}
formatted := buildFilenameFromTemplate(
"{artist} - {track} - {track_raw} - d{disc} - d{disc_raw} - {title}",
metadata,
)
expected := "Artist Name - 01 - 1 - d2 - d2 - Song Name"
if formatted != expected {
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestBuildFilenameFromTemplate_RawPlaceholdersEmptyWhenZero(t *testing.T) {
metadata := map[string]interface{}{
"title": "Song Name",
"artist": "Artist Name",
"track": 0,
"disc": 0,
}
formatted := buildFilenameFromTemplate("{track_raw}-{disc_raw}-{title}", metadata)
expected := "--Song Name"
if formatted != expected {
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
metadata := map[string]interface{}{
"track": 3,
"disc": 2,
}
formatted := buildFilenameFromTemplate("{track:1}-{track:02}-{disc:03}", metadata)
expected := "3-03-002"
if formatted != expected {
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
metadata := map[string]interface{}{
"artist": "Artist Name",
"title": "Song Name",
"release_date": "2024-03-09",
"track_number": 7,
"disc_number": 1,
}
formatted := buildFilenameFromTemplate(
"{artist} - {track:02} - {title} - {date:%Y-%m-%d} - {year}",
metadata,
)
expected := "Artist Name - 07 - Song Name - 2024-03-09 - 2024"
if formatted != expected {
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing.T) {
metadata := map[string]interface{}{
"artist": "Artist Name",
"title": "Song Name",
"date": "2019",
}
formatted := buildFilenameFromTemplate("{date:%Y}-{date:%m}-{date:%d}", metadata)
expected := "2019-01-01"
if formatted != expected {
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
+19 -8
View File
@@ -1,18 +1,29 @@
module github.com/zarz/spotiflac_android/go_backend
go 1.24.0
go 1.25.0
toolchain go1.24.5
toolchain go1.25.7
require (
github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5
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-20260211191516-dcd2a3258864
golang.org/x/net v0.50.0
)
require (
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
golang.org/x/mod v0.31.0 // indirect
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/klauspost/compress v1.17.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)
+66 -12
View File
@@ -1,14 +1,68 @@
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
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.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
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.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
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=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
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.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
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-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
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=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+371 -63
View File
@@ -1,48 +1,53 @@
package gobackend
import (
"crypto/tls"
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
// HTTP utility functions for consistent request handling across all downloaders
// User-Agent pool for Android Chrome browsers
var userAgentTemplates = []string{
"Mozilla/5.0 (Linux; Android %d; SM-G%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android %d; Pixel %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android %d; SM-A%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android %d; Redmi Note %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
}
// getRandomUserAgent generates a random browser-like User-Agent string (Android Chrome format)
func getRandomUserAgent() string {
template := userAgentTemplates[rand.Intn(len(userAgentTemplates))]
androidVersion := rand.Intn(5) + 10 // Android 10-14
deviceModel := rand.Intn(900) + 100 // Random model number
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
chromeBuild := rand.Intn(5000) + 5000
chromeVersion := rand.Intn(26) + 120
chromeBuild := rand.Intn(1500) + 6000
chromePatch := rand.Intn(200) + 100
return fmt.Sprintf(template, androidVersion, deviceModel, chromeVersion, chromeBuild, chromePatch)
return fmt.Sprintf(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
chromeVersion,
chromeBuild,
chromePatch,
)
}
// Default timeout values
const (
DefaultTimeout = 60 * time.Second // Default HTTP timeout
DownloadTimeout = 120 * time.Second // Timeout for file downloads
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
DefaultMaxRetries = 3 // Default retry count
DefaultRetryDelay = 1 * time.Second // Initial retry delay
DefaultTimeout = 60 * time.Second
DownloadTimeout = 24 * time.Hour
SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second
Second = time.Second
)
type NetworkCompatibilityOptions struct {
AllowHTTP bool
InsecureTLS bool
}
var (
networkCompatibilityMu sync.RWMutex
networkCompatibilityOptions NetworkCompatibilityOptions
)
// Shared transport with connection pooling to prevent TCP exhaustion
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -54,51 +59,184 @@ var sharedTransport = &http.Transport{
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: true,
}
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
// Isolated from download traffic so that download failures cannot poison
// the connection pool used by metadata enrichment.
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 30,
MaxIdleConnsPerHost: 5,
MaxConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 32 * 1024,
ReadBufferSize: 32 * 1024,
DisableCompression: true,
}
// Shared HTTP client for general requests (reuses connections)
var sharedClient = &http.Client{
Transport: sharedTransport,
Transport: newCompatibilityTransport(sharedTransport),
Timeout: DefaultTimeout,
}
// Shared HTTP client for downloads (longer timeout, reuses connections)
var downloadClient = &http.Client{
Transport: sharedTransport,
Transport: newCompatibilityTransport(sharedTransport),
Timeout: DownloadTimeout,
}
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
// Uses shared transport for connection reuse
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{
Transport: sharedTransport,
Transport: newCompatibilityTransport(sharedTransport),
Timeout: timeout,
}
}
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
// Use this for API calls that should not be affected by download traffic.
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: newCompatibilityTransport(metadataTransport),
Timeout: timeout,
}
}
// GetSharedClient returns the shared HTTP client for general requests
func GetSharedClient() *http.Client {
return sharedClient
}
// GetDownloadClient returns the shared HTTP client for downloads
func GetDownloadClient() *http.Client {
return downloadClient
}
// CloseIdleConnections closes idle connections in the shared transport
// Call this periodically during large batch downloads to prevent connection buildup
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
}
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
networkCompatibilityMu.Lock()
networkCompatibilityOptions = NetworkCompatibilityOptions{
AllowHTTP: allowHTTP,
InsecureTLS: insecureTLS,
}
networkCompatibilityMu.Unlock()
applyTLSCompatibility(sharedTransport, insecureTLS)
applyTLSCompatibility(metadataTransport, insecureTLS)
CloseIdleConnections()
GoLog("[HTTP] Network compatibility options updated: allow_http=%v insecure_tls=%v\n", allowHTTP, insecureTLS)
}
func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
networkCompatibilityMu.RLock()
defer networkCompatibilityMu.RUnlock()
return 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
}
type compatibilityTransport struct {
base http.RoundTripper
}
func newCompatibilityTransport(base http.RoundTripper) http.RoundTripper {
return &compatibilityTransport{base: base}
}
func (t *compatibilityTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil || req.URL == nil {
return t.base.RoundTrip(req)
}
opts := GetNetworkCompatibilityOptions()
if !opts.AllowHTTP || req.URL.Scheme != "https" {
return t.base.RoundTrip(req)
}
// Compatibility mode should prefer HTTPS and only fallback to HTTP on
// transport-level failures. Forcing HTTP unconditionally can trigger
// redirect loops (http -> https) on providers that enforce HTTPS.
resp, err := t.base.RoundTrip(req)
if err == nil {
return resp, nil
}
if !canFallbackToHTTP(req) {
return nil, err
}
fallbackReq, cloneErr := cloneRequestWithHTTPScheme(req, "http")
if cloneErr != nil {
return nil, err
}
GoLog("[HTTP] HTTPS request failed for %s, retrying over HTTP: %v\n", req.URL.Host, err)
return t.base.RoundTrip(fallbackReq)
}
func canFallbackToHTTP(req *http.Request) bool {
if req == nil {
return false
}
switch strings.ToUpper(req.Method) {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodDelete:
return true
default:
return req.GetBody != nil
}
}
func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request, error) {
reqCopy := req.Clone(req.Context())
if req.Body != nil && req.GetBody != nil {
bodyCopy, err := req.GetBody()
if err != nil {
return nil, err
}
reqCopy.Body = bodyCopy
}
urlCopy := *req.URL
urlCopy.Scheme = scheme
reqCopy.URL = &urlCopy
return reqCopy, nil
}
// Also checks for ISP blocking on errors
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
return client.Do(req)
resp, err := client.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
}
return resp, err
}
// RetryConfig holds configuration for retry logic
@@ -109,7 +247,6 @@ type RetryConfig struct {
BackoffFactor float64
}
// DefaultRetryConfig returns default retry configuration
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: DefaultMaxRetries,
@@ -119,33 +256,35 @@ func DefaultRetryConfig() RetryConfig {
}
}
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
// Handles 429 (Too Many Requests) responses with Retry-After header
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
var lastErr error
delay := config.InitialDelay
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
// Clone request for retry (body needs to be re-readable)
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(reqCopy)
if err != nil {
lastErr = err
if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") {
return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP")
}
if attempt < config.MaxRetries {
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
attempt+1, config.MaxRetries+1, err, delay)
time.Sleep(delay)
delay = calculateNextDelay(delay, config)
}
continue
}
// Success
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp, nil
}
// Handle rate limiting (429)
if resp.StatusCode == 429 {
resp.Body.Close()
retryAfter := getRetryAfterDuration(resp)
@@ -154,53 +293,71 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
}
lastErr = fmt.Errorf("rate limited (429)")
if attempt < config.MaxRetries {
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
time.Sleep(delay)
delay = calculateNextDelay(delay, config)
}
continue
}
// Server errors (5xx) - retry
// Check for ISP blocking via HTTP status codes
// Some ISPs return 403 or 451 when blocking content
if resp.StatusCode == 403 || resp.StatusCode == 451 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
bodyStr := strings.ToLower(string(body))
// Check if response looks like ISP blocking page
ispBlockingIndicators := []string{
"blocked", "forbidden", "access denied", "not available in your",
"restricted", "censored", "unavailable for legal", "blocked by",
}
for _, indicator := range ispBlockingIndicators {
if strings.Contains(bodyStr, indicator) {
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
LogError("HTTP", "Domain: %s", req.URL.Host)
LogError("HTTP", "Response contains: %s", indicator)
LogError("HTTP", "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
}
}
}
if resp.StatusCode >= 500 {
resp.Body.Close()
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
if attempt < config.MaxRetries {
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
time.Sleep(delay)
delay = calculateNextDelay(delay, config)
}
continue
}
// Client errors (4xx except 429) - don't retry
return resp, nil
}
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
}
// calculateNextDelay calculates the next delay with exponential backoff
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
if nextDelay > config.MaxDelay {
nextDelay = config.MaxDelay
}
return nextDelay
return min(nextDelay, config.MaxDelay)
}
// getRetryAfterDuration parses Retry-After header and returns duration
// Returns 60 seconds as default if header is missing or invalid
// Returns 0 if the header is missing or invalid so callers can keep their
// normal exponential backoff instead of stalling for an arbitrary minute.
func getRetryAfterDuration(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After")
if retryAfter == "" {
return 60 * time.Second // Default wait time
return 0
}
// Try parsing as seconds
if seconds, err := strconv.Atoi(retryAfter); err == nil {
return time.Duration(seconds) * time.Second
}
// Try parsing as HTTP date
if t, err := http.ParseTime(retryAfter); err == nil {
duration := time.Until(t)
if duration > 0 {
@@ -208,11 +365,9 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
}
}
return 60 * time.Second // Default
return 0
}
// ReadResponseBody reads and returns the response body
// Returns error if body is empty
func ReadResponseBody(resp *http.Response) ([]byte, error) {
if resp == nil {
return nil, fmt.Errorf("response is nil")
@@ -230,7 +385,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
return body, nil
}
// ValidateResponse checks if response is valid (non-nil, status 2xx)
func ValidateResponse(resp *http.Response) error {
if resp == nil {
return fmt.Errorf("response is nil")
@@ -243,14 +397,12 @@ func ValidateResponse(resp *http.Response) error {
return nil
}
// BuildErrorMessage creates a detailed error message for API failures
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
msg := fmt.Sprintf("API %s failed", apiURL)
if statusCode > 0 {
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
}
if responsePreview != "" {
// Truncate preview if too long
if len(responsePreview) > 100 {
responsePreview = responsePreview[:100] + "..."
}
@@ -258,3 +410,159 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
}
return msg
}
type ISPBlockingError struct {
Domain string
Reason string
OriginalErr error
}
func (e *ISPBlockingError) Error() string {
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
}
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil {
return nil
}
domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error())
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTemporary {
return &ISPBlockingError{
Domain: domain,
Reason: "DNS resolution failed - domain may be blocked by ISP",
OriginalErr: err,
}
}
}
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Op == "dial" {
var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
switch syscallErr {
case syscall.ECONNREFUSED:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection refused - port may be blocked by ISP/firewall",
OriginalErr: err,
}
case syscall.ECONNRESET:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection reset - ISP may be intercepting traffic",
OriginalErr: err,
}
case syscall.ETIMEDOUT:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection timed out - ISP may be blocking access",
OriginalErr: err,
}
case syscall.ENETUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Network unreachable - ISP may be blocking route",
OriginalErr: err,
}
case syscall.EHOSTUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Host unreachable - ISP may be blocking destination",
OriginalErr: err,
}
}
}
}
}
var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) {
return &ISPBlockingError{
Domain: domain,
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
OriginalErr: err,
}
}
blockingPatterns := []struct {
pattern string
reason string
}{
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
{"connection refused", "Connection refused - port may be blocked"},
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
{"certificate", "Certificate error - ISP may be using MITM proxy"},
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
}
for _, bp := range blockingPatterns {
if strings.Contains(errStr, bp.pattern) {
return &ISPBlockingError{
Domain: domain,
Reason: bp.reason,
OriginalErr: err,
}
}
}
return nil
}
// Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL)
if ispErr != nil {
LogError(tag, "ISP BLOCKING DETECTED: %s", ispErr.Error())
LogError(tag, "Domain: %s", ispErr.Domain)
LogError(tag, "Reason: %s", ispErr.Reason)
LogError(tag, "Original error: %v", ispErr.OriginalErr)
LogError(tag, "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
return true
}
return false
}
func extractDomain(rawURL string) string {
if rawURL == "" {
return "unknown"
}
parsed, err := url.Parse(rawURL)
if err != nil {
rawURL = strings.TrimPrefix(rawURL, "https://")
rawURL = strings.TrimPrefix(rawURL, "http://")
if idx := strings.Index(rawURL, "/"); idx > 0 {
return rawURL[:idx]
}
return rawURL
}
if parsed.Host != "" {
return parsed.Host
}
return "unknown"
}
// If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil {
return nil
}
if CheckAndLogISPBlocking(err, requestURL, tag) {
domain := extractDomain(requestURL)
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
}
return err
}
+27
View File
@@ -0,0 +1,27 @@
//go:build ios
package gobackend
import (
"net/http"
)
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
// Fall back to standard HTTP client
// GetCloudflareBypassClient returns the standard HTTP client on iOS
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
func GetCloudflareBypassClient() *http.Client {
return sharedClient
}
// DoRequestWithCloudflareBypass on iOS just uses the standard client
// uTLS Chrome fingerprint bypass is not available on iOS
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
}
return resp, err
}
+175
View File
@@ -0,0 +1,175 @@
//go:build !ios
package gobackend
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
utls "github.com/refraction-networking/utls"
"golang.org/x/net/http2"
)
// uTLS transport that mimics Chrome's TLS fingerprint to bypass Cloudflare
// Uses HTTP/2 for optimal performance as uTLS works best with HTTP/2
type utlsTransport struct {
dialer *net.Dialer
mu sync.Mutex
h2Transports map[string]*http2.Transport
}
func newUTLSTransport() *utlsTransport {
return &utlsTransport{
dialer: &net.Dialer{
Timeout: 30 * Second,
KeepAlive: 30 * Second,
},
h2Transports: make(map[string]*http2.Transport),
}
}
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme != "https" {
return sharedTransport.RoundTrip(req)
}
host := req.URL.Hostname()
port := t.getPort(req.URL)
addr := net.JoinHostPort(host, port)
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
if err != nil {
return nil, err
}
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
}, utls.HelloChrome_Auto)
if err := tlsConn.Handshake(); err != nil {
conn.Close()
return nil, err
}
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
if negotiatedProto == "h2" {
h2Transport := &http2.Transport{
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
return tlsConn, nil
},
AllowHTTP: false,
DisableCompression: false,
}
return h2Transport.RoundTrip(req)
}
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return tlsConn, nil
},
DisableKeepAlives: true,
}
return transport.RoundTrip(req)
}
func (t *utlsTransport) getPort(u *url.URL) string {
if u.Port() != "" {
return u.Port()
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
var cloudflareBypassTransport = newUTLSTransport()
var cloudflareBypassClient = &http.Client{
Transport: cloudflareBypassTransport,
Timeout: DefaultTimeout,
}
// GetCloudflareBypassClient returns an HTTP client that mimics Chrome's TLS fingerprint
// Use this when requests are blocked by Cloudflare (common when using VPN)
func GetCloudflareBypassClient() *http.Client {
return cloudflareBypassClient
}
// DoRequestWithCloudflareBypass attempts request with standard client first,
// then retries with uTLS Chrome fingerprint if Cloudflare blocks it.
// This is useful when using VPN as Cloudflare detects Go's default TLS fingerprint.
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req)
if err == nil {
// Check for Cloudflare challenge page (403 with specific markers)
if resp.StatusCode == 403 || resp.StatusCode == 503 {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr == nil {
bodyStr := strings.ToLower(string(body))
cloudflareMarkers := []string{
"cloudflare", "cf-ray", "checking your browser",
"please wait", "ddos protection", "ray id",
"enable javascript", "challenge-platform",
}
isCloudflare := false
for _, marker := range cloudflareMarkers {
if strings.Contains(bodyStr, marker) {
isCloudflare = true
break
}
}
if isCloudflare {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
return cloudflareBypassClient.Do(reqCopy)
}
}
// Not Cloudflare, return original response (recreate body)
return &http.Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
Header: resp.Header,
Body: io.NopCloser(strings.NewReader(string(body))),
}, nil
}
return resp, nil
}
// Check if error might be TLS-related (Cloudflare blocking)
errStr := strings.ToLower(err.Error())
tlsRelated := strings.Contains(errStr, "tls") ||
strings.Contains(errStr, "handshake") ||
strings.Contains(errStr, "certificate") ||
strings.Contains(errStr, "connection reset")
if tlsRelated {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
return cloudflareBypassClient.Do(reqCopy)
}
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
return nil, err
}
+182
View File
@@ -0,0 +1,182 @@
package gobackend
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// IDHSClient is a client for I Don't Have Spotify API
// Used as fallback when SongLink fails or is rate limited
type IDHSClient struct {
client *http.Client
}
var (
globalIDHSClient *IDHSClient
idhsClientOnce sync.Once
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
)
type IDHSSearchRequest struct {
Link string `json:"link"`
Adapters []string `json:"adapters,omitempty"`
}
type IDHSSearchResponse struct {
ID string `json:"id"`
Type string `json:"type"` // song, album, artist, podcast, show
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image,omitempty"`
Audio string `json:"audio,omitempty"`
Source string `json:"source"`
UniversalLink string `json:"universalLink"`
Links []IDHSLink `json:"links"`
}
type IDHSLink struct {
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
URL string `json:"url"`
IsVerified bool `json:"isVerified,omitempty"`
NotAvailable bool `json:"notAvailable,omitempty"`
}
func NewIDHSClient() *IDHSClient {
idhsClientOnce.Do(func() {
globalIDHSClient = &IDHSClient{
client: NewHTTPClientWithTimeout(15 * time.Second),
}
})
return globalIDHSClient
}
// Search converts a music link to links on other platforms
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
idhsRateLimiter.WaitForSlot()
reqBody := IDHSSearchRequest{
Link: link,
Adapters: adapters,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", "https://idonthavespotify.sjdonado.com/api/search?v=1", bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 400 {
return nil, fmt.Errorf("invalid link or missing parameters")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("IDHS rate limit exceeded")
}
if resp.StatusCode == 500 {
return nil, fmt.Errorf("IDHS processing failed")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("IDHS API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result IDHSSearchResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
adapters := []string{"tidal", "deezer"}
result, err := c.Search(spotifyURL, adapters)
if err != nil {
return nil, err
}
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
}
for _, link := range result.Links {
if link.NotAvailable {
continue
}
switch strings.ToLower(link.Type) {
case "tidal":
availability.Tidal = true
availability.TidalURL = link.URL
case "deezer":
availability.Deezer = true
availability.DeezerURL = link.URL
availability.DeezerID = extractDeezerIDFromURL(link.URL)
}
}
LogDebug("IDHS", "Availability from Spotify %s: Tidal=%v, Deezer=%v",
spotifyTrackID, availability.Tidal, availability.Deezer)
return availability, nil
}
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
adapters := []string{"spotify", "tidal"}
result, err := c.Search(deezerURL, adapters)
if err != nil {
return nil, err
}
availability := &TrackAvailability{
Deezer: true,
DeezerID: deezerTrackID,
}
for _, link := range result.Links {
if link.NotAvailable {
continue
}
switch strings.ToLower(link.Type) {
case "spotify":
availability.SpotifyID = extractSpotifyIDFromURL(link.URL)
case "tidal":
availability.Tidal = true
availability.TidalURL = link.URL
}
}
LogDebug("IDHS", "Availability from Deezer %s: Spotify=%s, Tidal=%v",
deezerTrackID, availability.SpotifyID, availability.Tidal)
return availability, nil
}
+835
View File
@@ -0,0 +1,835 @@
package gobackend
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
type LibraryScanResult struct {
ID string `json:"id"`
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
AlbumArtist string `json:"albumArtist,omitempty"`
FilePath string `json:"filePath"`
CoverPath string `json:"coverPath,omitempty"`
ScannedAt string `json:"scannedAt"`
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"trackNumber,omitempty"`
DiscNumber int `json:"discNumber,omitempty"`
Duration int `json:"duration,omitempty"`
ReleaseDate string `json:"releaseDate,omitempty"`
BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"`
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
Genre string `json:"genre,omitempty"`
Format string `json:"format,omitempty"`
}
type LibraryScanProgress struct {
TotalFiles int `json:"total_files"`
ScannedFiles int `json:"scanned_files"`
CurrentFile string `json:"current_file"`
ErrorCount int `json:"error_count"`
ProgressPct float64 `json:"progress_pct"`
IsComplete bool `json:"is_complete"`
}
type IncrementalScanResult struct {
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
SkippedCount int `json:"skippedCount"` // Files that were unchanged
TotalFiles int `json:"totalFiles"` // Total files in folder
}
var (
libraryScanProgress LibraryScanProgress
libraryScanProgressMu sync.RWMutex
libraryScanCancel chan struct{}
libraryScanCancelMu sync.Mutex
libraryCoverCacheDir string
libraryCoverCacheMu sync.RWMutex
)
var supportedAudioFormats = map[string]bool{
".flac": true,
".m4a": true,
".mp3": true,
".opus": true,
".ogg": true,
".cue": true,
}
type libraryAudioFileInfo struct {
path string
modTime int64
}
type scannedCueFileInfo struct {
sheet *CueSheet
audioPath string
}
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 {
if err != nil {
return nil
}
select {
case <-cancelCh:
return fmt.Errorf("scan cancelled")
default:
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if !supportedAudioFormats[ext] {
return nil
}
files = append(files, libraryAudioFileInfo{
path: path,
modTime: info.ModTime().UnixMilli(),
})
return nil
})
if err != nil {
return nil, err
}
return files, nil
}
func SetLibraryCoverCacheDir(cacheDir string) {
libraryCoverCacheMu.Lock()
libraryCoverCacheDir = cacheDir
libraryCoverCacheMu.Unlock()
}
func ScanLibraryFolder(folderPath string) (string, error) {
if folderPath == "" {
return "[]", fmt.Errorf("folder path is empty")
}
info, err := os.Stat(folderPath)
if err != nil {
return "[]", fmt.Errorf("folder not found: %w", err)
}
if !info.IsDir() {
return "[]", fmt.Errorf("path is not a folder: %s", folderPath)
}
libraryScanProgressMu.Lock()
libraryScanProgress = LibraryScanProgress{}
libraryScanProgressMu.Unlock()
libraryScanCancelMu.Lock()
if libraryScanCancel != nil {
close(libraryScanCancel)
}
libraryScanCancel = make(chan struct{})
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
audioFileInfos, err := collectLibraryAudioFiles(folderPath, cancelCh)
if err != nil {
return "[]", err
}
totalFiles := len(audioFileInfos)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
if totalFiles == 0 {
libraryScanProgressMu.Lock()
libraryScanProgress.IsComplete = true
libraryScanProgressMu.Unlock()
return "[]", nil
}
GoLog("[LibraryScan] Found %d audio files to scan\n", totalFiles)
results := make([]LibraryScanResult, 0, totalFiles)
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
// Track audio files referenced by .cue sheets to avoid duplicates
cueReferencedAudioFiles := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
// First pass: scan .cue files to collect referenced audio paths
for _, fileInfo := range audioFileInfos {
filePath := fileInfo.path
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".cue" {
sheet, err := ParseCueFile(filePath)
if err == nil && sheet.FileName != "" {
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
if audioPath != "" {
parsedCueFiles[filePath] = scannedCueFileInfo{
sheet: sheet,
audioPath: audioPath,
}
cueReferencedAudioFiles[audioPath] = true
}
}
}
}
for i, fileInfo := range audioFileInfos {
filePath := fileInfo.path
select {
case <-cancelCh:
return "[]", fmt.Errorf("scan cancelled")
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = i + 1
libraryScanProgress.CurrentFile = filepath.Base(filePath)
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
ext := strings.ToLower(filepath.Ext(filePath))
// Handle .cue files: produce multiple track results
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[filePath]
if ok {
cueResults, err = scanCueSheetForLibrary(
filePath,
cueInfo.sheet,
cueInfo.audioPath,
"",
fileInfo.modTime,
scanTime,
)
} else {
cueResults, err = ScanCueFileForLibrary(filePath, scanTime)
}
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
continue
}
results = append(results, cueResults...)
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
continue
}
// Skip audio files that are referenced by a .cue sheet
// (they will be represented by the cue sheet's track entries instead)
if cueReferencedAudioFiles[filePath] {
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
}
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
continue
}
results = append(results, *result)
}
libraryScanProgressMu.Lock()
libraryScanProgress.ErrorCount = errorCount
libraryScanProgress.IsComplete = true
libraryScanProgressMu.Unlock()
GoLog("[LibraryScan] Scan complete: %d tracks found, %d errors\n", len(results), errorCount)
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal results: %w", err)
}
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 scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime)
}
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
ext := resolveLibraryAudioExt(filePath, displayNameHint)
result := &LibraryScanResult{
ID: generateLibraryID(filePath),
FilePath: filePath,
ScannedAt: scanTime,
Format: strings.TrimPrefix(ext, "."),
}
if knownModTime > 0 {
result.FileModTime = knownModTime
} else if info, err := os.Stat(filePath); err == nil {
result.FileModTime = info.ModTime().UnixMilli()
}
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" {
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
}
}
switch ext {
case ".flac":
return scanFLACFile(filePath, result)
case ".m4a":
return scanM4AFile(filePath, result)
case ".mp3":
return scanMP3File(filePath, result)
case ".opus", ".ogg":
return scanOggFile(filePath, result, displayNameHint)
default:
return scanFromFilename(filePath, displayNameHint, result)
}
}
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
ext := strings.ToLower(filepath.Ext(filePath))
if ext != "" {
return ext
}
return strings.ToLower(filepath.Ext(displayNameHint))
}
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
if displayNameHint != "" {
return displayNameHint
}
return filePath
}
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
}
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
return scanFromFilename(filePath, "", result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.ReleaseDate = metadata.Date
result.Genre = metadata.Genre
quality, err := GetAudioQuality(filePath)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
applyDefaultLibraryMetadata(filePath, "", result)
return result, nil
}
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadM4ATags(filePath)
if err == nil && metadata != nil {
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.ReleaseDate = metadata.Date
if result.ReleaseDate == "" {
result.ReleaseDate = metadata.Year
}
result.Genre = metadata.Genre
}
quality, err := GetM4AQuality(filePath)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
applyDefaultLibraryMetadata(filePath, "", result)
return result, nil
}
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, "", result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.Genre = metadata.Genre
if metadata.Date != "" {
result.ReleaseDate = metadata.Date
} else {
result.ReleaseDate = metadata.Year
}
result.ISRC = metadata.ISRC
quality, err := GetMP3Quality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
applyDefaultLibraryMetadata(filePath, "", result)
return result, nil
}
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadOggVorbisComments(filePath)
if err != nil {
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.Genre = metadata.Genre
result.ReleaseDate = metadata.Date
quality, err := GetOggQuality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
parts := strings.SplitN(filename, " - ", 2)
if len(parts) == 2 {
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
result.TrackName = parts[1]
result.ArtistName = "Unknown Artist"
} else {
result.ArtistName = parts[0]
result.TrackName = parts[1]
}
} else {
if len(filename) > 3 && isNumeric(filename[:2]) {
title := strings.TrimLeft(filename[2:], " .-")
result.TrackName = title
} else {
result.TrackName = filename
}
result.ArtistName = "Unknown Artist"
}
dir := filepath.Dir(filePath)
result.AlbumName = filepath.Base(dir)
if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
func generateLibraryID(filePath string) string {
return fmt.Sprintf("lib_%x", hashString(filePath))
}
func hashString(s string) uint32 {
var hash uint32 = 5381
for _, c := range s {
hash = ((hash << 5) + hash) + uint32(c)
}
return hash
}
func GetLibraryScanProgress() string {
libraryScanProgressMu.RLock()
defer libraryScanProgressMu.RUnlock()
jsonBytes, _ := json.Marshal(libraryScanProgress)
return string(jsonBytes)
}
func CancelLibraryScan() {
libraryScanCancelMu.Lock()
defer libraryScanCancelMu.Unlock()
if libraryScanCancel != nil {
close(libraryScanCancel)
libraryScanCancel = nil
}
}
func ReadAudioMetadata(filePath string) (string, error) {
return ReadAudioMetadataWithDisplayName(filePath, "")
}
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
return string(jsonBytes), nil
}
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
existingFiles := make(map[string]int64)
if snapshotPath == "" {
return existingFiles, nil
}
file, err := os.Open(snapshotPath)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 2)
if len(parts) != 2 {
continue
}
modTime, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
continue
}
existingFiles[parts[1]] = modTime
}
if err := scanner.Err(); err != nil {
return nil, err
}
return existingFiles, nil
}
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
if folderPath == "" {
return "{}", fmt.Errorf("folder path is empty")
}
info, err := os.Stat(folderPath)
if err != nil {
return "{}", fmt.Errorf("folder not found: %w", err)
}
if !info.IsDir() {
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
}
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
libraryScanProgressMu.Lock()
libraryScanProgress = LibraryScanProgress{}
libraryScanProgressMu.Unlock()
libraryScanCancelMu.Lock()
if libraryScanCancel != nil {
close(libraryScanCancel)
}
libraryScanCancel = make(chan struct{})
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
if err != nil {
return "{}", err
}
currentPathSet := make(map[string]bool, len(currentFiles))
for _, fileInfo := range currentFiles {
currentPathSet[fileInfo.path] = true
}
totalFiles := len(currentFiles)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
// Find files to scan (new or modified)
var filesToScan []libraryAudioFileInfo
skippedCount := 0
existingCueTrackModTimes := make(map[string]int64)
for existingPath, modTime := range existingFiles {
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
existingCueTrackModTimes[baseCuePath] = modTime
}
}
}
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
// For .cue files, also check if any virtual path entries exist
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
// CUE file exists in DB via virtual paths; check if modTime changed
if f.modTime == cueTrackModTime {
skippedCount++
} else {
filesToScan = append(filesToScan, f)
}
continue
}
}
filesToScan = append(filesToScan, f)
} else if f.modTime != existingModTime {
filesToScan = append(filesToScan, f)
} else {
skippedCount++
}
}
var deletedPaths []string
for existingPath := range existingFiles {
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
// check if the base .cue file still exists on disk
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if currentPathSet[baseCuePath] {
continue // Base .cue file still exists, not deleted
}
// Base CUE file is gone, mark virtual path as deleted
deletedPaths = append(deletedPaths, existingPath)
} else if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
}
}
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
len(filesToScan), skippedCount, len(deletedPaths))
if len(filesToScan) == 0 {
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.IsComplete = true
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
result := IncrementalScanResult{
Scanned: []LibraryScanResult{},
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
results := make([]LibraryScanResult, 0, len(filesToScan))
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
cueReferencedAudioFilesInc := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
for _, f := range filesToScan {
ext := strings.ToLower(filepath.Ext(f.path))
if ext == ".cue" {
sheet, err := ParseCueFile(f.path)
if err == nil && sheet.FileName != "" {
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
if audioPath != "" {
parsedCueFiles[f.path] = scannedCueFileInfo{
sheet: sheet,
audioPath: audioPath,
}
cueReferencedAudioFilesInc[audioPath] = true
}
}
}
}
for i, f := range filesToScan {
select {
case <-cancelCh:
return "{}", fmt.Errorf("scan cancelled")
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = skippedCount + i + 1
libraryScanProgress.CurrentFile = filepath.Base(f.path)
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
ext := strings.ToLower(filepath.Ext(f.path))
// Handle .cue files: produce multiple track results
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[f.path]
if ok {
cueResults, err = scanCueSheetForLibrary(
f.path,
cueInfo.sheet,
cueInfo.audioPath,
"",
f.modTime,
scanTime,
)
} else {
cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
}
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
continue
}
results = append(results, cueResults...)
continue
}
// Skip audio files referenced by .cue sheets
if cueReferencedAudioFilesInc[f.path] {
continue
}
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
continue
}
results = append(results, *result)
}
libraryScanProgressMu.Lock()
libraryScanProgress.ErrorCount = errorCount
libraryScanProgress.IsComplete = true
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
len(results), skippedCount, len(deletedPaths), errorCount)
scanResult := IncrementalScanResult{
Scanned: results,
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, err := json.Marshal(scanResult)
if err != nil {
return "{}", fmt.Errorf("failed to marshal results: %w", err)
}
return string(jsonBytes), nil
}
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" {
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
}
}
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
}
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
if err != nil {
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
}
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
}
+212
View File
@@ -0,0 +1,212 @@
package gobackend
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"sync"
"time"
)
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Tag string `json:"tag"`
Message string `json:"message"`
}
type LogBuffer struct {
entries []LogEntry
maxSize int
mu sync.RWMutex
loggingEnabled bool
}
const (
defaultLogBufferSize = 500
maxLogMessageLength = 500
)
var (
globalLogBuffer *LogBuffer
logBufferOnce sync.Once
authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`)
genericKeyValuePattern = regexp.MustCompile(`(?i)\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)`)
queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+`)
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`)
)
func sanitizeSensitiveLogText(message string) string {
redacted := message
redacted = authorizationBearerPattern.ReplaceAllString(redacted, "Authorization: Bearer [REDACTED]")
redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`)
redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]")
return redacted
}
func GetLogBuffer() *LogBuffer {
logBufferOnce.Do(func() {
globalLogBuffer = &LogBuffer{
entries: make([]LogEntry, 0, defaultLogBufferSize),
maxSize: defaultLogBufferSize,
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
}
})
return globalLogBuffer
}
func truncateLogMessage(message string) string {
runes := []rune(message)
if len(runes) <= maxLogMessageLength {
return message
}
return string(runes[:maxLogMessageLength]) + "...[truncated]"
}
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock()
defer lb.mu.Unlock()
lb.loggingEnabled = enabled
}
func (lb *LogBuffer) IsLoggingEnabled() bool {
lb.mu.RLock()
defer lb.mu.RUnlock()
return lb.loggingEnabled
}
func (lb *LogBuffer) Add(level, tag, message string) {
lb.mu.Lock()
defer lb.mu.Unlock()
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
return
}
message = sanitizeSensitiveLogText(message)
message = truncateLogMessage(message)
entry := LogEntry{
Timestamp: time.Now().Format("15:04:05.000"),
Level: level,
Tag: tag,
Message: message,
}
if len(lb.entries) >= lb.maxSize {
lb.entries = lb.entries[1:]
}
lb.entries = append(lb.entries, entry)
fmt.Printf("[%s] %s\n", tag, message)
}
func (lb *LogBuffer) GetAll() string {
lb.mu.RLock()
defer lb.mu.RUnlock()
jsonBytes, _ := json.Marshal(lb.entries)
return string(jsonBytes)
}
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
lb.mu.RLock()
defer lb.mu.RUnlock()
if index < 0 {
index = 0
}
if index >= len(lb.entries) {
return []LogEntry{}, len(lb.entries)
}
entries := lb.entries[index:]
return entries, len(lb.entries)
}
func (lb *LogBuffer) Clear() {
lb.mu.Lock()
defer lb.mu.Unlock()
lb.entries = lb.entries[:0]
}
func (lb *LogBuffer) Count() int {
lb.mu.RLock()
defer lb.mu.RUnlock()
return len(lb.entries)
}
func LogDebug(tag, format string, args ...interface{}) {
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
}
func LogInfo(tag, format string, args ...interface{}) {
GetLogBuffer().Add("INFO", tag, fmt.Sprintf(format, args...))
}
func LogWarn(tag, format string, args ...interface{}) {
GetLogBuffer().Add("WARN", tag, fmt.Sprintf(format, args...))
}
func LogError(tag, format string, args ...interface{}) {
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
}
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
// It parses the tag from the format string if it starts with [Tag]
func GoLog(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
message = strings.TrimSuffix(message, "\n")
// Extract tag from message if present (e.g., "[Tidal] message")
tag := "Go"
level := "INFO"
if strings.HasPrefix(message, "[") {
endBracket := strings.Index(message, "]")
if endBracket > 1 {
tag = message[1:endBracket]
message = strings.TrimSpace(message[endBracket+1:])
}
}
// Determine level from message content
msgLower := strings.ToLower(message)
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
level = "ERROR"
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
level = "WARN"
} else if strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
level = "INFO"
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
level = "DEBUG"
}
GetLogBuffer().Add(level, tag, message)
}
func GetLogs() string {
return GetLogBuffer().GetAll()
}
func GetLogsSince(index int) string {
entries, nextIndex := GetLogBuffer().getSince(index)
logsJson, _ := json.Marshal(entries)
result := fmt.Sprintf(`{"logs":%s,"next_index":%d}`, string(logsJson), nextIndex)
return result
}
func ClearLogs() {
GetLogBuffer().Clear()
}
func GetLogCount() int {
return GetLogBuffer().Count()
}
func SetLoggingEnabled(enabled bool) {
GetLogBuffer().SetLoggingEnabled(enabled)
}
+810 -25
View File
@@ -3,14 +3,245 @@ package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
const (
lyricsCacheTTL = 24 * time.Hour
durationToleranceSec = 10.0
)
// Lyrics provider names (used in settings and cascade ordering)
const (
LyricsProviderSpotifyAPI = "spotify_api"
LyricsProviderLRCLIB = "lrclib"
LyricsProviderNetease = "netease"
LyricsProviderMusixmatch = "musixmatch"
LyricsProviderAppleMusic = "apple_music"
LyricsProviderQQMusic = "qqmusic"
)
// DefaultLyricsProviders is the default cascade order for lyrics fetching.
// LRCLIB first (no proxy dependency), then the others.
var DefaultLyricsProviders = []string{
LyricsProviderLRCLIB,
LyricsProviderSpotifyAPI,
LyricsProviderMusixmatch,
LyricsProviderNetease,
LyricsProviderAppleMusic,
LyricsProviderQQMusic,
}
var (
lyricsProvidersMu sync.RWMutex
lyricsProviders []string // ordered list of enabled providers
)
var (
spotifyLyricsRateLimitMu sync.RWMutex
spotifyLyricsRateLimitedTil time.Time
)
// LyricsFetchOptions controls optional provider-specific enhancements.
type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
}
var defaultLyricsFetchOptions = LyricsFetchOptions{
IncludeTranslationNetease: false,
IncludeRomanizationNetease: false,
MultiPersonWordByWord: true,
MusixmatchLanguage: "",
}
var (
lyricsFetchOptionsMu sync.RWMutex
lyricsFetchOptions = defaultLyricsFetchOptions
)
// SetLyricsProviderOrder sets the ordered list of lyrics providers to try.
// Providers not in the list are disabled. An empty list resets to defaults.
func SetLyricsProviderOrder(providers []string) {
lyricsProvidersMu.Lock()
defer lyricsProvidersMu.Unlock()
if len(providers) == 0 {
lyricsProviders = nil
return
}
// Validate provider names
validNames := map[string]bool{
LyricsProviderSpotifyAPI: true,
LyricsProviderLRCLIB: true,
LyricsProviderNetease: true,
LyricsProviderMusixmatch: true,
LyricsProviderAppleMusic: true,
LyricsProviderQQMusic: true,
}
var valid []string
for _, p := range providers {
normalized := strings.ToLower(strings.TrimSpace(p))
if validNames[normalized] {
valid = append(valid, normalized)
}
}
lyricsProviders = valid
GoLog("[Lyrics] Provider order set to: %v\n", valid)
}
// GetLyricsProviderOrder returns the current lyrics provider order.
func GetLyricsProviderOrder() []string {
lyricsProvidersMu.RLock()
defer lyricsProvidersMu.RUnlock()
if len(lyricsProviders) == 0 {
return DefaultLyricsProviders
}
result := make([]string, len(lyricsProviders))
copy(result, lyricsProviders)
return result
}
// GetAvailableLyricsProviders returns metadata about all available providers.
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
{"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"},
}
}
func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
opts.MusixmatchLanguage = strings.ToLower(strings.TrimSpace(opts.MusixmatchLanguage))
opts.MusixmatchLanguage = regexp.MustCompile(`[^a-z0-9\-_]`).ReplaceAllString(opts.MusixmatchLanguage, "")
if len(opts.MusixmatchLanguage) > 16 {
opts.MusixmatchLanguage = opts.MusixmatchLanguage[:16]
}
return opts
}
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
normalized := normalizeLyricsFetchOptions(opts)
lyricsFetchOptionsMu.Lock()
defer lyricsFetchOptionsMu.Unlock()
lyricsFetchOptions = normalized
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
normalized.IncludeTranslationNetease,
normalized.IncludeRomanizationNetease,
normalized.MultiPersonWordByWord,
normalized.MusixmatchLanguage,
)
}
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
func GetLyricsFetchOptions() LyricsFetchOptions {
lyricsFetchOptionsMu.RLock()
defer lyricsFetchOptionsMu.RUnlock()
return lyricsFetchOptions
}
type lyricsCacheEntry struct {
response *LyricsResponse
expiresAt time.Time
}
type lyricsCache struct {
mu sync.RWMutex
cache map[string]*lyricsCacheEntry
}
var globalLyricsCache = &lyricsCache{
cache: make(map[string]*lyricsCacheEntry),
}
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
roundedDuration := math.Round(durationSec/10) * 10
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
}
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := c.generateKey(artist, track, durationSec)
entry, exists := c.cache[key]
if !exists {
return nil, false
}
if time.Now().After(entry.expiresAt) {
return nil, false
}
return entry.response, true
}
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.generateKey(artist, track, durationSec)
c.cache[key] = &lyricsCacheEntry{
response: response,
expiresAt: time.Now().Add(lyricsCacheTTL),
}
}
func (c *lyricsCache) CleanExpired() int {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
cleaned := 0
for key, entry := range c.cache {
if now.After(entry.expiresAt) {
delete(c.cache, key)
cleaned++
}
}
return cleaned
}
func (c *lyricsCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
func (c *lyricsCache) ClearAll() int {
c.mu.Lock()
defer c.mu.Unlock()
cleared := len(c.cache)
c.cache = make(map[string]*lyricsCacheEntry)
return cleared
}
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -23,6 +254,18 @@ type LRCLibResponse struct {
SyncedLyrics string `json:"syncedLyrics"`
}
type SpotifyLyricsLine struct {
TimeTag string `json:"timeTag"`
Words string `json:"words"`
}
type SpotifyLyricsAPIResponse struct {
Error bool `json:"error"`
Message string `json:"message"`
SyncType string `json:"syncType"`
Lines []SpotifyLyricsLine `json:"lines"`
}
type LyricsLine struct {
StartTimeMs int64 `json:"startTimeMs"`
Words string `json:"words"`
@@ -44,9 +287,7 @@ type LyricsClient struct {
func NewLyricsClient() *LyricsClient {
return &LyricsClient{
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
}
}
@@ -62,7 +303,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -86,7 +327,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
return c.parseLRCLibResponse(&lrcResp), nil
}
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) {
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
baseURL := "https://lrclib.net/api/search"
params := url.Values{}
params.Set("q", query)
@@ -97,7 +338,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -118,6 +359,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return nil, fmt.Errorf("no lyrics found")
}
bestMatch := c.findBestMatch(results, durationSec)
if bestMatch != nil {
return c.parseLRCLibResponse(bestMatch), nil
}
for _, result := range results {
if result.SyncedLyrics != "" {
return c.parseLRCLibResponse(&result), nil
@@ -127,43 +373,441 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return c.parseLRCLibResponse(&results[0]), nil
}
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
// Strategy 1: Direct match with artist and track name
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
func parseSpotifyLyricsTimeTagToMs(tag string) int64 {
raw := strings.TrimSpace(tag)
raw = strings.TrimPrefix(raw, "[")
raw = strings.TrimSuffix(raw, "]")
if raw == "" {
return 0
}
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
return ms
}
re := regexp.MustCompile(`^(\d{1,2}):(\d{2})\.(\d{1,3})$`)
matches := re.FindStringSubmatch(raw)
if len(matches) != 4 {
return 0
}
minutes, _ := strconv.ParseInt(matches[1], 10, 64)
seconds, _ := strconv.ParseInt(matches[2], 10, 64)
fraction := matches[3]
fractionInt, _ := strconv.ParseInt(fraction, 10, 64)
if len(fraction) == 2 {
fractionInt *= 10
} else if len(fraction) == 1 {
fractionInt *= 100
}
return minutes*60*1000 + seconds*1000 + fractionInt
}
func getSpotifyLyricsRateLimitUntil() time.Time {
spotifyLyricsRateLimitMu.RLock()
defer spotifyLyricsRateLimitMu.RUnlock()
return spotifyLyricsRateLimitedTil
}
func setSpotifyLyricsRateLimitUntil(until time.Time) {
spotifyLyricsRateLimitMu.Lock()
spotifyLyricsRateLimitedTil = until
spotifyLyricsRateLimitMu.Unlock()
}
func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
raw := strings.TrimSpace(retryAfter)
if raw == "" {
return now.Add(10 * time.Minute)
}
if sec, err := strconv.Atoi(raw); err == nil && sec > 0 {
return now.Add(time.Duration(sec) * time.Second)
}
if when, err := http.ParseTime(raw); err == nil && when.After(now) {
return when
}
return now.Add(10 * time.Minute)
}
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
if len(lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
if syncType == "" {
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
syncType = "LINE_SYNCED"
} else {
syncType = "UNSYNCED"
}
}
return &LyricsResponse{
Lines: lines,
SyncType: syncType,
Instrumental: false,
PlainLyrics: plainLyrics,
Provider: "Spotify Lyrics API",
Source: "Spotify Lyrics API",
}, nil
}
func plainLyricsFromTimedLines(lines []LyricsLine) string {
parts := make([]string, 0, len(lines))
for _, line := range lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
parts = append(parts, words)
}
return strings.Join(parts, "\n")
}
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
var lrcPayload string
if err := json.Unmarshal(body, &lrcPayload); err == nil {
trimmed := strings.TrimSpace(lrcPayload)
if trimmed == "" {
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
}
lines := parseSyncedLyrics(trimmed)
if len(lines) > 0 {
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
}
plainLines := plainTextLyricsLines(trimmed)
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
}
var apiResp SpotifyLyricsAPIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
}
if apiResp.Error {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Spotify Lyrics API returned error"
}
return nil, fmt.Errorf("%s", msg)
}
lines := make([]LyricsLine, 0, len(apiResp.Lines))
for _, line := range apiResp.Lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
lines = append(lines, LyricsLine{
StartTimeMs: startMs,
Words: words,
EndTimeMs: 0,
})
}
for i := 0; i < len(lines)-1; i++ {
nextStart := lines[i+1].StartTimeMs
if nextStart > lines[i].StartTimeMs {
lines[i].EndTimeMs = nextStart
}
}
if len(lines) > 0 {
last := len(lines) - 1
if lines[last].EndTimeMs == 0 {
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
}
}
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
}
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
now := time.Now()
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
waitFor := int(math.Ceil(limitedUntil.Sub(now).Seconds()))
return nil, fmt.Errorf(
"Spotify Lyrics API cooldown active (%ds remaining after previous 429)",
waitFor,
)
}
spotifyID = strings.TrimSpace(spotifyID)
if spotifyID == "" {
return nil, fmt.Errorf("spotify ID is empty")
}
if parsed, err := parseSpotifyURI(spotifyID); err == nil && parsed.Type == "track" && parsed.ID != "" {
spotifyID = parsed.ID
}
apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
}
if resp.StatusCode != 200 {
if resp.StatusCode == http.StatusTooManyRequests {
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
setSpotifyLyricsRateLimitUntil(retryUntil)
}
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err == nil {
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
}
if msg, ok := payload["error"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
}
}
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
}
return parseSpotifyLyricsResponseBody(bodyBytes)
}
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse
for i := range results {
result := &results[i]
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
if durationMatches {
if result.SyncedLyrics != "" && bestSynced == nil {
bestSynced = result
} else if result.PlainLyrics != "" && bestPlain == nil {
bestPlain = result
}
}
}
if bestSynced != nil {
return bestSynced
}
return bestPlain
}
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
diff := math.Abs(lrcDuration - targetDuration)
return diff <= durationToleranceSec
}
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
primaryArtist := normalizeArtistName(artistName)
fetchOptions := GetLyricsFetchOptions()
extManager := GetExtensionManager()
var extensionProviders []*ExtensionProviderWrapper
if extManager != nil {
extensionProviders = extManager.GetLyricsProviders()
}
var cachedNonExtension *LyricsResponse
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
isExtensionCache := strings.HasPrefix(cached.Source, "Extension:")
if len(extensionProviders) == 0 || isExtensionCache {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
cachedCopy.Source = cached.Source + " (cached)"
return &cachedCopy, nil
}
// If extension providers are currently enabled, don't let stale built-in cache
// mask newly installed/activated extensions.
cachedNonExtension = cached
GoLog("[Lyrics] Ignoring cached non-extension lyrics because extension providers are available\n")
}
isValidResult := func(l *LyricsResponse) bool {
return lyricsHasUsableText(l)
}
if len(extensionProviders) > 0 {
for _, provider := range extensionProviders {
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
if err == nil && isValidResult(lyrics) {
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
if err != nil {
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
}
}
}
if cachedNonExtension != nil {
cachedCopy := *cachedNonExtension
cachedCopy.Source = cachedNonExtension.Source + " (cached fallback)"
GoLog("[Lyrics] Extension providers unavailable for this track, using cached built-in lyrics\n")
return &cachedCopy, nil
}
providerOrder := GetLyricsProviderOrder()
simplifiedTrack := simplifyTrackName(trackName)
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
// Cascade through all configured built-in providers
for _, providerName := range providerOrder {
GoLog("[Lyrics] Trying provider: %s\n", providerName)
var lyrics *LyricsResponse
var err error
switch providerName {
case LyricsProviderSpotifyAPI:
lyrics, err = c.FetchLyricsFromSpotifyAPI(spotifyID)
case LyricsProviderLRCLIB:
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
case LyricsProviderNetease:
neteaseClient := NewNeteaseClient()
lyrics, err = neteaseClient.FetchLyrics(
trackName,
primaryArtist,
durationSec,
fetchOptions.IncludeTranslationNetease,
fetchOptions.IncludeRomanizationNetease,
)
if err != nil && primaryArtist != artistName {
lyrics, err = neteaseClient.FetchLyrics(
trackName,
artistName,
durationSec,
fetchOptions.IncludeTranslationNetease,
fetchOptions.IncludeRomanizationNetease,
)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = neteaseClient.FetchLyrics(
simplifiedTrack,
primaryArtist,
durationSec,
fetchOptions.IncludeTranslationNetease,
fetchOptions.IncludeRomanizationNetease,
)
}
case LyricsProviderMusixmatch:
musixmatchClient := NewMusixmatchClient()
lyrics, err = musixmatchClient.FetchLyrics(
trackName,
primaryArtist,
durationSec,
fetchOptions.MusixmatchLanguage,
)
if err != nil && primaryArtist != artistName {
lyrics, err = musixmatchClient.FetchLyrics(
trackName,
artistName,
durationSec,
fetchOptions.MusixmatchLanguage,
)
}
case LyricsProviderAppleMusic:
appleClient := NewAppleMusicClient()
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
if err != nil && primaryArtist != artistName {
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
}
case LyricsProviderQQMusic:
qqClient := NewQQMusicClient()
lyrics, err = qqClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
if err != nil && primaryArtist != artistName {
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
}
default:
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
continue
}
if err == nil && isValidResult(lyrics) {
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
if err != nil {
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
}
}
return nil, fmt.Errorf("lyrics not found from any source")
}
// tryLRCLIB attempts all LRCLIB search strategies (exact match, simplified, search).
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
var lyrics *LyricsResponse
var err error
// 1. Exact match with primary artist
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
lyrics.Source = "LRCLIB"
return lyrics, nil
}
// Strategy 2: Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName)
// 2. Exact match with full artist name
if primaryArtist != artistName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
lyrics.Source = "LRCLIB"
return lyrics, nil
}
}
// 3. Simplified track name
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
lyrics.Source = "LRCLIB (simplified)"
return lyrics, nil
}
}
// Strategy 3: Search with full query
query := artistName + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
// 4. Search by query
query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
lyrics.Source = "LRCLIB Search"
return lyrics, nil
}
// Strategy 4: Search with simplified query
// 5. Search with simplified track name
if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
lyrics.Source = "LRCLIB Search (simplified)"
return lyrics, nil
}
}
return nil, fmt.Errorf("lyrics not found from any source")
return nil, fmt.Errorf("LRCLIB: no lyrics found")
}
func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse {
@@ -203,10 +847,20 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
continue
}
// Preserve Apple/QQ background vocal tags by attaching them to
// the previous timed line. This keeps [bg:...] in final exported LRC.
if strings.HasPrefix(line, "[bg:") && len(lines) > 0 {
lines[len(lines)-1].Words = strings.TrimSpace(lines[len(lines)-1].Words + "\n" + line)
continue
}
matches := lrcPattern.FindStringSubmatch(line)
if len(matches) == 5 {
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
words := strings.TrimSpace(matches[4])
if words == "" {
continue
}
lines = append(lines, LyricsLine{
StartTimeMs: startMs,
@@ -227,6 +881,79 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
return lines
}
func plainTextLyricsLines(rawLyrics string) []LyricsLine {
var lines []LyricsLine
for _, line := range strings.Split(rawLyrics, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
lines = append(lines, LyricsLine{
StartTimeMs: 0,
Words: trimmed,
EndTimeMs: 0,
})
}
return lines
}
func lyricsHasUsableText(lyrics *LyricsResponse) bool {
if lyrics == nil {
return false
}
if lyrics.Instrumental {
return true
}
if strings.TrimSpace(lyrics.PlainLyrics) != "" {
return true
}
for _, line := range lyrics.Lines {
if strings.TrimSpace(line.Words) != "" {
return true
}
}
return false
}
// detectLyricsErrorPayload extracts human-readable error messages from
// JSON payloads returned by lyrics proxies when no lyric is available.
func detectLyricsErrorPayload(raw string) (string, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
return "", false
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return "", false
}
lyricsKeys := []string{"lyrics", "lyric", "lrc", "content", "lines", "syncedLyrics", "unsyncedLyrics"}
hasLyricsKey := false
for _, key := range lyricsKeys {
if _, ok := payload[key]; ok {
hasLyricsKey = true
break
}
}
errorKeys := []string{"message", "error", "detail", "reason"}
for _, key := range errorKeys {
if msg, ok := payload[key].(string); ok {
msg = strings.TrimSpace(msg)
if msg != "" && !hasLyricsKey {
return msg, true
}
}
}
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
return "request unsuccessful", true
}
return "", false
}
func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
min, _ := strconv.ParseInt(minutes, 10, 64)
sec, _ := strconv.ParseInt(seconds, 10, 64)
@@ -240,23 +967,35 @@ func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
}
func msToLRCTimestamp(ms int64) string {
return fmt.Sprintf("[%s]", msToLRCTimestampInline(ms))
}
func msToLRCTimestampInline(ms int64) string {
totalSeconds := ms / 1000
minutes := totalSeconds / 60
seconds := totalSeconds % 60
centiseconds := (ms % 1000) / 10
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
}
func convertToLRC(lyrics *LyricsResponse) string {
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 {
return ""
}
var builder strings.Builder
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
builder.WriteString("\n")
if lyrics.SyncType == "LINE_SYNCED" {
for _, line := range lyrics.Lines {
if line.Words == "" {
continue
}
timestamp := msToLRCTimestamp(line.StartTimeMs)
builder.WriteString(timestamp)
builder.WriteString(line.Words)
@@ -264,6 +1003,9 @@ func convertToLRC(lyrics *LyricsResponse) string {
}
} else {
for _, line := range lyrics.Lines {
if line.Words == "" {
continue
}
builder.WriteString(line.Words)
builder.WriteString("\n")
}
@@ -294,6 +1036,49 @@ func simplifyTrackName(name string) string {
re := regexp.MustCompile("(?i)" + pattern)
result = re.ReplaceAllString(result, "")
}
result = strings.TrimSpace(result)
if result == "" {
return result
}
// Add a loose fallback form for provider queries where punctuation
// and separators differ (e.g. "/" vs "_" vs spaces).
if loose := normalizeLooseTitle(result); loose != "" {
return loose
}
return result
}
func normalizeArtistName(name string) string {
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
result := name
for _, sep := range separators {
if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
result = result[:idx]
break
}
}
return strings.TrimSpace(result)
}
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
if lrcContent == "" {
return "", fmt.Errorf("empty LRC content")
}
dir := filepath.Dir(audioFilePath)
ext := filepath.Ext(audioFilePath)
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
lrcFilePath := filepath.Join(dir, baseName+".lrc")
if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil {
return "", fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC file: %s\n", lrcFilePath)
return lrcFilePath, nil
}
+305
View File
@@ -0,0 +1,305 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"
)
// AppleMusicClient fetches lyrics from Apple Music.
// Uses Paxsenix endpoints for search and lyrics.
type AppleMusicClient struct {
httpClient *http.Client
}
type appleMusicSearchResult struct {
ID string `json:"id"`
SongName string `json:"songName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
Duration int `json:"duration"`
}
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
type paxResponse struct {
Type string `json:"type"` // "Syllable" or "Line"
Content []paxLyrics `json:"content"` // List of lyric lines
}
type paxLyrics struct {
Text []paxLyricDetail `json:"text"`
Timestamp int `json:"timestamp"`
OppositeTurn bool `json:"oppositeTurn"`
Background bool `json:"background"`
BackgroundText []paxLyricDetail `json:"backgroundText"`
EndTime int `json:"endtime"`
}
type paxLyricDetail struct {
Text string `json:"text"`
Part bool `json:"part"`
Timestamp *int `json:"timestamp"`
EndTime *int `json:"endtime"`
}
func NewAppleMusicClient() *AppleMusicClient {
return &AppleMusicClient{
httpClient: NewMetadataHTTPClient(20 * time.Second),
}
}
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
if len(results) == 0 {
return nil
}
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
if normalizedArtist == "" {
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := 0
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
switch {
case candidateTrack == normalizedTrack:
score += 50
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
score += 25
}
switch {
case candidateArtist == normalizedArtist:
score += 60
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
score += 30
}
if durationSec > 0 && result.Duration > 0 {
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
if diff <= durationToleranceSec {
score += 20
}
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
return &results[bestIndex]
}
// SearchSong searches for a song on Apple Music and returns its ID.
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)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
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)
}
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.ID) == "" {
return "", fmt.Errorf("no songs found on apple music")
}
return strings.TrimSpace(best.ID), nil
}
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
req, err := http.NewRequest("GET", lyricsURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("apple music lyrics fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("apple music lyrics proxy returned HTTP %d", resp.StatusCode)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read lyrics response: %w", err)
}
bodyStr := strings.TrimSpace(string(bodyBytes))
if bodyStr == "" {
return "", fmt.Errorf("empty lyrics response from apple music")
}
return bodyStr, nil
}
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
var paxResp paxResponse
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
}
var directLyrics []paxLyrics
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
}
return "", fmt.Errorf("failed to parse pax lyrics response")
}
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
lastStart := ""
for _, syllable := range details {
if syllable.Timestamp != nil {
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
if start != lastStart {
builder.WriteString(start)
lastStart = start
}
}
builder.WriteString(syllable.Text)
if !syllable.Part {
builder.WriteString(" ")
}
if syllable.EndTime != nil {
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
}
}
}
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
var sb strings.Builder
for i, line := range content {
if i > 0 {
sb.WriteString("\n")
}
timestamp := msToLRCTimestamp(int64(line.Timestamp))
if strings.EqualFold(lyricsType, "Syllable") {
sb.WriteString(timestamp)
if multiPersonWordByWord {
if line.OppositeTurn {
sb.WriteString("v2:")
} else {
sb.WriteString("v1:")
}
}
appendPaxLyricDetail(&sb, line.Text)
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
sb.WriteString("\n[bg:")
appendPaxLyricDetail(&sb, line.BackgroundText)
sb.WriteString("]")
}
} else {
if len(line.Text) > 0 {
sb.WriteString(timestamp)
sb.WriteString(line.Text[0].Text)
}
}
}
return strings.TrimSpace(sb.String())
}
// FetchLyrics searches Apple Music and returns parsed LyricsResponse.
func (c *AppleMusicClient) FetchLyrics(
trackName,
artistName string,
durationSec float64,
multiPersonWordByWord bool,
) (*LyricsResponse, error) {
songID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
rawLyrics, err := c.FetchLyricsByID(songID)
if err != nil {
return nil, err
}
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
}
// Try to parse as pax format (word-by-word or line)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
if err != nil {
// If pax parsing fails, try to parse as direct LRC text
lrcText = rawLyrics
}
lines := parseSyncedLyrics(lrcText)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
Provider: "Apple Music",
Source: "Apple Music",
}, nil
}
// Fall back to plain text if no timestamps found
resultLines := plainTextLyricsLines(lrcText)
if len(resultLines) > 0 {
return &LyricsResponse{
Lines: resultLines,
SyncType: "UNSYNCED",
Provider: "Apple Music",
Source: "Apple Music",
}, nil
}
return nil, fmt.Errorf("no lyrics found on apple music")
}
+192
View File
@@ -0,0 +1,192 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"
)
// MusixmatchClient fetches lyrics from Musixmatch via a proxy server.
// The proxy handles Musixmatch authentication internally.
type MusixmatchClient struct {
httpClient *http.Client
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),
baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
}
}
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
return "", fmt.Errorf("empty track or artist name")
}
params := url.Values{}
params.Set("t", trackName)
params.Set("a", artistName)
params.Set("type", lyricsType)
params.Set("format", "lrc")
if durationSec > 0 {
params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
}
if strings.TrimSpace(language) != "" {
params.Set("l", strings.ToLower(strings.TrimSpace(language)))
}
fullURL := c.baseURL + "?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("musixmatch request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read musixmatch response: %w", err)
}
if resp.StatusCode != 200 {
trimmed := strings.TrimSpace(string(body))
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
}
return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
}
var lrcPayload string
if err := json.Unmarshal(body, &lrcPayload); err == nil {
lrcPayload = strings.TrimSpace(lrcPayload)
if lrcPayload == "" {
return "", fmt.Errorf("empty musixmatch lyrics payload")
}
return lrcPayload, nil
}
trimmed := strings.TrimSpace(string(body))
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("%s", errMsg)
}
if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
return trimmed, nil
}
return "", fmt.Errorf("failed to decode musixmatch response")
}
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
lang := strings.ToLower(strings.TrimSpace(language))
if lang == "" {
return nil, fmt.Errorf("invalid language")
}
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
if err != nil {
return nil, err
}
lines := parseSyncedLyrics(lrcText)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
PlainLyrics: plainLyricsFromTimedLines(lines),
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
plainLines := plainTextLyricsLines(lrcText)
if len(plainLines) > 0 {
return &LyricsResponse{
Lines: plainLines,
SyncType: "UNSYNCED",
PlainLyrics: lrcText,
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
}
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
if localizedErr == nil {
return localized, nil
}
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
}
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
if err != nil {
return nil, err
}
lines := parseSyncedLyrics(lrcText)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
PlainLyrics: plainLyricsFromTimedLines(lines),
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
plainLines := plainTextLyricsLines(lrcText)
if len(plainLines) > 0 {
return &LyricsResponse{
Lines: plainLines,
SyncType: "UNSYNCED",
PlainLyrics: lrcText,
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
return nil, fmt.Errorf("no lyrics found on musixmatch")
}
+200
View File
@@ -0,0 +1,200 @@
package gobackend
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
type NeteaseClient struct {
httpClient *http.Client
}
type neteaseSearchResponse struct {
Result struct {
Songs []struct {
Name string `json:"name"`
ID int64 `json:"id"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
} `json:"songs"`
SongCount int `json:"songCount"`
} `json:"result"`
Code int `json:"code"`
}
type neteaseLyricsResponse struct {
LRC *neteaseLyricField `json:"lrc"`
TLyric *neteaseLyricField `json:"tlyric"`
RomaLRC *neteaseLyricField `json:"romalrc"`
Code int `json:"code"`
}
type neteaseLyricField struct {
Lyric string `json:"lyric"`
}
var neteaseHeaders = map[string]string{
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.9",
"Cache-Control": "max-age=0",
}
func NewNeteaseClient() *NeteaseClient {
return &NeteaseClient{
httpClient: NewMetadataHTTPClient(15 * time.Second),
}
}
// SearchSong searches for a song on Netease and returns the song ID.
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return 0, fmt.Errorf("empty search query")
}
searchURL := "https://lyrics.paxsenix.org/netease/search"
params := url.Values{}
params.Set("q", query)
fullURL := searchURL + "?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return 0, fmt.Errorf("failed to create request: %w", err)
}
for k, v := range neteaseHeaders {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("netease search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return 0, fmt.Errorf("netease search returned HTTP %d", resp.StatusCode)
}
var searchResp neteaseSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return 0, fmt.Errorf("failed to decode netease search: %w", err)
}
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
return 0, fmt.Errorf("no songs found on netease")
}
return searchResp.Result.Songs[0].ID, nil
}
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
params := url.Values{}
params.Set("id", fmt.Sprintf("%d", songID))
fullURL := lyricsURL + "?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
for k, v := range neteaseHeaders {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("netease lyrics fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("netease lyrics returned HTTP %d", resp.StatusCode)
}
var lyricsResp neteaseLyricsResponse
if err := json.NewDecoder(resp.Body).Decode(&lyricsResp); err != nil {
return "", fmt.Errorf("failed to decode netease lyrics: %w", err)
}
if lyricsResp.LRC == nil || strings.TrimSpace(lyricsResp.LRC.Lyric) == "" {
return "", fmt.Errorf("no lyrics available on netease")
}
lyric := lyricsResp.LRC.Lyric
if includeTranslation && lyricsResp.TLyric != nil && strings.TrimSpace(lyricsResp.TLyric.Lyric) != "" {
lyric += "\n\n" + lyricsResp.TLyric.Lyric
}
if includeRomanization && lyricsResp.RomaLRC != nil && strings.TrimSpace(lyricsResp.RomaLRC.Lyric) != "" {
lyric += "\n\n" + lyricsResp.RomaLRC.Lyric
}
return lyric, nil
}
// FetchLyrics searches for a track and returns parsed LyricsResponse.
func (c *NeteaseClient) FetchLyrics(
trackName,
artistName string,
durationSec float64,
includeTranslation,
includeRomanization bool,
) (*LyricsResponse, error) {
songID, err := c.SearchSong(trackName, artistName)
if err != nil {
return nil, err
}
lrcText, err := c.FetchLyricsByID(songID, includeTranslation, includeRomanization)
if err != nil {
return nil, err
}
lines := parseSyncedLyrics(lrcText)
if len(lines) == 0 {
// May be plain text lyrics without timestamps
plainLines := strings.Split(lrcText, "\n")
for _, line := range plainLines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
lines = append(lines, LyricsLine{
StartTimeMs: 0,
Words: trimmed,
EndTimeMs: 0,
})
}
}
if len(lines) == 0 {
return nil, fmt.Errorf("netease returned empty lyrics")
}
return &LyricsResponse{
Lines: lines,
SyncType: "UNSYNCED",
Provider: "Netease",
Source: "Netease",
}, nil
}
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
Provider: "Netease",
Source: "Netease",
}, nil
}
+142
View File
@@ -0,0 +1,142 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"strings"
"time"
)
// QQMusicClient fetches lyrics from QQ Music.
// Uses Paxsenix metadata lookup for lyrics.
type QQMusicClient struct {
httpClient *http.Client
}
type qqLyricsMetadataRequest struct {
Artist []string `json:"artist"`
Album string `json:"album,omitempty"`
SongID int64 `json:"songid,omitempty"`
Title string `json:"title"`
Duration int64 `json:"duration,omitempty"`
}
type qqLyricsMetadataResponse struct {
Lyrics []paxLyrics `json:"lyrics"`
}
func NewQQMusicClient() *QQMusicClient {
return &QQMusicClient{
httpClient: NewMetadataHTTPClient(15 * time.Second),
}
}
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
payload := qqLyricsMetadataRequest{
Artist: []string{artistName},
Title: trackName,
}
if durationSec > 0 {
payload.Duration = int64(math.Round(durationSec))
}
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("qqmusic lyrics fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("qqmusic lyrics proxy returned HTTP %d", resp.StatusCode)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read lyrics response: %w", err)
}
bodyStr := strings.TrimSpace(string(bodyBytes))
if bodyStr == "" {
return "", fmt.Errorf("empty lyrics response from qqmusic")
}
return bodyStr, nil
}
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
var response qqLyricsMetadataResponse
if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
return "", fmt.Errorf("failed to parse qq metadata lyrics response")
}
if len(response.Lyrics) == 0 {
return "", fmt.Errorf("qq metadata lyrics response was empty")
}
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
}
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
func (c *QQMusicClient) FetchLyrics(
trackName,
artistName string,
durationSec float64,
multiPersonWordByWord bool,
) (*LyricsResponse, error) {
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
}
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
if err != nil {
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
lrcText = fallback
} else {
lrcText = rawLyrics
}
}
lines := parseSyncedLyrics(lrcText)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
Provider: "QQ Music",
Source: "QQ Music",
}, nil
}
resultLines := plainTextLyricsLines(lrcText)
if len(resultLines) > 0 {
return &LyricsResponse{
Lines: resultLines,
SyncType: "UNSYNCED",
Provider: "QQ Music",
Source: "QQ Music",
}, nil
}
return nil, fmt.Errorf("no lyrics found on qqmusic")
}
+895 -114
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
// This file ensures gomobile dependencies are not removed by go mod tidy.
// These packages are required by gomobile bind but not directly imported in code.
package gobackend
import (
_ "golang.org/x/mobile/bind"
)
+88
View File
@@ -0,0 +1,88 @@
package gobackend
import (
"fmt"
"os"
"strings"
)
func isFDOutput(outputFD int) bool {
return outputFD > 0
}
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
if isFDOutput(outputFD) {
// Never hand the original detached FD directly to a provider attempt.
// Fallback chains may retry with another provider after a failure.
// If the first attempt closes the original FD, its numeric ID can be
// reused by unrelated resources and a later close may trigger fdsan abort.
dupFD, err := dupOutputFD(outputFD)
if err != nil {
return nil, fmt.Errorf("failed to duplicate output fd %d: %w", outputFD, err)
}
if err := prepareDupFDForWrite(dupFD, outputFD); err != nil {
_ = closeFD(dupFD)
return nil, err
}
return os.NewFile(uintptr(dupFD), fmt.Sprintf("saf_fd_%d_dup_%d", outputFD, dupFD)), nil
}
path := strings.TrimSpace(outputPath)
if strings.HasPrefix(path, "/proc/self/fd/") {
// Re-open procfs fd path instead of taking ownership of raw detached fd.
// Some SAF providers reject O_TRUNC on these descriptors with EACCES/EPERM.
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0)
if err == nil {
return file, nil
}
if strings.Contains(strings.ToLower(err.Error()), "permission denied") {
return os.OpenFile(path, os.O_WRONLY, 0)
}
return nil, err
}
return os.Create(outputPath)
}
func prepareDupFDForWrite(dupFD, originalFD int) error {
// Best-effort reset so retries start writing from byte 0.
if err := truncateFD(dupFD); err != nil {
if isBestEffortTruncateError(err) {
GoLog("[OutputFD] truncate not supported on fd %d (dup of %d): %v\n", dupFD, originalFD, err)
} else {
return fmt.Errorf("failed to truncate output fd %d (dup of %d): %w", dupFD, originalFD, err)
}
}
if err := seekFDStart(dupFD); err != nil {
GoLog("[OutputFD] seek reset failed on fd %d (dup of %d): %v\n", dupFD, originalFD, err)
}
return nil
}
func closeOwnedOutputFD(outputFD int) {
if !isFDOutput(outputFD) {
return
}
if err := closeFD(outputFD); err != nil {
if !isBadFD(err) {
GoLog("[OutputFD] failed to close detached fd %d: %v\n", outputFD, err)
}
return
}
GoLog("[OutputFD] closed detached fd %d\n", outputFD)
}
func cleanupOutputOnError(outputPath string, outputFD int) {
if isFDOutput(outputFD) {
return
}
path := strings.TrimSpace(outputPath)
if path == "" || strings.HasPrefix(path, "/proc/self/fd/") {
return
}
_ = os.Remove(path)
}
+35
View File
@@ -0,0 +1,35 @@
//go:build !windows
package gobackend
import "syscall"
func dupOutputFD(fd int) (int, error) {
return syscall.Dup(fd)
}
func truncateFD(fd int) error {
return syscall.Ftruncate(fd, 0)
}
func seekFDStart(fd int) error {
_, err := syscall.Seek(fd, 0, 0)
return err
}
func closeFD(fd int) error {
return syscall.Close(fd)
}
func isBestEffortTruncateError(err error) bool {
switch err {
case syscall.EPERM, syscall.EACCES, syscall.EINVAL, syscall.ESPIPE, syscall.ENOSYS:
return true
default:
return false
}
}
func isBadFD(err error) bool {
return err == syscall.EBADF
}
+29
View File
@@ -0,0 +1,29 @@
//go:build windows
package gobackend
func dupOutputFD(fd int) (int, error) {
// Windows build is primarily for local tooling/tests.
// Android runtime uses the !windows implementation.
return fd, nil
}
func truncateFD(fd int) error {
return nil
}
func seekFDStart(fd int) error {
return nil
}
func closeFD(fd int) error {
return nil
}
func isBestEffortTruncateError(err error) bool {
return true
}
func isBadFD(err error) bool {
return false
}

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