Compare commits

...

154 Commits

Author SHA1 Message Date
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
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
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 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
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 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
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
zarzet ebe7d87da7 docs: update VirusTotal hash for v3.6.0 2026-02-10 01:03:58 +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
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
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
Zarz Eleutherius bf87662f99 New translations app_en.arb (Russian) 2026-02-06 18:33:40 +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
Zarz Eleutherius fb7a576e00 New translations app_en.arb (Turkish) 2026-02-04 12:53:12 +07:00
Zarz Eleutherius 30a559b279 New translations app_en.arb (Hindi) 2026-02-04 12:53:11 +07:00
Zarz Eleutherius f77d5fdf14 New translations app_en.arb (Indonesian) 2026-02-04 12:53:10 +07:00
Zarz Eleutherius 0a0667889c New translations app_en.arb (Chinese Traditional) 2026-02-04 12:53:09 +07:00
Zarz Eleutherius 14d8cd54d7 New translations app_en.arb (Chinese Simplified) 2026-02-04 12:53:08 +07:00
Zarz Eleutherius 5fa3d405e6 New translations app_en.arb (Russian) 2026-02-04 12:53:07 +07:00
Zarz Eleutherius 34eb335fd0 New translations app_en.arb (Portuguese) 2026-02-04 12:53:06 +07:00
Zarz Eleutherius c910530927 New translations app_en.arb (Dutch) 2026-02-04 12:53:05 +07:00
Zarz Eleutherius 69e1a6cf6b New translations app_en.arb (Korean) 2026-02-04 12:53:04 +07:00
Zarz Eleutherius bd84613624 New translations app_en.arb (Japanese) 2026-02-04 12:53:02 +07:00
Zarz Eleutherius 0b4777fc6b New translations app_en.arb (German) 2026-02-04 12:53:01 +07:00
Zarz Eleutherius e22813caec New translations app_en.arb (Spanish) 2026-02-04 12:53:00 +07:00
Zarz Eleutherius 8f6e8432de New translations app_en.arb (French) 2026-02-04 12:52:59 +07:00
Zarz Eleutherius b3c98cecc3 New translations app_en.arb (Turkish) 2026-02-02 08:25:49 +07:00
Zarz Eleutherius 49a18a977b New translations app_en.arb (Hindi) 2026-02-02 08:25:48 +07:00
Zarz Eleutherius a5d0feeedf New translations app_en.arb (Indonesian) 2026-02-02 08:25:47 +07:00
Zarz Eleutherius a574e73b44 New translations app_en.arb (Chinese Traditional) 2026-02-02 08:25:46 +07:00
Zarz Eleutherius a66f6a739f New translations app_en.arb (Chinese Simplified) 2026-02-02 08:25:45 +07:00
Zarz Eleutherius cc7e1b54b6 New translations app_en.arb (Russian) 2026-02-02 08:25:44 +07:00
Zarz Eleutherius 28cb7fcd3d New translations app_en.arb (Portuguese) 2026-02-02 08:25:43 +07:00
Zarz Eleutherius aeb370beca New translations app_en.arb (Dutch) 2026-02-02 08:25:42 +07:00
Zarz Eleutherius 239707e2da New translations app_en.arb (Korean) 2026-02-02 08:25:41 +07:00
Zarz Eleutherius c1e2778735 New translations app_en.arb (Japanese) 2026-02-02 08:25:40 +07:00
Zarz Eleutherius fb608a554d New translations app_en.arb (German) 2026-02-02 08:25:39 +07:00
Zarz Eleutherius 7561065802 New translations app_en.arb (Spanish) 2026-02-02 08:25:38 +07:00
Zarz Eleutherius 56c8d89999 New translations app_en.arb (French) 2026-02-02 08:25:37 +07:00
Zarz Eleutherius 9192760f3c Update source file app_en.arb 2026-02-02 08:25:35 +07:00
Zarz Eleutherius 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
157 changed files with 42966 additions and 7254 deletions
-1
View File
@@ -1,4 +1,3 @@
github: zarzet
ko_fi: zarzet
buy_me_a_coffee: zarzet
+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
+2 -2
View File
@@ -71,7 +71,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version: "1.26"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
@@ -174,7 +174,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version: "1.26"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
+263
View File
@@ -1,5 +1,268 @@
# Changelog
## [3.6.9] - 2026-02-17
### Added
- **YouTube Bitrate Presets**: YouTube bitrate selection now uses supported presets only
- Opus: 128 / 256 kbps
- MP3: 128 / 256 / 320 kbps
- **Go Test Coverage for YouTube Quality Parsing**: Added tests for supported-bitrate normalization behavior
- **Localization for YouTube Bitrate UI**: Added localized strings (EN/ID) for YouTube bitrate titles and labels
### Fixed
- **Cover Image Cache Clear Not Working**: Clearing "Cover image cache" now performs a full on-disk wipe, clears in-memory image cache, and reinitializes cache manager state
- Prevents stale/orphaned cache files from keeping the same storage usage after clear
- **YouTube Queue Fallback Quality Mismatch**: Queue fallback now normalizes YouTube quality IDs so conversion paths use valid bitrate format IDs
### Changed
- **Default Lyrics Behavior**: `Apple/QQ Multi-Person Word-by-Word` is now OFF by default for new installs
- **Removed Dynamic YouTube Bitrate Mode**: Arbitrary values are now normalized to nearest supported Spotube preset across settings, picker, queue fallback, and Go backend parser
- **Lyrics Embedding Control**: Users can now disable the embedded-lyrics process from settings (`Embed Lyrics` off)
---
## [3.6.8] - 2026-02-14
### Added
- **Lyrics Source Tracking**: Track Metadata screen now displays the source of loaded lyrics (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music, Embedded, or Extension)
- New `getLyricsLRCWithSource` API returns lyrics with source metadata
- Source badge appears below lyrics section in Track Metadata screen
- **Dedicated Lyrics Provider Priority Page**: Lyrics providers can now be configured from a dedicated settings page with full-screen reorderable list
- Replaced inline bottom sheet with `LyricsProviderPriorityPage`
- Cleaner UI with provider descriptions and priority ordering
- **Paxsenix Integration**: Added Paxsenix API as official lyrics proxy partner for Apple Music, QQ Music, Musixmatch, and Netease sources
- Listed in About page and Partners page on project site
- README updated with partner attribution
### Fixed
- **LRC Background Vocal Preservation**: Apple Music/QQ Music `[bg:...]` background vocal tags are now preserved during LRC parsing instead of being stripped
- Background vocals attach to the previous timed line in exported LRC files
- **LRC Display Improvements**:
- Inline word-by-word timestamps (`<mm:ss.xx>`) are stripped from lyrics display
- Speaker prefixes (`v1:`, `v2:`) are removed for cleaner display
- Multi-line background vocals converted to readable secondary vocal lines
- **Apple Music Lyrics Case Sensitivity**: Fixed `lyricsType` comparison to use case-insensitive matching for "Syllable" type
### Changed
- Track Metadata lyrics fetching now uses `getLyricsLRCWithSource` for consistent source attribution across embedded and online lyrics
---
## [3.6.7] - 2026-02-13
### Added
- "Advanced Filename Templates" - new placeholders for custom track/disc formatting and date patterns
- `{track_raw}` and `{disc_raw}` - unpadded raw numbers
- `{track:N}` and `{disc:N}` - zero-padded to N digits (e.g. `{track:02}``01`)
- `{date}` - full release date from metadata
- `{date:%Y-%m-%d}` - date formatting with strftime patterns
- "Show advanced tags" toggle in Settings > Download > Filename Format to reveal these placeholders
- Low-RAM / ARM32-only device profiling - detects constrained devices at startup and reduces image cache (120 items / 24 MiB) and disables overscroll effects for smoother performance
- Responsive selection bar on artist screen - switches to compact stacked layout on narrow screens (< 430dp) or large text scale (> 1.15x)
- Quality picker dialog before downloading individual tracks from artist screen (when "Ask quality before download" is enabled)
- Project website with GitHub Pages deployment workflow
- Mobile burger menu navigation for all site pages
- Go filename template test suite
- "Lyrics Provider" extension type - extensions can now provide lyrics (synced or plain text) via `fetchLyrics()` function
- Lyrics provider extensions are called before built-in providers, giving extensions highest priority
- New `lyrics_provider` manifest type alongside `metadata_provider` and `download_provider`
- Shows "Lyrics Provider" capability badge on extension detail page
- "Lyrics Providers" settings - configurable provider cascade order and per-provider options
- Reorderable provider list: LRCLIB, Musixmatch, Netease, Apple Music, QQ Music
- Netease: toggle translated/romanized lyrics appending
- Apple Music / QQ Music: multi-person word-by-word speaker tags
- Musixmatch: selectable language code for localized lyrics
- "Documentation Search" - global search modal on all site pages
- Opens with Ctrl+K / Cmd+K / `/` keyboard shortcuts on every page
- Search button with bordered pill styling in desktop nav and mobile hamburger menu
- On non-docs pages, search results navigate to the docs page at the matching section
- Full keyboard navigation: arrow keys, Enter to select, Esc to close
### Fixed
- Fixed ICU plural syntax errors in DE, ES, PT, RU translations - incorrect `=1` clause was causing missing plural forms
- Fixed featured-artist regex incorrectly splitting on `&` character (e.g. "Simon & Garfunkel" was being split) - removed `&` from separator pattern
- Fixed `{date}` placeholder not working in filename templates - release date was not being passed to the template builder across all providers (Amazon, Qobuz, Tidal, YouTube, extensions)
### Changed
- Improved Go backend metadata handling - filename builder now supports fallback metadata keys and automatic type conversion for more robust template rendering
- Extension providers now pass full metadata set to filename builder (track, disc, year, date, release_date)
- Updated translations: added filename advanced tags strings (EN, ID), regenerated all locale dart files
- Updated app screenshot assets
---
## [3.6.6] - 2026-02-12
### Added
- "Filter Contributing Artists in Album Artist" setting - strips featured/contributing artists from Album Artist metadata tag
- Library scan notifications (Android and iOS) - shows progress, completion, failure, and cancellation status
- Collapsible "Artist Name Filters" section in download settings UI
### Fixed
- Fixed downloads not working on iOS - missing `downloadByStrategy` and `downloadFromYouTube` method channel handlers in AppDelegate.swift
- Fixed extended metadata (genre, label, copyright) lost during service fallback (e.g. Tidal unavailable, falls back to Qobuz) - Go backend now enriches metadata from Deezer by ISRC before download and preserves it through the fallback chain
- Fixed local library showing incorrect "16-bit" quality label for lossy formats (MP3, Opus) - now displays actual bitrate (e.g. "MP3 320kbps")
- Fixed inaccurate Opus/Vorbis duration calculation (e.g. 4:11 showing as 8:44) - now reads granule position from last Ogg page for precise duration
- Fixed MP3 duration/bitrate inaccuracy for VBR files - added Xing/Info and VBRI header parsing with MPEG2/2.5 bitrate table support
- Fixed Track Metadata screen showing scan date instead of file date for local library items
- Fixed SAF content URI paths displayed as raw `content://` strings in Track Metadata - now shows human-readable paths
### Changed
- Removed legacy iOS download handlers (`downloadTrack`, `downloadWithFallback`, `downloadFromYouTube`) - iOS now uses `downloadByStrategy` only
- Updated translations from Crowdin (all 14 languages)
---
## [3.6.5] - 2026-02-10
### Highlights
- **Audio Format Conversion**: Convert between FLAC, MP3, and Opus directly from Track Metadata screen with full metadata and cover art preservation
- **PC v7.0.8 Backend Merge**: Adopts several Go backend improvements from SpotiFLAC PC v7.0.8 including Amazon encrypted stream support, SpotFetch metadata fallback, and Qobuz API update
- **Amazon Music Re-enabled**: Amazon provider back in service with new API
### Added
- "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization
- Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x`
- Available in Settings > Download > below "Use Album Artist for folders"
- Audio format conversion from Track Metadata screen
- Convert between FLAC, MP3, and Opus formats (any direction)
- Selectable bitrate: 128k, 192k, 256k, 320k
- Full metadata and cover art preservation during conversion
- Confirmation dialog before converting (original file deleted after)
- SAF storage support: copies to temp, converts, writes back via SAF
- Download history automatically updated with new file path
- Unified download request contract (`DownloadRequestPayload`) for all providers/flows
- Includes full superset fields: lyrics mode, genre/label/copyright, provider IDs, SAF params, cover/quality settings
- Added strategy flags in payload: `use_extensions`, `use_fallback`
- New Go unified router entrypoint: `DownloadByStrategy(requestJSON)`
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
- SpotFetch metadata fallback integration for Spotify-blocked regions
- New backend client for `spotify.afkarxyz.fun/api`
- Automatic fallback in Spotify metadata fetch path when primary source fails
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
- Includes heuristic detection of lyrics stored in Comment fields
- Edit Metadata now supports manual cover selection (pick/replace cover image) and embeds it into audio tags on save
- Save Lyrics now shows an immediate in-progress snackbar (`Saving lyrics...`) so users know the operation has started
### Changed
- Merged several Go backend improvements from SpotiFLAC PC v7.0.8: Amazon new API with encrypted stream/decryption support, SpotFetch metadata fallback for Spotify-blocked regions, multi-format lyrics extraction (MP3/Opus/OGG), Qobuz Jumo API update.
- Download queue execution now builds one payload and uses a single bridge entrypoint (`PlatformBridge.downloadByStrategy`) instead of branching into multiple bridge methods
- Dart `downloadByStrategy` now sends a single request to Go (`downloadByStrategy` channel); routing concern is centralized in Go backend
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
- Edit Metadata cover section moved to the top of the form and now previews current embedded cover before replacement (plus selected replacement preview)
- Edit Metadata cover preview enlarged (120px to 160px) with shadow, side-by-side layout for current vs selected cover, and label repositioned below image
### Fixed
- Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed"
- Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters
- Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup
- Track Metadata lyrics section now hides "Embed Lyrics" when lyrics are already embedded in file, preventing redundant embed attempts
- Fixed lyrics embed path to support FLAC/MP3/Opus consistently (including SAF files) without forcing unsupported parser paths
- Inconsistent parameter parity across download paths
- `downloadWithExtensions` now carries `copyright`
- YouTube path now carries `embed_max_quality_cover` and metadata parity fields
- Inconsistent success response metadata between direct/fallback flows
- Added shared Go response builder for `DownloadTrack` and `DownloadWithFallback`
- Success responses now consistently include `genre`, `label`, `copyright`, and `lyrics_lrc`
- YouTube success response now also includes extended metadata fields (`cover_url`, `genre`, `label`, `copyright`) for parity with other providers
- Fixed `Save Lyrics` crash on Android (`java.lang.Integer cannot be cast to java.lang.Long`) by normalizing `duration_ms` channel argument as `Number -> Long`
- Fixed FLAC Re-enrich cover edge case where metadata could be written without cover when temp cover file creation failed; FLAC cover embed now uses in-memory bytes and verifies cover after write
- Fixed FLAC picture-block embed robustness by detecting image MIME via magic bytes (JPEG/PNG/GIF/WEBP) instead of relying on filename extension
- Fixed MP3/Opus metadata rewrite flows to preserve existing embedded cover when no new cover is available
- Fixed Library tab cover not updating after manual cover edit/re-embed for downloaded tracks
- Queue/Library now prefers embedded cover art extracted from local files (not just cached `coverUrl`)
- Added per-track extraction cache with file-modification invalidation so updated embedded art is reflected in Library
- Extraction is now on-demand for edited tracks only (not full-library reload)
- Returning from Track Metadata now refreshes cover cache only for the affected track
- Cover refresh is now skipped when file modification time is unchanged, removing unnecessary flash when simply opening/closing metadata screen
- Fixed repeated cover preview extraction in Track Metadata screen (`track_cover_preview_*`) causing visible flash when reopening
- Added in-memory preview cache keyed by file path so reopening metadata reuses existing preview without re-extract
- Cache validation uses file modification time for filesystem paths; SAF paths are refreshed only after successful edit actions
- Queue/Library now also compares SAF file last-modified (`getSafFileModTimes`) before refreshing embedded-cover cache
- Preview cache key is now stable per track item (not volatile temp SAF path), eliminating false cache misses on SAF-backed files
- Track Metadata no longer auto-extracts cover preview on every screen open; extraction now runs only after actual edit/re-enrich changes (or when explicitly forced)
- Track metadata edits/re-enrich now sync updated tags back into `downloadHistoryProvider` + SQLite history rows
- Non-Library screens that read download history (Home/album/history views) now reflect updated title/artist/album/tags without manual rescan
- Track Metadata back-navigation now returns an explicit update result after successful edits/re-enrich, enabling History-tab cover refresh fallback when SAF timestamps are unreliable
### Performance
- Configured Flutter image cache limits (240 entries / 60 MiB) and added `ResizeImage` wrappers for cover art precaching across all screens, reducing peak memory usage on cover-heavy pages
- Added LRU eviction to Deezer cache with configurable max entries per cache type (search/album/artist/ISRC) and periodic expired-entry cleanup to prevent unbounded memory growth in long sessions
- Download progress notifications are now normalized (2-decimal progress, 1-decimal speed, 0.1 MiB byte steps) and deduplicated by track/artist/percent/queue-count, reducing notification overhead during batch downloads
- Each queue item now uses a dedicated `ConsumerWidget` with per-item `.select()` instead of rebuilding the entire list on any item change; items are wrapped in `RepaintBoundary` for paint isolation
- Queue/Library search indexes are now built on-demand per item instead of upfront for all items, with bounded LRU caches (max 4000 entries)
- `copyWith` now preserves derived lookup indexes (ISRC map, track key set) when items list is unchanged, avoiding O(n) rebuild on every scan progress update
- Scan progress polling now compares values before calling `setState`, skipping unnecessary widget rebuilds when nothing changed
- Added in-flight flag to download progress and library scan polling to prevent concurrent timer callbacks from overlapping
- New `DownloadedEmbeddedCoverResolver` service replaces per-screen cover extraction logic with a shared bounded cache (160 entries), mod-time validation, and throttled refresh checks
- Multiple embedded cover change callbacks are now coalesced into a single frame via `addPostFrameCallback`, preventing redundant rebuilds
- Downloaded album screen now caches filtered/sorted track lists and reuses them when the source data reference is unchanged
- Home tab recent downloads now use single-pass aggregation instead of building full per-album lists, and store only IDs instead of full item objects for the clear-all action
- Removed duplicate `_downloadedSpotifyIds` Set and `_isrcSet` (both now use existing map lookups), removed unused `_isTyping` state in home tab
- Track cache pre-warming is now capped at 80 tracks per request to avoid excessive backend calls on large playlists
- About page contributor avatars now use `memCacheWidth`/`memCacheHeight` to decode at display size instead of full resolution
- Orphaned download cleanup now checks file existence in parallel (chunk 16) instead of sequentially
- Local library `findByTrackAndArtist` now uses O(1) map lookup (`_byTrackKey`) instead of O(n) linear scan
- Local library database load and SharedPreferences fetch now run in parallel
- Legacy mod-time backfill now uses chunked parallel `File.stat` (chunk 24) with per-chunk cancel check
- Downloaded album screen now caches disc grouping, sorted disc numbers, common quality, and embedded cover path with reference-identity invalidation
- Local album screen common quality is now computed once during cache rebuild instead of per-build
- Batch delete in album screens now uses O(1) map lookup (`tracksById`) instead of `.where().firstOrNull`
- Cache management page now fires all async init calls in parallel and uses chunked async directory deletion (chunk 24)
- Cover resolver preview file existence check is now throttled (2.2s interval) to reduce synchronous I/O in build path
- History and library database DELETE operations are now chunked (500 per batch) to stay within SQLite variable limits
- Library database `cleanupMissingFiles` now checks file existence in parallel (chunk 16) and deletes in batched SQL
### Security
- All logs (Go and Dart) now automatically redact Bearer tokens, access/refresh tokens, client secrets, API keys, and passwords using regex-based sanitization before storage
- Extension auth URLs are now validated for HTTPS-only, no embedded credentials, and no private/local network targets before opening
- Auth URLs in logs are summarized to scheme+host+path only (query params stripped) to prevent token leakage; token exchange error bodies are truncated and sanitized
- Extension HTTP requests now block URLs with embedded credentials (`user:pass@host`)
- Extension storage files changed from `0644` to `0600` (owner-only read/write)
- All SAF relative directory paths are now sanitized per-segment with `.`/`..` filtering; all user-provided file names pass through `sanitizeFilename()` before use
- Extension ID is sanitized before building download destination path
- Log export device info now shows Build ID and Security Patch level instead of masked Device ID
### Technical
- Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model
- Go strategy router normalizes incoming service casing before dispatch
- Extension runtime: `customSearch` now passes query/options via VM globals instead of string interpolation, preventing parser edge cases on certain devices
- Extension runtime: JS panic handler now logs full stack trace for easier debugging
- `DownloadQueueLookup` expanded with `byItemId` map and `itemIds` list for O(1) queue item access from UI
- Non-error/non-fatal log entries are now skipped entirely (not just hidden) when detailed logging is disabled, reducing buffer growth and Go log polling overhead
### Removed
- Buy Me a Coffee references removed from donate page, FUNDING.yml, README, and all localization files (account suspended)
---
## [3.6.0] - 2026-02-09
### Highlights
+4 -4
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/dec9c96672ab80e6bf6b7a66786e612f5404446c341eb0311b4cc78fe10c96a1)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center">
@@ -71,9 +71,9 @@ A: Some countries have restricted access to certain streaming service APIs. If d
### Want to support SpotiFLAC-Mobile?
_If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going._
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet) <a href="https://www.buymeacoffee.com/zarzet" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;" ></a>
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
## Disclaimer
@@ -97,7 +97,7 @@ The software is provided "as is", without warranty of any kind. The author assum
- **Tidal**: [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)
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
- **Lyrics**: [LRCLib](https://lrclib.net)
- **Lyrics**: [LRCLib](https://lrclib.net), [Paxsenix](https://lyrics.paxsenix.org) (Apple Music/QQ Music lyrics proxy)
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
@@ -33,6 +33,7 @@ class MainActivity: FlutterFragmentActivity() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
private val safDirLock = Any()
private var safScanProgress = SafScanProgress()
@Volatile private var safScanCancel = false
@Volatile private var safScanActive = false
@@ -299,27 +300,55 @@ class MainActivity: FlutterFragmentActivity() {
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
}
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
if (relativeDir.isBlank()) return current
private fun sanitizeRelativeDir(relativeDir: String): String {
if (relativeDir.isBlank()) return ""
return relativeDir
.split("/")
.map { sanitizeFilename(it) }
.filter { it.isNotBlank() && it != "." && it != ".." }
.joinToString("/")
}
val parts = relativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
current = if (existing != null && existing.isDirectory) {
existing
} else {
current.createDirectory(part) ?: return null
}
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
if (safeRelativeDir.isBlank()) {
return DocumentFile.fromTreeUri(this, treeUri)
}
// Synchronize to prevent concurrent downloads from creating duplicate
// directories with (1), (2) suffixes via SAF's auto-rename behavior.
synchronized(safDirLock) {
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
current = if (existing != null && existing.isDirectory) {
existing
} else {
val created = current.createDirectory(part) ?: return null
// SAF may auto-rename to "part (1)" if another thread just created it.
// Re-check: if the created name differs, delete it and use the original.
val createdName = created.name ?: part
if (createdName != part) {
// Another thread won the race; delete the duplicate and use theirs.
created.delete()
current.findFile(part) ?: return null
} else {
created
}
}
}
return current
}
return current
}
private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
if (relativeDir.isBlank()) return current
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
if (safeRelativeDir.isBlank()) return current
val parts = relativeDir.split("/").filter { it.isNotBlank() }
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
if (existing == null || !existing.isDirectory) return null
@@ -359,14 +388,21 @@ class MainActivity: FlutterFragmentActivity() {
obj.put("relative_dir", "")
return obj.toString()
}
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
val safeFileName = sanitizeFilename(fileName)
if (safeFileName.isBlank()) {
obj.put("uri", "")
obj.put("relative_dir", "")
return obj.toString()
}
val treeUri = Uri.parse(treeUriStr)
val targetDir = findDocumentDir(treeUri, relativeDir)
val targetDir = findDocumentDir(treeUri, safeRelativeDir)
if (targetDir != null) {
val direct = targetDir.findFile(fileName)
val direct = targetDir.findFile(safeFileName)
if (direct != null && direct.isFile) {
obj.put("uri", direct.uri.toString())
obj.put("relative_dir", relativeDir)
obj.put("relative_dir", safeRelativeDir)
return obj.toString()
}
}
@@ -392,7 +428,7 @@ class MainActivity: FlutterFragmentActivity() {
val childPath = if (path.isBlank()) childName else "$path/$childName"
queue.add(child to childPath)
} else if (child.isFile) {
if (child.name == fileName) {
if (child.name == safeFileName) {
obj.put("uri", child.uri.toString())
obj.put("relative_dir", path)
return obj.toString()
@@ -408,7 +444,7 @@ class MainActivity: FlutterFragmentActivity() {
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return provided
if (provided.isNotBlank()) return sanitizeFilename(provided)
val trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "")
@@ -599,7 +635,7 @@ class MainActivity: FlutterFragmentActivity() {
}
val treeUri = Uri.parse(treeUriStr)
val relativeDir = req.optString("saf_relative_dir", "")
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt)
@@ -1276,20 +1312,11 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"downloadTrack" -> {
"downloadByStrategy" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
handleSafDownload(requestJson) { json ->
Gobackend.downloadTrack(json)
}
}
result.success(response)
}
"downloadWithFallback" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
handleSafDownload(requestJson) { json ->
Gobackend.downloadWithFallback(json)
Gobackend.downloadByStrategy(json)
}
}
result.success(response)
@@ -1465,11 +1492,12 @@ class MainActivity: FlutterFragmentActivity() {
"safCreateFromPath" -> {
val treeUriStr = call.argument<String>("tree_uri") ?: ""
val relativeDir = call.argument<String>("relative_dir") ?: ""
val fileName = call.argument<String>("file_name") ?: ""
val fileName = sanitizeFilename(call.argument<String>("file_name") ?: "")
val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
val srcPath = call.argument<String>("src_path") ?: ""
val createdUri = withContext(Dispatchers.IO) {
if (treeUriStr.isBlank()) return@withContext null
if (fileName.isBlank()) return@withContext null
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
val existing = dir.findFile(fileName)
val createdNew = existing == null
@@ -1554,6 +1582,32 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getLyricsLRCWithSource" -> {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val filePath = call.argument<String>("file_path") ?: ""
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) {
if (filePath.startsWith("content://")) {
val tempPath = copyUriToTemp(Uri.parse(filePath))
if (tempPath == null) {
"""{"lyrics":"","source":"","sync_type":"","instrumental":false}"""
} else {
try {
Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, tempPath, durationMs)
} finally {
try {
File(tempPath).delete()
} catch (_: Exception) {}
}
}
} else {
Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs)
}
}
result.success(response)
}
"embedLyricsToFile" -> {
val filePath = call.argument<String>("file_path") ?: ""
val lyrics = call.argument<String>("lyrics") ?: ""
@@ -1716,7 +1770,7 @@ class MainActivity: FlutterFragmentActivity() {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
val durationMs = call.argument<Long>("duration_ms") ?: 0L
val durationMs = call.argument<Number>("duration_ms")?.toLong() ?: 0L
val outputPath = call.argument<String>("output_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
@@ -1728,6 +1782,60 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"setLyricsProviders" -> {
val providersJson = call.argument<String>("providers_json") ?: "[]"
val response = withContext(Dispatchers.IO) {
try {
Gobackend.setLyricsProvidersJSON(providersJson)
"""{"success":true}"""
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"getLyricsProviders" -> {
val response = withContext(Dispatchers.IO) {
try {
Gobackend.getLyricsProvidersJSON()
} catch (e: Exception) {
"[]"
}
}
result.success(response)
}
"getAvailableLyricsProviders" -> {
val response = withContext(Dispatchers.IO) {
try {
Gobackend.getAvailableLyricsProvidersJSON()
} catch (e: Exception) {
"[]"
}
}
result.success(response)
}
"setLyricsFetchOptions" -> {
val optionsJson = call.argument<String>("options_json") ?: "{}"
val response = withContext(Dispatchers.IO) {
try {
Gobackend.setLyricsFetchOptionsJSON(optionsJson)
"""{"success":true}"""
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"getLyricsFetchOptions" -> {
val response = withContext(Dispatchers.IO) {
try {
Gobackend.getLyricsFetchOptionsJSON()
} catch (e: Exception) {
"{}"
}
}
result.success(response)
}
"reEnrichFile" -> {
val requestJson = call.argument<String>("request_json") ?: "{}"
val response = withContext(Dispatchers.IO) {
@@ -2093,24 +2201,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"downloadWithExtensions" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
handleSafDownload(requestJson) { json ->
Gobackend.downloadWithExtensionsJSON(json)
}
}
result.success(response)
}
"downloadFromYouTube" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
handleSafDownload(requestJson) { json ->
Gobackend.downloadFromYouTube(json)
}
}
result.success(response)
}
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 B

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 B

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 122 KiB

+232 -81
View File
@@ -31,6 +31,8 @@ type AmazonDownloader struct {
var (
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
)
// AfkarXYZResponse is the response from AfkarXYZ API
@@ -43,6 +45,12 @@ type AfkarXYZResponse struct {
} `json:"data"`
}
// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin}
type AmazonStreamResponse struct {
StreamURL string `json:"streamUrl"`
DecryptionKey string `json:"decryptionKey"`
}
func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
@@ -52,10 +60,9 @@ func NewAmazonDownloader() *AmazonDownloader {
return globalAmazonDownloader
}
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) {
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
// Returns downloadURL, suggested fileName, optional decryptionKey.
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
var lastErr error
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
if attempt > 0 {
@@ -64,66 +71,184 @@ func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, st
time.Sleep(delay)
}
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL)
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
if err == nil {
return downloadURL, fileName, nil
return downloadURL, fileName, decryptionKey, nil
}
lastErr = err
errStr := err.Error()
errStr := strings.ToLower(err.Error())
// Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "eof") ||
strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429")
strings.Contains(errStr, "status 429") ||
strings.Contains(errStr, "http 429")
if !isRetryable {
return "", "", err
return "", "", "", err
}
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
}
return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
}
// doAfkarXYZRequest performs a single request to AfkarXYZ API
func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) {
func normalizeAmazonASIN(candidate string) string {
trimmed := strings.TrimSpace(candidate)
if trimmed == "" {
return ""
}
if decoded, err := url.QueryUnescape(trimmed); err == nil {
trimmed = decoded
}
trimmed = strings.ToUpper(trimmed)
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
trimmed = trimmed[:idx]
}
if amazonASINRegex.MatchString(trimmed) {
return trimmed
}
return ""
}
func extractAmazonASIN(amazonURL string) string {
raw := strings.TrimSpace(amazonURL)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err == nil {
query := parsed.Query()
// Prefer track-level ASIN when URL also contains albumAsin.
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
return asin
}
}
path := strings.Trim(parsed.Path, "/")
if path != "" {
segments := strings.Split(path, "/")
for i := 0; i < len(segments)-1; i++ {
segment := strings.ToLower(strings.TrimSpace(segments[i]))
if segment == "track" || segment == "tracks" {
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
return asin
}
}
}
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
return asin
}
}
}
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
return normalizeAmazonASIN(match)
}
// doAfkarXYZRequest performs a single request to Amazon API.
// It tries new endpoint first, then falls back to legacy /convert endpoint.
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
asin := extractAmazonASIN(amazonURL)
if asin != "" {
GoLog("[Amazon] Using ASIN: %s\n", asin)
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
if err == nil {
return downloadURL, fileName, decryptKey, nil
}
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
}
return a.doAfkarXYZRequestLegacy(amazonURL)
}
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
defer cancel()
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", "", fmt.Errorf("failed to create request: %w", err)
return "", "", "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
resp, err := a.client.Do(req)
if err != nil {
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", fmt.Errorf("failed to read response: %w", err)
}
var apiResp AmazonStreamResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
}
if strings.TrimSpace(apiResp.StreamURL) == "" {
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
}
fileName := asin + ".m4a"
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
}
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
defer cancel()
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := a.client.Do(req)
if err != nil {
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("failed to read response: %w", err)
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
}
var apiResp AfkarXYZResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", "", fmt.Errorf("failed to decode response: %w", err)
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
}
if !apiResp.Success || apiResp.Data.DirectLink == "" {
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
}
fileName := apiResp.Data.FileName
@@ -134,19 +259,22 @@ func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, err
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
return apiResp.Data.DirectLink, fileName, nil
return apiResp.Data.DirectLink, fileName, "", nil
}
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL)
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
if err != nil {
return "", "", err
return "", "", "", err
}
if decryptionKey != "" {
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
}
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
return downloadURL, fileName, nil
return downloadURL, fileName, decryptionKey, nil
}
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
@@ -233,17 +361,18 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD
// AmazonDownloadResult contains download result with quality info
type AmazonDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
DecryptionKey string
}
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
@@ -299,7 +428,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
// Download using AfkarXYZ API
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL)
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
}
@@ -312,6 +441,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
var outputPath string
@@ -321,7 +451,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
filename = sanitizeFilename(filename) + ".flac"
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
if outputExt == "" {
outputExt = ".flac"
}
filename = sanitizeFilename(filename) + outputExt
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
@@ -352,6 +486,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
actualOutputPath := outputPath
needsDecryption := strings.TrimSpace(decryptionKey) != ""
if needsDecryption {
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
}
// Wait for parallel operations to complete
<-parallelDone
@@ -360,7 +500,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
SetItemFinalizing(req.ItemID)
}
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
actualDate := req.ReleaseDate
@@ -368,25 +507,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
actualTitle := req.TrackName
actualArtist := req.ArtistName
if metaErr == nil && existingMeta != nil {
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
if !needsDecryption {
existingMeta, metaErr := ReadMetadata(actualOutputPath)
if metaErr == nil && existingMeta != nil {
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
}
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
actualDiscNum = existingMeta.DiscNumber
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
}
if existingMeta.Date != "" && req.ReleaseDate == "" {
actualDate = existingMeta.Date
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
}
if existingMeta.Album != "" && req.AlbumName == "" {
actualAlbum = existingMeta.Album
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
}
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
}
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
actualDiscNum = existingMeta.DiscNumber
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
}
if existingMeta.Date != "" && req.ReleaseDate == "" {
actualDate = existingMeta.Date
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
}
if existingMeta.Album != "" && req.AlbumName == "" {
actualAlbum = existingMeta.Album
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
}
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
}
metadata := Metadata{
@@ -409,7 +551,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
coverData = parallelResult.CoverData
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} else {
existingCover, coverErr := ExtractCoverArt(outputPath)
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
if coverErr == nil && len(existingCover) > 0 {
coverData = existingCover
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
@@ -418,11 +560,16 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
}
if isSafOutput {
if isSafOutput || needsDecryption {
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
if isFlacOutput {
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
}
} else {
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
@@ -433,20 +580,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
GoLog("[Amazon] Lyrics embedded successfully\n")
}
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
}
} else if req.EmbedLyrics {
GoLog("[Amazon] No lyrics available from parallel fetch\n")
@@ -456,17 +605,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
quality := AudioQuality{}
if isSafOutput {
if isSafOutput || needsDecryption {
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
} else {
quality, err = GetAudioQuality(outputPath)
quality, err = GetAudioQuality(actualOutputPath)
if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
} else {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
finalMeta, metaReadErr := ReadMetadata(outputPath)
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
@@ -478,9 +627,10 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
}
// Add to ISRC index for fast duplicate checking
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
// Add to ISRC index for fast duplicate checking.
// When decryption is pending in Flutter, postpone indexing until final file is settled.
if !isSafOutput && !needsDecryption {
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
}
bitDepth := 0
@@ -496,16 +646,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
return AmazonDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: actualTrackNum,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: actualTrackNum,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
DecryptionKey: decryptionKey,
}, nil
}
+46
View File
@@ -0,0 +1,46 @@
package gobackend
import "testing"
func TestExtractAmazonASIN(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{
name: "prefers trackAsin over albumAsin",
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
want: "B0TRACK456",
},
{
name: "extract from tracks path",
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
want: "B0CYQHGWZJ",
},
{
name: "extract from plain query asin",
url: "https://example.com/?asin=B0CYQHGWZJ",
want: "B0CYQHGWZJ",
},
{
name: "fallback regex",
url: "https://example.com/path/B0CYQHGWZJ",
want: "B0CYQHGWZJ",
},
{
name: "invalid url",
url: "https://music.amazon.com/tracks/not-valid",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractAmazonASIN(tt.url)
if got != tt.want {
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
}
})
}
}
+313 -45
View File
@@ -23,6 +23,7 @@ type AudioMetadata struct {
TrackNumber int
DiscNumber int
ISRC string
Lyrics string
Label string
Copyright string
Composer string
@@ -42,6 +43,7 @@ type OggQuality struct {
SampleRate int
BitDepth int
Duration int
Bitrate int // estimated bitrate in bps
}
// =============================================================================
@@ -181,6 +183,15 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
metadata.Label = value
case "TCR":
metadata.Copyright = value
case "ULT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXX":
desc, userValue := extractUserTextFrame(frameData)
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
metadata.Lyrics = userValue
}
}
pos += 6 + frameSize
@@ -297,6 +308,15 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
if v := extractCommentFrame(frameData); v != "" {
metadata.Comment = v
}
case "USLT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXXX":
desc, userValue := extractUserTextFrame(frameData)
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
metadata.Lyrics = userValue
}
}
pos += 10 + frameSize
@@ -399,6 +419,98 @@ func extractCommentFrame(data []byte) string {
return extractTextFrame(framed)
}
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
func extractLyricsFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
rest := data[4:] // skip 3-byte language code
var text []byte
switch encoding {
case 1, 2: // UTF-16 variants use double-null terminator
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:]
break
}
}
default: // ISO-8859-1 or UTF-8
idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:]
} else {
text = rest
}
}
if len(text) == 0 {
return ""
}
framed := make([]byte, 1+len(text))
framed[0] = encoding
copy(framed[1:], text)
return extractTextFrame(framed)
}
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
// encoding(1) + description + separator + value.
func extractUserTextFrame(data []byte) (string, string) {
if len(data) < 2 {
return "", ""
}
encoding := data[0]
payload := data[1:]
var descRaw, valueRaw []byte
switch encoding {
case 1, 2: // UTF-16 variants
for i := 0; i+1 < len(payload); i += 2 {
if payload[i] == 0 && payload[i+1] == 0 {
descRaw = payload[:i]
valueRaw = payload[i+2:]
break
}
}
default: // ISO-8859-1 or UTF-8
idx := bytes.IndexByte(payload, 0)
if idx >= 0 {
descRaw = payload[:idx]
if idx+1 <= len(payload) {
valueRaw = payload[idx+1:]
}
}
}
if len(valueRaw) == 0 {
return "", ""
}
descFramed := make([]byte, 1+len(descRaw))
descFramed[0] = encoding
copy(descFramed[1:], descRaw)
valueFramed := make([]byte, 1+len(valueRaw))
valueFramed[0] = encoding
copy(valueFramed[1:], valueRaw)
return strings.TrimSpace(extractTextFrame(descFramed)), strings.TrimSpace(extractTextFrame(valueFramed))
}
func isLyricsDescription(description string) bool {
switch strings.ToLower(strings.TrimSpace(description)) {
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
return true
default:
return false
}
}
func decodeUTF16(data []byte) string {
if len(data) < 2 {
return ""
@@ -553,50 +665,144 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
file.Seek(audioStart, io.SeekStart)
// Find first valid MP3 frame sync
frameHeader := make([]byte, 4)
for i := 0; i < 10000; i++ { // Search first 10KB
var frameStart int64 = -1
for i := 0; i < 10000; i++ {
if _, err := io.ReadFull(file, frameHeader); err != nil {
break
}
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
version := (frameHeader[1] >> 3) & 0x03
layer := (frameHeader[1] >> 1) & 0x03
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
sampleRates := [][]int{
{11025, 12000, 8000},
{0, 0, 0},
{22050, 24000, 16000},
{44100, 48000, 32000},
}
if version < 4 && sampleRateIdx < 3 {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
quality.BitDepth = 16
if quality.Bitrate > 0 {
audioSize := fileSize - audioStart - 128
if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
}
}
pos, _ := file.Seek(0, io.SeekCurrent)
frameStart = pos - 4
break
}
file.Seek(-3, io.SeekCurrent)
}
if frameStart < 0 {
return quality, nil
}
version := (frameHeader[1] >> 3) & 0x03
layer := (frameHeader[1] >> 1) & 0x03
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
channelMode := (frameHeader[3] >> 6) & 0x03
// Sample rate tables: [version][index]
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
sampleRates := [][]int{
{11025, 12000, 8000},
{0, 0, 0},
{22050, 24000, 16000},
{44100, 48000, 32000},
}
if version < 4 && sampleRateIdx < 3 {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
// Bitrate tables for all MPEG versions and layers
// MPEG1 Layer III
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
// MPEG2/2.5 Layer III
if (version == 0 || version == 2) && layer == 1 {
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
// Determine samples per frame for duration calculation
samplesPerFrame := 1152 // MPEG1 Layer III
if version == 0 || version == 2 {
samplesPerFrame = 576 // MPEG2/2.5 Layer III
}
// Try to read Xing/VBRI header from the first frame for VBR info
// Xing header offset depends on MPEG version and channel mode
var xingOffset int
if version == 3 { // MPEG1
if channelMode == 3 { // Mono
xingOffset = 17
} else {
xingOffset = 32
}
} else { // MPEG2/2.5
if channelMode == 3 {
xingOffset = 9
} else {
xingOffset = 17
}
}
// Read enough of the first frame to find Xing/VBRI header
xingBuf := make([]byte, 200)
file.Seek(frameStart+4, io.SeekStart)
n, _ := io.ReadFull(file, xingBuf)
xingBuf = xingBuf[:n]
vbrFrames := 0
vbrBytes := int64(0)
isVBR := false
// Check for Xing/Info header
if xingOffset+8 <= n {
tag := string(xingBuf[xingOffset : xingOffset+4])
if tag == "Xing" || tag == "Info" {
flags := binary.BigEndian.Uint32(xingBuf[xingOffset+4 : xingOffset+8])
off := xingOffset + 8
if flags&0x01 != 0 && off+4 <= n { // Frames flag
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[off : off+4]))
off += 4
}
if flags&0x02 != 0 && off+4 <= n { // Bytes flag
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[off : off+4]))
}
if vbrFrames > 0 {
isVBR = true
}
}
}
// Check for VBRI header (always at offset 32 from frame start + 4)
if !isVBR && 36+26 <= n {
if string(xingBuf[32:36]) == "VBRI" {
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[36+10 : 36+14]))
if vbrFrames > 0 {
isVBR = true
}
}
}
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
// Accurate duration from total frames
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
quality.Duration = int(totalSamples / int64(quality.SampleRate))
// Accurate average bitrate
if vbrBytes > 0 && quality.Duration > 0 {
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
} else if quality.Duration > 0 {
audioSize := fileSize - audioStart
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
}
} else if quality.Bitrate > 0 {
// CBR fallback: estimate duration from file size and frame bitrate
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
}
}
return quality, nil
}
@@ -800,9 +1006,16 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
break
}
if commentLen > 10000 {
remaining := uint32(reader.Len())
if commentLen > remaining {
break
}
// Large comment entries are typically METADATA_BLOCK_PICTURE.
// Skip them so we can continue parsing normal text tags after/before.
if commentLen > 512*1024 {
reader.Seek(int64(commentLen), io.SeekCurrent)
continue
}
comment := make([]byte, commentLen)
if _, err := reader.Read(comment); err != nil {
@@ -843,6 +1056,10 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
metadata.Composer = value
case "COMMENT", "DESCRIPTION":
metadata.Comment = value
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
@@ -859,7 +1076,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
defer file.Close()
quality := &OggQuality{}
isOpus := false
packets, err := collectOggPackets(file, 5, 10)
if err != nil && len(packets) == 0 {
@@ -875,15 +1091,17 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
}
}
if streamType == oggStreamOpus {
isOpus = true
isOpus := streamType == oggStreamOpus
var preSkip int
if isOpus {
for _, pkt := range packets {
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
if quality.SampleRate == 0 {
quality.SampleRate = 48000
}
quality.BitDepth = 16
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
break
}
}
@@ -891,26 +1109,76 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
for _, pkt := range packets {
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
quality.BitDepth = 16
break
}
}
}
// Read granule position from the last Ogg page for accurate duration
stat, err := file.Stat()
if err == nil {
// Very rough duration estimate based on file size
// Assume ~128kbps average for Opus, ~160kbps for Vorbis
avgBitrate := 128000
if !isOpus {
avgBitrate = 160000
if err != nil {
return quality, nil
}
fileSize := stat.Size()
granule := readLastOggGranulePosition(file, fileSize)
if granule > 0 {
if isOpus {
// Opus always uses 48kHz granule position internally
totalSamples := granule - int64(preSkip)
if totalSamples > 0 {
quality.Duration = int(totalSamples / 48000)
}
} else if quality.SampleRate > 0 {
quality.Duration = int(granule / int64(quality.SampleRate))
}
quality.Duration = int(stat.Size() * 8 / int64(avgBitrate))
}
// Calculate average bitrate from file size and actual duration
if quality.Duration > 0 {
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
}
return quality, nil
}
// readLastOggGranulePosition seeks to the end of the file and scans backwards
// to find the last Ogg page, then reads its granule position (bytes 6-13).
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
// Read the last chunk of the file to find the last OggS sync
searchSize := int64(65536)
if searchSize > fileSize {
searchSize = fileSize
}
buf := make([]byte, searchSize)
offset := fileSize - searchSize
if offset < 0 {
offset = 0
}
n, err := file.ReadAt(buf, offset)
if err != nil && n == 0 {
return 0
}
buf = buf[:n]
// Scan backwards for "OggS" magic
lastPageOffset := -1
for i := n - 4; i >= 0; i-- {
if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' {
lastPageOffset = i
break
}
}
if lastPageOffset < 0 || lastPageOffset+14 > n {
return 0
}
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64)
return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14]))
}
// =============================================================================
// ID3v1 Genre List
// =============================================================================
+129 -15
View File
@@ -28,15 +28,23 @@ const (
deezerAPITimeoutMobile = 25 * time.Second
deezerMaxRetries = 2
deezerRetryDelay = 500 * time.Millisecond
deezerMaxSearchCacheEntries = 300
deezerMaxAlbumCacheEntries = 200
deezerMaxArtistCacheEntries = 200
deezerMaxISRCCacheEntries = 4000
deezerCacheCleanupInterval = 5 * time.Minute
)
type DeezerClient struct {
httpClient *http.Client
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry
isrcCache map[string]string
cacheMu sync.RWMutex
httpClient *http.Client
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry
isrcCache map[string]string
cacheMu sync.RWMutex
lastCacheCleanup time.Time
cacheCleanupInterval time.Duration
}
var (
@@ -47,16 +55,111 @@ var (
func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry),
isrcCache: make(map[string]string),
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry),
isrcCache: make(map[string]string),
cacheCleanupInterval: deezerCacheCleanupInterval,
}
})
return deezerClient
}
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
cache map[string]*cacheEntry,
now time.Time,
) {
for key, entry := range cache {
if entry == nil || now.After(entry.expiresAt) {
delete(cache, key)
}
}
}
func (c *DeezerClient) trimCacheEntriesLocked(
cache map[string]*cacheEntry,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
for len(cache) > maxEntries {
var oldestKey string
var oldestExpiry time.Time
first := true
for key, entry := range cache {
expiry := time.Time{}
if entry != nil {
expiry = entry.expiresAt
}
if first || expiry.Before(oldestExpiry) {
first = false
oldestKey = key
oldestExpiry = expiry
}
}
if oldestKey == "" {
return
}
delete(cache, oldestKey)
}
}
func (c *DeezerClient) trimStringCacheEntriesLocked(
cache map[string]string,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
toRemove := len(cache) - maxEntries
for key := range cache {
delete(cache, key)
toRemove--
if toRemove <= 0 {
return
}
}
}
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
(c.lastCacheCleanup.IsZero() ||
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
if periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
c.lastCacheCleanup = now
}
if len(c.searchCache) > deezerMaxSearchCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
}
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
}
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
}
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
}
if len(c.artistCache) > deezerMaxArtistCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
}
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
}
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
}
}
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -414,10 +517,12 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
c.cacheMu.Lock()
now := time.Now()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
@@ -555,10 +660,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
}
c.cacheMu.Lock()
now := time.Now()
c.albumCache[albumID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
@@ -638,10 +745,12 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
}
c.cacheMu.Lock()
now := time.Now()
c.artistCache[artistID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
@@ -807,6 +916,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
for trackIDStr, isrc := range directISRCs {
c.isrcCache[trackIDStr] = isrc
}
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock()
}
@@ -841,6 +951,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
c.cacheMu.Lock()
c.isrcCache[trackIDStr] = fullTrack.ISRC
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock()
}(track)
}
@@ -864,6 +975,7 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
c.cacheMu.Lock()
c.isrcCache[trackID] = fullTrack.ISRC
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock()
return fullTrack.ISRC, nil
@@ -946,10 +1058,12 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
}
c.cacheMu.Lock()
now := time.Now()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
+551 -145
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -47,10 +48,30 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
client, err := NewSpotifyMetadataClient()
if err != nil {
if shouldTrySpotFetchFallback(err) {
data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
jsonBytes, marshalErr := json.Marshal(data)
if marshalErr != nil {
return "", marshalErr
}
return string(jsonBytes), nil
}
}
return "", err
}
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err != nil {
if shouldTrySpotFetchFallback(err) {
fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
jsonBytes, marshalErr := json.Marshal(fallbackData)
if marshalErr != nil {
return "", marshalErr
}
return string(jsonBytes), nil
}
}
return "", err
}
@@ -150,6 +171,8 @@ type DownloadRequest struct {
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
LyricsMode string `json:"lyrics_mode,omitempty"`
UseExtensions bool `json:"use_extensions,omitempty"`
UseFallback bool `json:"use_fallback,omitempty"`
}
type DownloadResponse struct {
@@ -176,20 +199,179 @@ type DownloadResponse struct {
Copyright string `json:"copyright,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
}
type DownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
Genre string
Label string
Copyright string
LyricsLRC string
DecryptionKey string
}
func buildDownloadSuccessResponse(
req DownloadRequest,
result DownloadResult,
service string,
message string,
filePath string,
alreadyExists bool,
) DownloadResponse {
title := result.Title
if title == "" {
title = req.TrackName
}
artist := result.Artist
if artist == "" {
artist = req.ArtistName
}
album := result.Album
if album == "" {
album = req.AlbumName
}
releaseDate := result.ReleaseDate
if releaseDate == "" {
releaseDate = req.ReleaseDate
}
trackNumber := result.TrackNumber
if trackNumber == 0 {
trackNumber = req.TrackNumber
}
discNumber := result.DiscNumber
if discNumber == 0 {
discNumber = req.DiscNumber
}
isrc := result.ISRC
if isrc == "" {
isrc = req.ISRC
}
genre := result.Genre
if genre == "" {
genre = req.Genre
}
label := result.Label
if label == "" {
label = req.Label
}
copyright := result.Copyright
if copyright == "" {
copyright = req.Copyright
}
return DownloadResponse{
Success: true,
Message: message,
FilePath: filePath,
AlreadyExists: alreadyExists,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: service,
Title: title,
Artist: artist,
Album: album,
AlbumArtist: req.AlbumArtist,
ReleaseDate: releaseDate,
TrackNumber: trackNumber,
DiscNumber: discNumber,
ISRC: isrc,
CoverURL: req.CoverURL,
Genre: genre,
Label: label,
Copyright: copyright,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
}
}
func shouldSkipQualityProbe(filePath string) bool {
path := strings.TrimSpace(filePath)
if path == "" {
return true
}
if strings.HasPrefix(path, "/proc/self/fd/") {
return true
}
// Content URI and other non-filesystem schemes cannot be read directly by os.Open.
if strings.Contains(path, "://") {
return true
}
return false
}
func enrichResultQualityFromFile(result *DownloadResult) {
if result == nil {
return
}
path := strings.TrimSpace(result.FilePath)
if shouldSkipQualityProbe(path) {
if strings.HasPrefix(path, "/proc/self/fd/") {
LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path)
}
return
}
quality, qErr := GetAudioQuality(path)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
return
}
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
}
func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req == nil {
return
}
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
deezerClient := GetDeezerClient()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
if err != nil || extMeta == nil {
if err != nil {
GoLog("[DownloadWithFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
return
}
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Genre != "" || req.Label != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
}
}
func DownloadTrack(requestJSON string) (string, error) {
@@ -210,6 +392,8 @@ func DownloadTrack(requestJSON string) (string, error) {
AddAllowedDownloadDir(req.OutputDir)
}
enrichRequestExtendedMetadata(&req)
var result DownloadResult
var err error
@@ -254,17 +438,18 @@ func DownloadTrack(requestJSON string) (string, error) {
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
}
}
err = amazonErr
@@ -296,61 +481,76 @@ func DownloadTrack(requestJSON string) (string, error) {
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := DownloadResponse{
Success: true,
Message: "File already exists",
FilePath: actualPath,
AlreadyExists: true,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: req.Service,
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
}
result.FilePath = actualPath
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
result,
req.Service,
"File already exists",
actualPath,
true,
)
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Could not read quality from file: %v\n", qErr)
}
enrichResultQualityFromFile(&result)
resp := DownloadResponse{
Success: true,
Message: "Download complete",
FilePath: result.FilePath,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: req.Service,
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
}
resp := buildDownloadSuccessResponse(
req,
result,
req.Service,
"Download complete",
result.FilePath,
false,
)
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// DownloadByStrategy routes a unified download request to the appropriate flow.
// Routing priority: YouTube service > extension fallback > built-in fallback > direct service.
func DownloadByStrategy(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
serviceRaw := strings.TrimSpace(req.Service)
serviceNormalized := strings.ToLower(serviceRaw)
normalizedReq := req
if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) {
normalizedReq.Service = serviceNormalized
}
normalizedBytes, err := json.Marshal(normalizedReq)
if err != nil {
return errorResponse("Invalid request: " + err.Error())
}
normalizedJSON := string(normalizedBytes)
if serviceNormalized == "youtube" {
return DownloadFromYouTube(normalizedJSON)
}
if req.UseExtensions {
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
if err != nil {
return errorResponse(err.Error())
}
return resp, nil
}
if req.UseFallback {
return DownloadWithFallback(normalizedJSON)
}
return DownloadTrack(normalizedJSON)
}
func DownloadWithFallback(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -369,6 +569,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
AddAllowedDownloadDir(req.OutputDir)
}
enrichRequestExtendedMetadata(&req)
allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service
if preferredService == "" {
@@ -440,17 +642,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
}
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
@@ -465,57 +668,30 @@ func DownloadWithFallback(requestJSON string) (string, error) {
if err == nil {
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := DownloadResponse{
Success: true,
Message: "File already exists",
FilePath: actualPath,
AlreadyExists: true,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: service,
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
}
result.FilePath = actualPath
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
result,
service,
"File already exists",
actualPath,
true,
)
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Could not read quality from file: %v\n", qErr)
}
enrichResultQualityFromFile(&result)
resp := DownloadResponse{
Success: true,
Message: "Downloaded from " + service,
FilePath: result.FilePath,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: service,
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
}
resp := buildDownloadSuccessResponse(
req,
result,
service,
"Downloaded from "+service,
result.FilePath,
false,
)
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
@@ -622,6 +798,7 @@ func ReadFileMetadata(filePath string) (string, error) {
result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre
result["composer"] = meta.Composer
result["comment"] = meta.Comment
@@ -646,6 +823,7 @@ func ReadFileMetadata(filePath string) (string, error) {
result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre
result["composer"] = meta.Composer
result["comment"] = meta.Comment
@@ -678,6 +856,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
coverPath := strings.TrimSpace(fields["cover_path"])
if isFlac {
trackNum := 0
@@ -705,7 +884,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
Comment: fields["comment"],
}
if err := EmbedMetadata(filePath, meta, ""); err != nil {
if err := EmbedMetadata(filePath, meta, coverPath); err != nil {
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
}
@@ -829,6 +1008,64 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
return lrcContent, nil
}
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
result := map[string]interface{}{
"lyrics": lyrics,
"source": "Embedded",
"sync_type": "EMBEDDED",
"instrumental": false,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
result := map[string]interface{}{
"lyrics": "",
"source": "",
"sync_type": "",
"instrumental": false,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
lrcContent := ""
if lyricsData.Instrumental {
lrcContent = "[instrumental:true]"
} else {
lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName)
}
result := map[string]interface{}{
"lyrics": lrcContent,
"source": lyricsData.Source,
"sync_type": lyricsData.SyncType,
"instrumental": lyricsData.Instrumental,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
err := EmbedLyrics(filePath, lyrics)
if err != nil {
@@ -1101,9 +1338,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var spotifyErr error
client, err := NewSpotifyMetadataClient()
if err != nil {
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
spotifyErr = err
} else {
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err == nil {
@@ -1114,28 +1354,81 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
return string(jsonBytes), nil
}
errStr := strings.ToLower(err.Error())
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
spotifyErr = err
if !shouldTrySpotFetchFallback(err) {
return "", err
}
}
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
jsonBytes, err := json.Marshal(spotFetchData)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
GoLog("[Fallback] SpotFetch API fallback failed: %v\n", apiErr)
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
if spotifyErr != nil {
return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
}
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
GoLog("[Fallback] Trying Deezer conversion fallback for %s...\n", parsed.Type)
if parsed.Type == "track" || parsed.Type == "album" {
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
}
if parsed.Type == "artist" {
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
if spotifyErr != nil {
return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr)
}
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
if spotifyErr != nil {
return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
}
func shouldTrySpotFetchFallback(err error) bool {
if err == nil {
return false
}
if errors.Is(err, ErrNoSpotifyCredentials) {
return true
}
errStr := strings.ToLower(err.Error())
indicators := []string{
"429",
"rate",
"limit",
"403",
"forbidden",
"401",
"unauthorized",
"timeout",
"connection",
"spotify error",
"access token",
"client token",
"eof",
}
for _, indicator := range indicators {
if strings.Contains(errStr, indicator) {
return true
}
}
return false
}
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
@@ -1228,7 +1521,7 @@ func errorResponse(msg string) (string, error) {
// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ====================
// DownloadFromYouTube downloads a track from YouTube via Cobalt API
// This is a lossy-only provider (Opus 256kbps or MP3 320kbps)
// This is a lossy-only provider (Opus/MP3 with configurable bitrate)
// It does NOT participate in the lossless fallback chain
func DownloadFromYouTube(requestJSON string) (string, error) {
var req DownloadRequest
@@ -1266,6 +1559,10 @@ func DownloadFromYouTube(requestJSON string) (string, error) {
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
CoverURL: req.CoverURL,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
jsonBytes, _ := json.Marshal(resp)
@@ -1360,6 +1657,62 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6
return nil
}
// ==================== LYRICS PROVIDER SETTINGS ====================
// SetLyricsProvidersJSON sets the lyrics provider order from a JSON array of provider IDs.
func SetLyricsProvidersJSON(providersJSON string) error {
var providers []string
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
return err
}
SetLyricsProviderOrder(providers)
return nil
}
// GetLyricsProvidersJSON returns the current lyrics provider order as JSON.
func GetLyricsProvidersJSON() (string, error) {
providers := GetLyricsProviderOrder()
jsonBytes, err := json.Marshal(providers)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// GetAvailableLyricsProvidersJSON returns metadata about all available lyrics providers.
func GetAvailableLyricsProvidersJSON() (string, error) {
providers := GetAvailableLyricsProviders()
jsonBytes, err := json.Marshal(providers)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// SetLyricsFetchOptionsJSON sets lyrics provider fetch options.
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
opts := GetLyricsFetchOptions()
if strings.TrimSpace(optionsJSON) != "" {
if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil {
return err
}
}
SetLyricsFetchOptions(opts)
return nil
}
// GetLyricsFetchOptionsJSON returns current lyrics provider fetch options.
func GetLyricsFetchOptionsJSON() (string, error) {
opts := GetLyricsFetchOptions()
jsonBytes, err := json.Marshal(opts)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
// complete metadata from the internet before embedding.
@@ -1528,19 +1881,47 @@ func ReEnrichFile(requestJSON string) (string, error) {
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
lower := strings.ToLower(req.FilePath)
isFlac := strings.HasSuffix(lower, ".flac")
// Download cover art to temp file
var coverTempPath string
var coverDataBytes []byte
if req.CoverURL != "" {
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
if err != nil {
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
} else {
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
if err == nil {
coverTempPath = tmpFile.Name()
tmpFile.Write(coverData)
tmpFile.Close()
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
coverDataBytes = coverData
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
// MP3/Opus requires a real image file path for Dart FFmpeg.
// FLAC uses in-memory embed and does not require temp files.
if !isFlac {
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
if err != nil {
fallbackDir := filepath.Dir(req.FilePath)
if fallbackDir == "" || fallbackDir == "." {
GoLog("[ReEnrich] Failed to create cover temp file: %v\n", err)
} else {
tmpFile, err = os.CreateTemp(fallbackDir, "reenrich_cover_*.jpg")
if err != nil {
GoLog("[ReEnrich] Failed to create cover temp file (fallback dir %s): %v\n", fallbackDir, err)
}
}
}
if err == nil && tmpFile != nil {
coverTempPath = tmpFile.Name()
if _, writeErr := tmpFile.Write(coverData); writeErr != nil {
GoLog("[ReEnrich] Failed writing cover temp file: %v\n", writeErr)
tmpFile.Close()
os.Remove(coverTempPath)
coverTempPath = ""
} else if closeErr := tmpFile.Close(); closeErr != nil {
GoLog("[ReEnrich] Failed closing cover temp file: %v\n", closeErr)
os.Remove(coverTempPath)
coverTempPath = ""
}
}
}
}
}
@@ -1570,9 +1951,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}
lower := strings.ToLower(req.FilePath)
isFlac := strings.HasSuffix(lower, ".flac")
// Build enriched metadata response for Dart (includes online search results)
enrichedMeta := map[string]interface{}{
"track_name": req.TrackName,
@@ -1608,8 +1986,24 @@ func ReEnrichFile(requestJSON string) (string, error) {
Lyrics: lyricsLRC,
}
if err := EmbedMetadata(req.FilePath, metadata, coverTempPath); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
if len(coverDataBytes) > 0 {
if err := EmbedMetadataWithCoverData(req.FilePath, metadata, coverDataBytes); err != nil {
return "", fmt.Errorf("failed to embed metadata with cover: %w", err)
}
} else {
if err := EmbedMetadata(req.FilePath, metadata, ""); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
}
if len(coverDataBytes) > 0 {
embeddedCover, err := ExtractCoverArt(req.FilePath)
if err != nil || len(embeddedCover) == 0 {
if err != nil {
return "", fmt.Errorf("metadata embedded but cover verification failed: %w", err)
}
return "", fmt.Errorf("metadata embedded but cover verification failed: empty embedded cover")
}
GoLog("[ReEnrich] Cover verified after embed (%d bytes)\n", len(embeddedCover))
}
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
@@ -2699,14 +3093,26 @@ func GetStoreCategoriesJSON() (string, error) {
return string(jsonBytes), nil
}
func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
if strings.TrimSpace(extensionID) == "" {
return "", fmt.Errorf("invalid extension id")
}
safeExtensionID := sanitizeFilename(extensionID)
return filepath.Join(destDir, safeExtensionID+".spotiflac-ext"), nil
}
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
store := GetExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
destPath := fmt.Sprintf("%s/%s.spotiflac-ext", destDir, extensionID)
err := store.DownloadExtension(extensionID, destPath)
destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
if err != nil {
return "", err
}
err = store.DownloadExtension(extensionID, destPath)
if err != nil {
return "", err
}
+2
View File
@@ -713,6 +713,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
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"`
@@ -770,6 +771,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
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,
+7 -2
View File
@@ -12,6 +12,7 @@ type ExtensionType string
const (
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
)
type SettingType string
@@ -167,10 +168,10 @@ func (m *ExtensionManifest) Validate() error {
}
for _, t := range m.Types {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
return &ManifestValidationError{
Field: "type",
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t),
}
}
}
@@ -226,6 +227,10 @@ 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 {
+180 -19
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -1082,16 +1083,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
}
}
err = amazonErr
@@ -1119,6 +1122,8 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
}, nil
}
@@ -1132,8 +1137,13 @@ func buildOutputPath(req DownloadRequest) string {
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
}
@@ -1164,16 +1174,30 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
optionsJSON, _ := json.Marshal(options)
if options == nil {
options = map[string]interface{}{}
}
script := fmt.Sprintf(`
// Avoid embedding user input directly into JS source. Some inputs can trigger
// parser/runtime edge cases on specific devices/Goja builds.
const queryVar = "__sf_custom_search_query"
const optionsVar = "__sf_custom_search_options"
global := p.vm.GlobalObject()
_ = global.Set(queryVar, query)
_ = global.Set(optionsVar, options)
defer func() {
global.Delete(queryVar)
global.Delete(optionsVar)
}()
const script = `
(function() {
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
return extension.customSearch(%q, %s);
return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options);
}
return null;
})()
`, query, string(optionsJSON))
`
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
@@ -1358,12 +1382,12 @@ type PostProcessResult struct {
}
type PostProcessInput struct {
Path string `json:"path,omitempty"`
URI string `json:"uri,omitempty"`
Name string `json:"name,omitempty"`
Path string `json:"path,omitempty"`
URI string `json:"uri,omitempty"`
Name string `json:"name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size,omitempty"`
IsSAF bool `json:"is_saf,omitempty"`
Size int64 `json:"size,omitempty"`
IsSAF bool `json:"is_saf,omitempty"`
}
const PostProcessTimeout = 2 * time.Minute
@@ -1676,3 +1700,140 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
}
// ==================== Lyrics Provider ====================
// ExtLyricsResult represents lyrics data returned from an extension
type ExtLyricsResult struct {
Lines []ExtLyricsLine `json:"lines"`
SyncType string `json:"syncType"`
Instrumental bool `json:"instrumental"`
PlainLyrics string `json:"plainLyrics"`
Provider string `json:"provider"`
}
type ExtLyricsLine struct {
StartTimeMs int64 `json:"startTimeMs"`
Words string `json:"words"`
EndTimeMs int64 `json:"endTimeMs"`
}
// FetchLyrics calls the extension's fetchLyrics function
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
if !p.extension.Manifest.IsLyricsProvider() {
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Use global variables to avoid JS injection issues with special characters in track/artist names
const trackVar = "__sf_lyrics_track"
const artistVar = "__sf_lyrics_artist"
const albumVar = "__sf_lyrics_album"
const durationVar = "__sf_lyrics_duration"
global := p.vm.GlobalObject()
_ = global.Set(trackVar, trackName)
_ = global.Set(artistVar, artistName)
_ = global.Set(albumVar, albumName)
_ = global.Set(durationVar, durationSec)
defer func() {
global.Delete(trackVar)
global.Delete(artistVar)
global.Delete(albumVar)
global.Delete(durationVar)
}()
const script = `
(function() {
if (typeof extension !== 'undefined' && typeof extension.fetchLyrics === 'function') {
return extension.fetchLyrics(__sf_lyrics_track, __sf_lyrics_artist, __sf_lyrics_album, __sf_lyrics_duration);
}
return null;
})()
`
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("fetchLyrics timeout: extension took too long to respond")
}
return nil, fmt.Errorf("fetchLyrics failed: %w", err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return nil, fmt.Errorf("fetchLyrics returned null")
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
return nil, fmt.Errorf("failed to marshal lyrics result: %w", err)
}
var extResult ExtLyricsResult
if err := json.Unmarshal(jsonBytes, &extResult); err != nil {
return nil, fmt.Errorf("failed to parse lyrics result: %w", err)
}
// Convert ExtLyricsResult to LyricsResponse
response := &LyricsResponse{
SyncType: extResult.SyncType,
Instrumental: extResult.Instrumental,
PlainLyrics: extResult.PlainLyrics,
Provider: extResult.Provider,
Source: "Extension: " + p.extension.ID,
}
if response.Provider == "" {
response.Provider = p.extension.Manifest.DisplayName
}
for _, line := range extResult.Lines {
response.Lines = append(response.Lines, LyricsLine{
StartTimeMs: line.StartTimeMs,
Words: line.Words,
EndTimeMs: line.EndTimeMs,
})
}
// If the extension provided plainLyrics but no lines, parse them as unsynced
if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental {
response.SyncType = "UNSYNCED"
for _, line := range strings.Split(response.PlainLyrics, "\n") {
if strings.TrimSpace(line) != "" {
response.Lines = append(response.Lines, LyricsLine{
StartTimeMs: 0,
Words: line,
EndTimeMs: 0,
})
}
}
}
return response, nil
}
// GetLyricsProviders returns all enabled extensions that provide lyrics
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper
for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext))
}
}
// Keep a deterministic order so provider selection is stable across runs.
sort.Slice(providers, func(i, j int) bool {
return providers[i].extension.ID < providers[j].extension.ID
})
return providers
}
+58 -4
View File
@@ -18,6 +18,43 @@ import (
// ==================== Auth API (OAuth Support) ====================
func validateExtensionAuthURL(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid auth URL: %w", err)
}
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{}{
@@ -32,6 +69,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
callbackURL = call.Arguments[1].String()
}
if err := validateExtensionAuthURL(authURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
@@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
state.AuthCode = ""
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
return r.vm.ToValue(map[string]interface{}{
"success": true,
@@ -273,6 +317,12 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
"error": "authUrl, clientId, and redirectUri are required",
})
}
if err := validateExtensionAuthURL(authURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
scope, _ := config["scope"].(string)
extraParams, _ := config["extraParams"].(map[string]interface{})
@@ -331,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
}
pendingAuthRequestsMu.Unlock()
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
return r.vm.ToValue(map[string]interface{}{
"success": true,
@@ -441,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(),
})
}
bodyPreview := sanitizeSensitiveLogText(string(body))
if len(bodyPreview) > 1000 {
bodyPreview = bodyPreview[:1000] + "...[truncated]"
}
var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to parse token response: %v", err),
"body": string(body),
"body": bodyPreview,
})
}
@@ -468,7 +522,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "no access_token in response",
"body": string(body),
"body": bodyPreview,
})
}
+3
View File
@@ -32,6 +32,9 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
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 == "" {
+1 -1
View File
@@ -46,7 +46,7 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
return err
}
return os.WriteFile(storagePath, data, 0644)
return os.WriteFile(storagePath, data, 0600)
}
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
+2
View File
@@ -4,6 +4,7 @@ package gobackend
import (
"context"
"fmt"
"runtime/debug"
"sync"
"time"
@@ -49,6 +50,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
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)}
}
}
+234 -29
View File
@@ -3,28 +3,35 @@ package gobackend
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
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}`)
)
func sanitizeFilename(filename string) string {
sanitized := invalidChars.ReplaceAllString(filename, "_")
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".")
multiUnderscore := regexp.MustCompile(`_+`)
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
if len(sanitized) > 200 {
sanitized = sanitized[:200]
}
if sanitized == "" {
sanitized = "untitled"
}
return sanitized
}
@@ -32,45 +39,120 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
if template == "" {
template = "{artist} - {title}"
}
result := template
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 {
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
}
@@ -88,6 +170,129 @@ func formatDiscNumber(n int) string {
return fmt.Sprintf("%d", n)
}
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)
}
}
+9 -9
View File
@@ -2,16 +2,16 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0
toolchain go1.25.7
toolchain go1.26.0
require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
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-20260204172633-1dceadbbeea3
golang.org/x/net v0.49.0
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
golang.org/x/net v0.50.0
)
require (
@@ -20,10 +20,10 @@ require (
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.47.0 // indirect
golang.org/x/mod v0.32.0 // 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/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.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
)
+18
View File
@@ -8,6 +8,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
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=
@@ -30,20 +32,36 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
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/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=
+9 -2
View File
@@ -28,6 +28,7 @@ type LibraryScanResult struct {
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"`
}
@@ -289,8 +290,11 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
quality, err := GetMP3Quality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
if result.TrackName == "" {
@@ -326,8 +330,11 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
quality, err := GetOggQuality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
if result.TrackName == "" {
+16
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"sync"
"time"
@@ -30,8 +31,22 @@ const (
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{
@@ -71,6 +86,7 @@ func (lb *LogBuffer) Add(level, tag, message string) {
return
}
message = sanitizeSensitiveLogText(message)
message = truncateLogMessage(message)
entry := LogEntry{
+408 -49
View File
@@ -20,6 +20,140 @@ const (
durationToleranceSec = 10.0
)
// Lyrics provider names (used in settings and cascade ordering)
const (
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,
LyricsProviderMusixmatch,
LyricsProviderNetease,
LyricsProviderAppleMusic,
LyricsProviderQQMusic,
}
// Global lyrics provider configuration
var (
lyricsProvidersMu sync.RWMutex
lyricsProviders []string // ordered list of enabled providers
)
// 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{
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": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
}
}
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
@@ -90,6 +224,15 @@ func (c *lyricsCache) Size() int {
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"`
@@ -139,7 +282,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 {
@@ -174,7 +317,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
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 {
@@ -240,68 +383,203 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
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 {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
cachedCopy.Source = cached.Source + " (cached)"
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)
}
// Try extension lyrics providers first
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
}
var lyrics *LyricsResponse
var err error
isValidResult := func(l *LyricsResponse) bool {
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
}
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
if primaryArtist != artistName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
// Get configured provider order
providerOrder := GetLyricsProviderOrder()
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
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 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) {
lyrics.Source = "LRCLIB (simplified)"
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
if simplifiedTrack != trackName {
query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search (simplified)"
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
}
// 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(primaryArtist, simplifiedTrack)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
lyrics.Source = "LRCLIB (simplified)"
return lyrics, nil
}
}
// 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
}
// 5. Search with simplified track name
if simplifiedTrack != trackName {
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("LRCLIB: no lyrics found")
}
func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse {
result := &LyricsResponse{
Instrumental: resp.Instrumental,
@@ -339,10 +617,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,
@@ -363,6 +651,63 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
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)
@@ -376,12 +721,16 @@ 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 convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
@@ -441,8 +790,18 @@ func simplifyTrackName(name string) string {
re := regexp.MustCompile("(?i)" + pattern)
result = re.ReplaceAllString(result, "")
}
result = strings.TrimSpace(result)
if result == "" {
return result
}
return strings.TrimSpace(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 {
+381
View File
@@ -0,0 +1,381 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
// AppleMusicClient fetches lyrics from Apple Music.
// Uses a scraped JWT token for search and a proxy for lyrics.
type AppleMusicClient struct {
httpClient *http.Client
}
// Apple Music token manager — singleton with mutex for thread safety
type appleTokenManager struct {
mu sync.Mutex
token string
}
var globalAppleTokenManager = &appleTokenManager{}
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.token != "" {
return m.token, nil
}
// Step 1: Fetch the Apple Music beta page
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
}
// Step 2: Find the index JS file URL
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
match := indexJsRegex.Find(body)
if match == nil {
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
}
indexJsURL := "https://beta.music.apple.com" + string(match)
// Step 3: Fetch the JS file
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create JS request: %w", err)
}
jsReq.Header.Set("User-Agent", getRandomUserAgent())
jsResp, err := client.Do(jsReq)
if err != nil {
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
}
defer jsResp.Body.Close()
jsBody, err := io.ReadAll(jsResp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
}
// Step 4: Extract JWT token (starts with eyJh)
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
tokenMatch := tokenRegex.Find(jsBody)
if tokenMatch == nil {
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
}
m.token = string(tokenMatch)
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
return m.token, nil
}
func (m *appleTokenManager) clearToken() {
m.mu.Lock()
defer m.mu.Unlock()
m.token = ""
}
// Apple Music API response models
type appleMusicSearchResponse struct {
Results struct {
Songs *struct {
Data []struct {
ID string `json:"id"`
Type string `json:"type"`
} `json:"data"`
} `json:"songs"`
} `json:"results"`
Resources *struct {
Songs map[string]struct {
Attributes struct {
Name string `json:"name"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
URL string `json:"url"`
Artwork struct {
URL string `json:"url"`
} `json:"artwork"`
} `json:"attributes"`
} `json:"songs"`
} `json:"resources"`
}
// 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),
}
}
// SearchSong searches for a song on Apple Music and returns its ID.
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return "", fmt.Errorf("empty search query")
}
token, err := globalAppleTokenManager.getToken(c.httpClient)
if err != nil {
return "", fmt.Errorf("apple music token error: %w", err)
}
encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf(
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
encodedQuery,
)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Origin", "https://music.apple.com")
req.Header.Set("Referer", "https://music.apple.com/")
req.Header.Set("User-Agent", 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 == 401 {
globalAppleTokenManager.clearToken()
return "", fmt.Errorf("apple music token expired")
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
}
var searchResp appleMusicSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return "", fmt.Errorf("failed to decode apple music response: %w", err)
}
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
return "", fmt.Errorf("no songs found on apple music")
}
return searchResp.Results.Songs.Data[0].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
}
// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format.
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
// Try to parse as PaxResponse first
var paxResp paxResponse
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
}
// Try to parse as a direct list of PaxLyrics
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)
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
plainLines := strings.Split(lrcText, "\n")
var resultLines []LyricsLine
for _, line := range plainLines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
resultLines = append(resultLines, LyricsLine{
StartTimeMs: 0,
Words: trimmed,
EndTimeMs: 0,
})
}
}
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")
}
+214
View File
@@ -0,0 +1,214 @@
package gobackend
import (
"encoding/json"
"fmt"
"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
}
// Musixmatch proxy response models
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: "http://158.180.60.95",
}
}
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
// The Musixmatch proxy returns both search result and lyrics in a single response.
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
return nil, fmt.Errorf("empty track or artist name")
}
encodedArtist := url.QueryEscape(artistName)
encodedTrack := url.QueryEscape(trackName)
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
req, err := http.NewRequest("GET", fullURL, 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("musixmatch search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
}
var result musixmatchSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
}
return &result, nil
}
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
lang := strings.ToLower(strings.TrimSpace(language))
if songID <= 0 || lang == "" {
return nil, fmt.Errorf("invalid song id or language")
}
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
req, err := http.NewRequest("GET", fullURL, 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("musixmatch language fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
}
var result musixmatchSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
}
// Prefer synced lyrics for selected language
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
}
// Fall back to unsynced lyrics for selected language
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
var lines []LyricsLine
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
lines = append(lines, LyricsLine{
StartTimeMs: 0,
Words: trimmed,
EndTimeMs: 0,
})
}
}
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "UNSYNCED",
PlainLyrics: result.UnsyncedLyrics.Lyrics,
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) {
result, err := c.searchAndGetLyrics(trackName, artistName)
if err != nil {
return nil, err
}
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
if localizedErr == nil {
return localized, nil
}
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
}
// Prefer synced lyrics
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
}
// Fall back to unsynced lyrics
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
var lines []LyricsLine
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
lines = append(lines, LyricsLine{
StartTimeMs: 0,
Words: trimmed,
EndTimeMs: 0,
})
}
}
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "UNSYNCED",
PlainLyrics: result.UnsyncedLyrics.Lyrics,
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
}
return nil, fmt.Errorf("no lyrics found on musixmatch")
}
+209
View File
@@ -0,0 +1,209 @@
package gobackend
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
// This is a direct public API — no proxy dependency.
type NeteaseClient struct {
httpClient *http.Client
}
// Netease API response models
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 := "http://music.163.com/api/search/pc"
params := url.Values{}
params.Set("s", query)
params.Set("type", "1")
params.Set("limit", "1")
params.Set("offset", "0")
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 := "http://music.163.com/api/song/lyric"
params := url.Values{}
params.Set("id", fmt.Sprintf("%d", songID))
params.Set("lv", "1")
params.Set("tv", "1")
params.Set("rv", "1")
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
}
// Parse the LRC text into LyricsResponse
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
}
+211
View File
@@ -0,0 +1,211 @@
package gobackend
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// QQMusicClient fetches lyrics from QQ Music.
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
type QQMusicClient struct {
httpClient *http.Client
}
// QQ Music search response models
type qqMusicSearchResponse struct {
Data struct {
Song struct {
List []struct {
Title string `json:"title"`
Singer []struct {
Name string `json:"name"`
} `json:"singer"`
Album struct {
Name string `json:"name"`
} `json:"album"`
ID int64 `json:"id"`
} `json:"list"`
} `json:"song"`
} `json:"data"`
}
// QQ Music lyrics request payload for paxsenix proxy
type qqLyricsPayload struct {
Artist []string `json:"artist"`
Album string `json:"album"`
ID int64 `json:"id"`
Title string `json:"title"`
}
func NewQQMusicClient() *QQMusicClient {
return &QQMusicClient{
httpClient: NewMetadataHTTPClient(15 * time.Second),
}
}
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return nil, fmt.Errorf("empty search query")
}
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
params := url.Values{}
params.Set("format", "json")
params.Set("inCharset", "utf8")
params.Set("outCharset", "utf8")
params.Set("platform", "yqq.json")
params.Set("new_json", "1")
params.Set("w", query)
fullURL := searchURL + "?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("qqmusic search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
}
var searchResp qqMusicSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
}
if len(searchResp.Data.Song.List) == 0 {
return nil, fmt.Errorf("no songs found on qqmusic")
}
song := searchResp.Data.Song.List[0]
var artists []string
for _, singer := range song.Singer {
artists = append(artists, singer.Name)
}
return &qqLyricsPayload{
Artist: artists,
Album: song.Album.Name,
ID: song.ID,
Title: song.Title,
}, nil
}
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("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
}
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
func (c *QQMusicClient) FetchLyrics(
trackName,
artistName string,
durationSec float64,
multiPersonWordByWord bool,
) (*LyricsResponse, error) {
payload, err := c.searchSong(trackName, artistName)
if err != nil {
return nil, err
}
rawLyrics, err := c.fetchLyricsByPayload(payload)
if err != nil {
return nil, err
}
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
return nil, fmt.Errorf("qqmusic 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 use as direct LRC text
lrcText = rawLyrics
}
lines := parseSyncedLyrics(lrcText)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
Provider: "QQ Music",
Source: "QQ Music",
}, nil
}
// Fall back to plain text
plainLines := strings.Split(lrcText, "\n")
var resultLines []LyricsLine
for _, line := range plainLines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
resultLines = append(resultLines, LyricsLine{
StartTimeMs: 0,
Words: trimmed,
EndTimeMs: 0,
})
}
}
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")
}
+160 -35
View File
@@ -4,8 +4,13 @@ import (
"bytes"
"encoding/binary"
"fmt"
stdimage "image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"os"
"path/filepath"
"strconv"
"strings"
@@ -14,6 +19,82 @@ import (
"github.com/go-flac/go-flac/v2"
)
func detectCoverMIME(coverPath string, coverData []byte) string {
// Prefer magic-byte detection over file extension.
// Some providers return non-JPEG data behind .jpg URLs.
if len(coverData) >= 8 &&
coverData[0] == 0x89 &&
coverData[1] == 0x50 &&
coverData[2] == 0x4E &&
coverData[3] == 0x47 &&
coverData[4] == 0x0D &&
coverData[5] == 0x0A &&
coverData[6] == 0x1A &&
coverData[7] == 0x0A {
return "image/png"
}
if len(coverData) >= 3 &&
coverData[0] == 0xFF &&
coverData[1] == 0xD8 &&
coverData[2] == 0xFF {
return "image/jpeg"
}
if len(coverData) >= 6 {
header := string(coverData[:6])
if header == "GIF87a" || header == "GIF89a" {
return "image/gif"
}
}
if len(coverData) >= 12 &&
string(coverData[:4]) == "RIFF" &&
string(coverData[8:12]) == "WEBP" {
return "image/webp"
}
switch strings.ToLower(filepath.Ext(strings.TrimSpace(coverPath))) {
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
}
return "image/jpeg"
}
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
if len(coverData) == 0 {
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
}
mime := detectCoverMIME(coverPath, coverData)
picture := &flacpicture.MetadataBlockPicture{
PictureType: flacpicture.PictureTypeFrontCover,
MIME: mime,
Description: "Front Cover",
ImageData: coverData,
}
// Width/height/depth are optional in practice; keep zero when decode fails.
if cfg, format, err := stdimage.DecodeConfig(bytes.NewReader(coverData)); err == nil {
picture.Width = uint32(cfg.Width)
picture.Height = uint32(cfg.Height)
switch format {
case "png":
picture.ColorDepth = 32
case "jpeg":
picture.ColorDepth = 24
default:
picture.ColorDepth = 0
}
}
return picture.Marshal(), nil
}
type Metadata struct {
Title string
Artist string
@@ -127,19 +208,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
}
}
picture, err := flacpicture.NewFromImageData(
flacpicture.PictureTypeFrontCover,
"Front Cover",
coverData,
"image/jpeg",
)
picBlock, err := buildPictureBlock(coverPath, coverData)
if err != nil {
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
} else {
picBlock := picture.Marshal()
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
return fmt.Errorf("failed to create picture block: %w", err)
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
} else {
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
@@ -238,19 +312,12 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
}
}
picture, err := flacpicture.NewFromImageData(
flacpicture.PictureTypeFrontCover,
"Front Cover",
coverData,
"image/jpeg",
)
picBlock, err := buildPictureBlock("", coverData)
if err != nil {
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
} else {
picBlock := picture.Marshal()
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
return fmt.Errorf("failed to create picture block: %w", err)
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
return f.Save(filePath)
@@ -475,33 +542,91 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
}
func ExtractLyrics(filePath string) (string, error) {
lower := strings.ToLower(filePath)
if strings.HasSuffix(lower, ".flac") {
return extractLyricsFromFlac(filePath)
}
if strings.HasSuffix(lower, ".mp3") {
meta, err := ReadID3Tags(filePath)
if err != nil || meta == nil {
return "", fmt.Errorf("no lyrics found in file")
}
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
return "", fmt.Errorf("no lyrics found in file")
}
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
meta, err := ReadOggVorbisComments(filePath)
if err != nil || meta == nil {
return "", fmt.Errorf("no lyrics found in file")
}
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
return "", fmt.Errorf("no lyrics found in file")
}
return "", fmt.Errorf("unsupported file format for lyrics extraction")
}
func extractLyricsFromFlac(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
if meta.Type != flac.VorbisComment {
continue
}
lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
}
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
}
lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
return lyrics[0], nil
}
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
return lyrics[0], nil
}
}
return "", fmt.Errorf("no lyrics found in file")
}
func looksLikeEmbeddedLyrics(value string) bool {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return false
}
lower := strings.ToLower(trimmed)
if strings.Contains(lower, "[ar:") || strings.Contains(lower, "[ti:") {
return true
}
if strings.Contains(trimmed, "\n") && strings.Contains(trimmed, "[") && strings.Contains(trimmed, "]") {
return true
}
return false
}
type AudioQuality struct {
BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"`
+24 -1
View File
@@ -174,6 +174,26 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true
}
looseExpected := normalizeLooseTitle(normExpected)
looseFound := normalizeLooseTitle(normFound)
if looseExpected != "" && looseFound != "" {
if looseExpected == looseFound {
return true
}
if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) {
return true
}
}
// Some tracks are symbol/emoji-heavy and providers can return textual
// aliases. If artist/duration already matched upstream, avoid false rejects.
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
strings.TrimSpace(expectedTitle) != "" &&
strings.TrimSpace(foundTitle) != "" {
GoLog("[Qobuz] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
}
expectedLatin := qobuzIsLatinScript(expectedTitle)
foundLatin := qobuzIsLatinScript(foundTitle)
if expectedLatin != foundLatin {
@@ -419,7 +439,7 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
formatID := mapJumoQuality(quality)
region := "US"
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
GoLog("[Qobuz] Trying Jumo API fallback...\n")
@@ -428,6 +448,8 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
if err != nil {
return "", err
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
resp, err := client.Do(req)
if err != nil {
@@ -1178,6 +1200,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
var outputPath string
+80
View File
@@ -0,0 +1,80 @@
package gobackend
import (
"path/filepath"
"strings"
"testing"
)
func TestSanitizeSensitiveLogText(t *testing.T) {
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
redacted := sanitizeSensitiveLogText(input)
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
}
if !strings.Contains(redacted, "[REDACTED]") {
t.Fatalf("expected redaction marker in output, got: %s", redacted)
}
}
func TestValidateExtensionAuthURL(t *testing.T) {
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
t.Fatalf("expected valid auth URL, got error: %v", err)
}
blocked := []string{
"http://accounts.example.com/oauth/authorize",
"https://user:pass@accounts.example.com/oauth/authorize",
"https://localhost/oauth/authorize",
}
for _, rawURL := range blocked {
if err := validateExtensionAuthURL(rawURL); err == nil {
t.Fatalf("expected URL to be blocked: %s", rawURL)
}
}
}
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
Network: []string{"api.example.com"},
},
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
t.Fatal("expected embedded URL credentials to be rejected")
}
}
func TestBuildStoreExtensionDestPath(t *testing.T) {
baseDir := t.TempDir()
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
if err != nil {
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
}
if !isPathWithinBase(baseDir, destPath) {
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
}
baseName := filepath.Base(destPath)
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
}
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
}
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
t.Fatal("expected empty extension id to be rejected")
}
}
+80
View File
@@ -0,0 +1,80 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
// This is used as a fallback when direct Spotify API access is blocked/limited.
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
parsed, err := parseSpotifyURI(spotifyURL)
if err != nil {
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
}
base := strings.TrimSpace(apiBaseURL)
if base == "" {
base = DefaultSpotFetchAPIBaseURL
}
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
client := NewHTTPClientWithTimeout(30 * time.Second)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
}
switch parsed.Type {
case "track":
var trackResp TrackResponse
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
return nil, fmt.Errorf("failed to decode track response: %w", err)
}
return trackResp, nil
case "album":
var albumResp AlbumResponsePayload
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
return nil, fmt.Errorf("failed to decode album response: %w", err)
}
return &albumResp, nil
case "playlist":
var playlistResp PlaylistResponsePayload
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
}
return playlistResp, nil
case "artist":
var artistResp ArtistResponsePayload
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
return nil, fmt.Errorf("failed to decode artist response: %w", err)
}
return &artistResp, nil
default:
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
}
}
+21
View File
@@ -1289,6 +1289,26 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true
}
looseExpected := normalizeLooseTitle(normExpected)
looseFound := normalizeLooseTitle(normFound)
if looseExpected != "" && looseFound != "" {
if looseExpected == looseFound {
return true
}
if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) {
return true
}
}
// Some tracks are symbol/emoji-heavy and providers can return textual
// aliases. If artist/duration already matched upstream, avoid false rejects.
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
strings.TrimSpace(expectedTitle) != "" &&
strings.TrimSpace(foundTitle) != "" {
GoLog("[Tidal] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
}
expectedLatin := isLatinScript(expectedTitle)
foundLatin := isLatinScript(foundTitle)
if expectedLatin != foundLatin {
@@ -1609,6 +1629,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
+43
View File
@@ -0,0 +1,43 @@
package gobackend
import (
"strings"
"unicode"
)
// normalizeLooseTitle collapses separators/punctuation so titles like
// "Doctor / Cops" and "Doctor _ Cops" can still match.
func normalizeLooseTitle(title string) string {
trimmed := strings.TrimSpace(strings.ToLower(title))
if trimmed == "" {
return ""
}
var b strings.Builder
b.Grow(len(trimmed))
for _, r := range trimmed {
switch {
case unicode.IsLetter(r), unicode.IsNumber(r):
b.WriteRune(r)
case unicode.IsSpace(r):
b.WriteByte(' ')
// Treat common separators as spaces.
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ')
default:
// Drop other punctuation/symbols (including emoji) for loose matching.
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
func hasAlphaNumericRunes(value string) bool {
for _, r := range value {
if unicode.IsLetter(r) || unicode.IsNumber(r) {
return true
}
}
return false
}
+34
View File
@@ -0,0 +1,34 @@
package gobackend
import "testing"
func TestNormalizeLooseTitle_Separators(t *testing.T) {
got := normalizeLooseTitle("Doctor / Cops")
if got != "doctor cops" {
t.Fatalf("expected doctor cops, got %q", got)
}
got = normalizeLooseTitle("Doctor _ Cops")
if got != "doctor cops" {
t.Fatalf("expected doctor cops, got %q", got)
}
}
func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) {
got := normalizeLooseTitle("Music Of The Spheres 🌎✨")
if got != "music of the spheres" {
t.Fatalf("expected music of the spheres, got %q", got)
}
}
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
}
}
func TestQobuzTitlesMatch_SeparatorVariants(t *testing.T) {
if !qobuzTitlesMatch("Doctor / Cops", "Doctor _ Cops") {
t.Fatal("expected qobuzTitlesMatch to accept / vs _ variant")
}
}
+174 -45
View File
@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -20,6 +21,8 @@ type YouTubeDownloader struct {
mu sync.Mutex
}
const spotubeBaseURL = "https://spotubedl.com"
var (
globalYouTubeDownloader *YouTubeDownloader
youtubeDownloaderOnce sync.Once
@@ -29,9 +32,17 @@ type YouTubeQuality string
const (
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
)
var (
youtubeOpusSupportedBitrates = []int{128, 256}
youtubeMp3SupportedBitrates = []int{128, 256, 320}
)
type CobaltRequest struct {
URL string `json:"url"`
AudioBitrate string `json:"audioBitrate,omitempty"`
@@ -79,6 +90,77 @@ func NewYouTubeDownloader() *YouTubeDownloader {
return globalYouTubeDownloader
}
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
parts := strings.FieldsFunc(raw, func(r rune) bool {
return (r < '0' || r > '9')
})
for i := len(parts) - 1; i >= 0; i-- {
part := parts[i]
if part == "" {
continue
}
if parsed, err := strconv.Atoi(part); err == nil {
return parsed
}
}
return defaultBitrate
}
func nearestSupportedBitrate(value int, supported []int) int {
nearest := supported[0]
nearestDistance := absInt(value - nearest)
for _, option := range supported[1:] {
distance := absInt(value - option)
// On tie prefer higher quality.
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
nearest = option
nearestDistance = distance
}
}
return nearest
}
func absInt(value int) int {
if value < 0 {
return -value
}
return value
}
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
if strings.HasPrefix(normalizedRaw, "opus") {
parsed := extractBitrateFromQuality(normalizedRaw, 256)
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
}
if strings.HasPrefix(normalizedRaw, "mp3") {
parsed := extractBitrateFromQuality(normalizedRaw, 320)
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
}
// Backward compatibility for legacy symbolic values.
switch normalizedRaw {
case "opus_256", "opus256", "opus":
return "opus", 256, YouTubeQualityOpus256
case "opus_128", "opus128":
return "opus", 128, YouTubeQualityOpus128
case "mp3_320", "mp3320", "mp3", "":
return "mp3", 320, YouTubeQualityMP3320
case "mp3_256", "mp3256":
return "mp3", 256, YouTubeQualityMP3256
case "mp3_128", "mp3128":
return "mp3", 128, YouTubeQualityMP3128
default:
return "mp3", 320, YouTubeQualityMP3320
}
}
// SearchYouTube returns a YouTube Music search URL for the given track
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
@@ -95,22 +177,11 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
y.mu.Lock()
defer y.mu.Unlock()
var audioFormat string
var audioBitrate string
switch quality {
case YouTubeQualityOpus256:
audioFormat = "opus"
audioBitrate = "256"
case YouTubeQualityMP3320:
audioFormat = "mp3"
audioBitrate = "320"
default:
audioFormat = "mp3"
audioBitrate = "320"
}
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
audioBitrate := strconv.Itoa(bitrate)
// Try SpotubeDL first (primary)
var spotubeErr error
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
if extractErr == nil {
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
@@ -120,6 +191,7 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
if err == nil {
return resp, nil
}
spotubeErr = err
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
} else {
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
@@ -132,6 +204,9 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
if err != nil {
if spotubeErr != nil {
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
}
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
}
@@ -201,11 +276,34 @@ func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitr
}
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
// Note: engine v2 currently serves MP3-oriented outputs, so we only use v2 for MP3 requests.
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s",
videoID, audioFormat, audioBitrate)
engines := []string{"v1"}
if strings.EqualFold(audioFormat, "mp3") {
engines = append(engines, "v2")
}
var lastErr error
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
for _, engine := range engines {
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
if err == nil {
return resp, nil
}
lastErr = err
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
}
if lastErr == nil {
lastErr = fmt.Errorf("no SpotubeDL engine available")
}
return nil, lastErr
}
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
@@ -225,27 +323,60 @@ func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
}
var result struct {
URL string `json:"url"`
URL string `json:"url"`
Status string `json:"status"`
Error string `json:"error"`
Message string `json:"message"`
Filename string `json:"filename"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
}
if result.URL == "" {
return nil, fmt.Errorf("no download URL from spotubedl")
downloadURL := strings.TrimSpace(result.URL)
if downloadURL == "" {
if result.Error != "" {
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
}
if result.Message != "" {
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
}
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
}
GoLog("[YouTube] Got download URL from SpotubeDL\n")
if strings.HasPrefix(downloadURL, "/") {
downloadURL = spotubeBaseURL + downloadURL
}
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
}
filename := strings.TrimSpace(result.Filename)
if filename == "" {
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
filename = decodedFilename
} else {
filename = queryFilename
}
}
}
}
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
return &CobaltResponse{
Status: "tunnel",
URL: result.URL,
Status: "tunnel",
URL: downloadURL,
Filename: filename,
}, nil
}
@@ -411,15 +542,7 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
downloader := NewYouTubeDownloader()
var quality YouTubeQuality
switch strings.ToLower(req.Quality) {
case "opus_256", "opus256", "opus":
quality = YouTubeQualityOpus256
case "mp3_320", "mp3320", "mp3":
quality = YouTubeQualityMP3320
default:
quality = YouTubeQualityMP3320 // Default to MP3 320kbps
}
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
var youtubeURL string
@@ -480,18 +603,23 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
var ext string
var format string
var bitrate int
switch quality {
case YouTubeQualityOpus256:
ext := ".mp3"
if format == "opus" {
ext = ".opus"
format = "opus"
bitrate = 256
case YouTubeQualityMP3320:
ext = ".mp3"
format = "mp3"
bitrate = 320
}
// Some SpotubeDL engines may return a different output container than requested.
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
if cobaltResp != nil && cobaltResp.Filename != "" {
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
switch {
case strings.HasSuffix(lowerName, ".mp3"):
ext = ".mp3"
format = "mp3"
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
ext = ".opus"
format = "opus"
}
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
@@ -500,6 +628,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ext
+41
View File
@@ -0,0 +1,41 @@
package gobackend
import "testing"
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
if format != "opus" {
t.Fatalf("expected opus format, got %s", format)
}
if bitrate != 128 {
t.Fatalf("expected 128 bitrate, got %d", bitrate)
}
if normalized != YouTubeQualityOpus128 {
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
}
}
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
if format != "mp3" {
t.Fatalf("expected mp3 format, got %s", format)
}
if bitrate != 256 {
t.Fatalf("expected 256 bitrate, got %d", bitrate)
}
if normalized != YouTubeQualityMP3256 {
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
}
}
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
if opusBitrate != 256 {
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
}
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
if mp3Bitrate != 128 {
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+5
View File
@@ -46,6 +46,11 @@ post_install do |installer|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
definitions = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
unless definitions.include?('PERMISSION_NOTIFICATIONS=1')
definitions << 'PERMISSION_NOTIFICATIONS=1'
end
end
end
end
+85 -15
View File
@@ -83,18 +83,12 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "downloadTrack":
case "downloadByStrategy":
let requestJson = call.arguments as! String
let response = GobackendDownloadTrack(requestJson, &error)
let response = GobackendDownloadByStrategy(requestJson, &error)
if let error = error { throw error }
return response
case "downloadWithFallback":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithFallback(requestJson, &error)
if let error = error { throw error }
return response
case "getDownloadProgress":
let response = GobackendGetDownloadProgress()
return response
@@ -197,6 +191,17 @@ import Gobackend // Import Go framework
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
if let error = error { throw error }
return response
case "getLyricsLRCWithSource":
let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let filePath = args["file_path"] as? String ?? ""
let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendGetLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs, &error)
if let error = error { throw error }
return response
case "embedLyricsToFile":
let args = call.arguments as! [String: Any]
@@ -209,6 +214,41 @@ import Gobackend // Import Go framework
case "cleanupConnections":
GobackendCleanupConnections()
return nil
case "downloadCoverToFile":
let args = call.arguments as! [String: Any]
let coverURL = args["cover_url"] as! String
let outputPath = args["output_path"] as! String
let maxQuality = args["max_quality"] as? Bool ?? true
GobackendDownloadCoverToFile(coverURL, outputPath, maxQuality, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "extractCoverToFile":
let args = call.arguments as! [String: Any]
let audioPath = args["audio_path"] as! String
let outputPath = args["output_path"] as! String
GobackendExtractCoverToFile(audioPath, outputPath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "fetchAndSaveLyrics":
let args = call.arguments as! [String: Any]
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let spotifyId = args["spotify_id"] as! String
let durationMs = args["duration_ms"] as? Int64 ?? 0
let outputPath = args["output_path"] as! String
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "reEnrichFile":
let args = call.arguments as! [String: Any]
let requestJson = args["request_json"] as? String ?? "{}"
let response = GobackendReEnrichFile(requestJson, &error)
if let error = error { throw error }
return response
case "readFileMetadata":
let args = call.arguments as! [String: Any]
@@ -479,12 +519,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "downloadWithExtensions":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
if let error = error { throw error }
return response
case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -492,6 +526,12 @@ import Gobackend // Import Go framework
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
if let error = error { throw error }
return response
case "downloadWithExtensions":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
if let error = error { throw error }
return response
case "removeExtension":
let args = call.arguments as! [String: Any]
@@ -754,6 +794,36 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Lyrics Provider Settings
case "setLyricsProviders":
let args = call.arguments as! [String: Any]
let providersJson = args["providers_json"] as? String ?? "[]"
GobackendSetLyricsProvidersJSON(providersJson, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "getLyricsProviders":
let response = GobackendGetLyricsProvidersJSON(&error)
if let error = error { throw error }
return response
case "getAvailableLyricsProviders":
let response = GobackendGetAvailableLyricsProvidersJSON(&error)
if let error = error { throw error }
return response
case "setLyricsFetchOptions":
let args = call.arguments as! [String: Any]
let optionsJson = args["options_json"] as? String ?? "{}"
GobackendSetLyricsFetchOptionsJSON(optionsJson, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "getLyricsFetchOptions":
let response = GobackendGetLyricsFetchOptionsJSON(&error)
if let error = error { throw error }
return response
default:
throw NSError(
domain: "SpotiFLAC",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 576 B

After

Width:  |  Height:  |  Size: 905 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 744 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 B

After

Width:  |  Height:  |  Size: 624 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 789 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 576 B

After

Width:  |  Height:  |  Size: 905 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 752 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+19 -15
View File
@@ -10,9 +10,13 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) {
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
final isFirstLaunch = ref.watch(
settingsProvider.select((s) => s.isFirstLaunch),
);
final hasCompletedTutorial = ref.watch(
settingsProvider.select((s) => s.hasCompletedTutorial),
);
// Determine initial location based on app state
String initialLocation;
if (isFirstLaunch) {
@@ -22,18 +26,12 @@ final _routerProvider = Provider<GoRouter>((ref) {
} else {
initialLocation = '/';
}
return GoRouter(
initialLocation: initialLocation,
routes: [
GoRoute(
path: '/',
builder: (context, state) => const MainShell(),
),
GoRoute(
path: '/setup',
builder: (context, state) => const SetupScreen(),
),
GoRoute(path: '/', builder: (context, state) => const MainShell()),
GoRoute(path: '/setup', builder: (context, state) => const SetupScreen()),
GoRoute(
path: '/tutorial',
builder: (context, state) => const TutorialScreen(),
@@ -43,13 +41,18 @@ final _routerProvider = Provider<GoRouter>((ref) {
});
class SpotiFLACApp extends ConsumerWidget {
const SpotiFLACApp({super.key});
final bool disableOverscrollEffects;
const SpotiFLACApp({super.key, this.disableOverscrollEffects = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(_routerProvider);
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
final scrollBehavior = disableOverscrollEffects
? const MaterialScrollBehavior().copyWith(overscroll: false)
: null;
Locale? locale;
if (localeString != 'system') {
if (localeString.contains('_')) {
@@ -59,7 +62,7 @@ class SpotiFLACApp extends ConsumerWidget {
locale = Locale(localeString);
}
}
return DynamicColorWrapper(
builder: (lightTheme, darkTheme, themeMode) {
return MaterialApp.router(
@@ -68,6 +71,7 @@ class SpotiFLACApp extends ConsumerWidget {
theme: lightTheme,
darkTheme: darkTheme,
themeMode: themeMode,
scrollBehavior: scrollBehavior,
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
routerConfig: router,
+2 -3
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.6.0';
static const String buildNumber = '77';
static const String version = '3.6.9';
static const String buildNumber = '82';
static const String fullVersion = '$version+$buildNumber';
@@ -17,6 +17,5 @@ class AppInfo {
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String bmacUrl = 'https://buymeacoffee.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
}
+136 -12
View File
@@ -928,18 +928,6 @@ abstract class AppLocalizations {
/// **'Support'**
String get aboutSupport;
/// Donation link
///
/// In en, this message translates to:
/// **'Buy me a coffee'**
String get aboutBuyMeCoffee;
/// Subtitle for donation
///
/// In en, this message translates to:
/// **'Support development on Ko-fi'**
String get aboutBuyMeCoffeeSubtitle;
/// Section for app info
///
/// In en, this message translates to:
@@ -2164,6 +2152,18 @@ abstract class AppLocalizations {
/// **'{artist} - {title}'**
String filenameHint(Object artist, Object title);
/// Toggle label for showing advanced filename tags
///
/// In en, this message translates to:
/// **'Show advanced tags'**
String get filenameShowAdvancedTags;
/// Description for advanced filename tag toggle
///
/// In en, this message translates to:
/// **'Enable formatted tags for track padding and date patterns'**
String get filenameShowAdvancedTagsDescription;
/// Setting title - folder structure
///
/// In en, this message translates to:
@@ -3508,6 +3508,42 @@ abstract class AppLocalizations {
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
String get youtubeQualityNote;
/// Title for YouTube Opus bitrate setting
///
/// In en, this message translates to:
/// **'YouTube Opus Bitrate'**
String get youtubeOpusBitrateTitle;
/// Title for YouTube MP3 bitrate setting
///
/// In en, this message translates to:
/// **'YouTube MP3 Bitrate'**
String get youtubeMp3BitrateTitle;
/// Subtitle showing current bitrate and valid range
///
/// In en, this message translates to:
/// **'{bitrate}kbps ({min}-{max})'**
String youtubeBitrateSubtitle(int bitrate, int min, int max);
/// Helper text for manual YouTube bitrate input
///
/// In en, this message translates to:
/// **'Enter custom bitrate ({min}-{max} kbps)'**
String youtubeBitrateInputHelp(int min, int max);
/// Label for YouTube bitrate input field
///
/// In en, this message translates to:
/// **'Bitrate (kbps)'**
String get youtubeBitrateFieldLabel;
/// Validation error for invalid YouTube bitrate input
///
/// In en, this message translates to:
/// **'Bitrate must be between {min} and {max} kbps'**
String youtubeBitrateValidationError(int min, int max);
/// Setting - show quality picker
///
/// In en, this message translates to:
@@ -3550,6 +3586,24 @@ abstract class AppLocalizations {
/// **'Artist folders use Track Artist only'**
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
/// Setting - strip featured artists from folder name
///
/// In en, this message translates to:
/// **'Primary artist only for folders'**
String get downloadUsePrimaryArtistOnly;
/// Subtitle when primary artist only is enabled
///
/// In en, this message translates to:
/// **'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'**
String get downloadUsePrimaryArtistOnlyEnabled;
/// Subtitle when primary artist only is disabled
///
/// In en, this message translates to:
/// **'Full artist string used for folder name'**
String get downloadUsePrimaryArtistOnlyDisabled;
/// Setting - output file format
///
/// In en, this message translates to:
@@ -5062,6 +5116,12 @@ abstract class AppLocalizations {
/// **'Fetch and save lyrics as .lrc file'**
String get trackSaveLyricsSubtitle;
/// Snackbar while saving lyrics to file
///
/// In en, this message translates to:
/// **'Saving lyrics...'**
String get trackSaveLyricsProgress;
/// Menu action - re-embed metadata into audio file
///
/// In en, this message translates to:
@@ -5133,6 +5193,70 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Failed: {error}'**
String trackSaveFailed(String error);
/// Menu item - convert audio format
///
/// In en, this message translates to:
/// **'Convert Format'**
String get trackConvertFormat;
/// Subtitle for convert format menu item
///
/// In en, this message translates to:
/// **'Convert to MP3 or Opus'**
String get trackConvertFormatSubtitle;
/// Title of convert bottom sheet
///
/// In en, this message translates to:
/// **'Convert Audio'**
String get trackConvertTitle;
/// Label for format selection
///
/// In en, this message translates to:
/// **'Target Format'**
String get trackConvertTargetFormat;
/// Label for bitrate selection
///
/// In en, this message translates to:
/// **'Bitrate'**
String get trackConvertBitrate;
/// Confirmation dialog title
///
/// In en, this message translates to:
/// **'Confirm Conversion'**
String get trackConvertConfirmTitle;
/// Confirmation dialog message
///
/// In en, this message translates to:
/// **'Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.'**
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
);
/// Snackbar while converting
///
/// In en, this message translates to:
/// **'Converting audio...'**
String get trackConvertConverting;
/// Snackbar after successful conversion
///
/// In en, this message translates to:
/// **'Converted to {format} successfully'**
String trackConvertSuccess(String format);
/// Snackbar when conversion fails
///
/// In en, this message translates to:
/// **'Conversion failed'**
String get trackConvertFailed;
}
class _AppLocalizationsDelegate
File diff suppressed because it is too large Load Diff
+83 -6
View File
@@ -457,12 +457,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -1188,6 +1182,13 @@ class AppLocalizationsEn extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Folder Organization';
@@ -1922,6 +1923,30 @@ class AppLocalizationsEn extends AppLocalizations {
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
return '${bitrate}kbps ($min-$max)';
}
@override
String youtubeBitrateInputHelp(int min, int max) {
return 'Enter custom bitrate ($min-$max kbps)';
}
@override
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
@override
String youtubeBitrateValidationError(int min, int max) {
return 'Bitrate must be between $min and $max kbps';
}
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1945,6 +1970,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2846,6 +2882,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@@ -2889,4 +2928,42 @@ class AppLocalizationsEn extends AppLocalizations {
String trackSaveFailed(String error) {
return 'Failed: $error';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
File diff suppressed because it is too large Load Diff
+168 -85
View File
@@ -13,62 +13,62 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get appDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Téléchargez des pistes Spotify en qualité sans perte de Tidal, Qobuz et Amazon Music.';
@override
String get navHome => 'Home';
String get navHome => 'Accueil';
@override
String get navLibrary => 'Library';
String get navLibrary => 'Bibliothèques';
@override
String get navHistory => 'History';
String get navHistory => 'Historique';
@override
String get navSettings => 'Settings';
String get navSettings => 'Paramètres';
@override
String get navStore => 'Store';
String get navStore => 'Magasin';
@override
String get homeTitle => 'Home';
String get homeTitle => 'Accueil';
@override
String get homeSearchHint => 'Paste Spotify URL or search...';
String get homeSearchHint => 'Coller l\'URL Spotify ou rechercher...';
@override
String homeSearchHintExtension(String extensionName) {
return 'Search with $extensionName...';
return 'Rechercher avec $extensionName...';
}
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs';
@override
String get homeRecent => 'Recent';
String get homeRecent => 'Récent';
@override
String get historyTitle => 'History';
String get historyTitle => 'Historique';
@override
String historyDownloading(int count) {
return 'Downloading ($count)';
return 'Téléchargement ($count)';
}
@override
String get historyDownloaded => 'Downloaded';
String get historyDownloaded => 'Téléchargé';
@override
String get historyFilterAll => 'All';
String get historyFilterAll => 'Tous';
@override
String get historyFilterAlbums => 'Albums';
@override
String get historyFilterSingles => 'Singles';
String get historyFilterSingles => 'Titres';
@override
String historyTracksCount(int count) {
@@ -93,36 +93,37 @@ class AppLocalizationsFr extends AppLocalizations {
}
@override
String get historyNoDownloads => 'No download history';
String get historyNoDownloads => 'Pas d\'historique de téléchargement';
@override
String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here';
String get historyNoDownloadsSubtitle =>
'Les pistes téléchargées apparaîtront ici';
@override
String get historyNoAlbums => 'No album downloads';
String get historyNoAlbums => 'Pas de téléchargement d\'album';
@override
String get historyNoAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
'Téléchargez plusieurs titres d\'un album pour les voir ici';
@override
String get historyNoSingles => 'No single downloads';
String get historyNoSingles => 'Pas de téléchargements uniques';
@override
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
'Les téléchargements de pistes uniques apparaîtront ici';
@override
String get historySearchHint => 'Search history...';
String get historySearchHint => 'Historique de recherche...';
@override
String get settingsTitle => 'Settings';
String get settingsTitle => 'Paramètres';
@override
String get settingsDownload => 'Download';
String get settingsDownload => 'Télécharger';
@override
String get settingsAppearance => 'Appearance';
String get settingsAppearance => 'Apparence';
@override
String get settingsOptions => 'Options';
@@ -131,51 +132,54 @@ class AppLocalizationsFr extends AppLocalizations {
String get settingsExtensions => 'Extensions';
@override
String get settingsAbout => 'About';
String get settingsAbout => 'À propos';
@override
String get downloadTitle => 'Download';
String get downloadTitle => 'Télécharger';
@override
String get downloadLocation => 'Download Location';
String get downloadLocation => 'Télécharger Localisation';
@override
String get downloadLocationSubtitle => 'Choose where to save files';
String get downloadLocationSubtitle =>
'Choisissez où enregistrer des fichiers';
@override
String get downloadLocationDefault => 'Default location';
String get downloadLocationDefault => 'Localisation par défaut';
@override
String get downloadDefaultService => 'Default Service';
String get downloadDefaultService => 'Service par défaut';
@override
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
String get downloadDefaultServiceSubtitle =>
'Service utilisé pour les téléchargements';
@override
String get downloadDefaultQuality => 'Default Quality';
String get downloadDefaultQuality => 'Qualité par défaut';
@override
String get downloadAskQuality => 'Ask Quality Before Download';
String get downloadAskQuality =>
'Demandez La Qualité Avant Le Téléchargement';
@override
String get downloadAskQualitySubtitle =>
'Show quality picker for each download';
'Afficher le sélecteur de qualité pour chaque téléchargement';
@override
String get downloadFilenameFormat => 'Filename Format';
String get downloadFilenameFormat => 'Nom du fichier';
@override
String get downloadFolderOrganization => 'Folder Organization';
String get downloadFolderOrganization => 'Organisation du dossier';
@override
String get downloadSeparateSingles => 'Separate Singles';
String get downloadSeparateSingles => 'Titres séparés';
@override
String get downloadSeparateSinglesSubtitle =>
'Put single tracks in a separate folder';
'Mettre des pistes uniques dans un dossier séparé';
@override
String get qualityBest => 'Best Available';
String get qualityBest => 'Meilleur Disponible';
@override
String get qualityFlac => 'FLAC';
@@ -187,69 +191,71 @@ class AppLocalizationsFr extends AppLocalizations {
String get quality128 => '128 kbps';
@override
String get appearanceTitle => 'Appearance';
String get appearanceTitle => 'Apparence';
@override
String get appearanceTheme => 'Theme';
String get appearanceTheme => 'Thème';
@override
String get appearanceThemeSystem => 'System';
String get appearanceThemeSystem => 'Système';
@override
String get appearanceThemeLight => 'Light';
String get appearanceThemeLight => 'Clair';
@override
String get appearanceThemeDark => 'Dark';
String get appearanceThemeDark => 'Sombre';
@override
String get appearanceDynamicColor => 'Dynamic Color';
String get appearanceDynamicColor => 'Couleur dynamique';
@override
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
String get appearanceDynamicColorSubtitle =>
'Utilisez les couleurs de votre fond d\'écran';
@override
String get appearanceAccentColor => 'Accent Color';
String get appearanceAccentColor => 'Couleur d\'accent';
@override
String get appearanceHistoryView => 'History View';
String get appearanceHistoryView => 'Historique Vue';
@override
String get appearanceHistoryViewList => 'List';
String get appearanceHistoryViewList => '';
@override
String get appearanceHistoryViewGrid => 'Grid';
String get appearanceHistoryViewGrid => 'Grille';
@override
String get optionsTitle => 'Options';
@override
String get optionsSearchSource => 'Search Source';
String get optionsSearchSource => 'Recherche Source';
@override
String get optionsPrimaryProvider => 'Primary Provider';
String get optionsPrimaryProvider => 'Fournisseur principal';
@override
String get optionsPrimaryProviderSubtitle =>
'Service used when searching by track name.';
'Service utilisé lors de la recherche par nom de piste.';
@override
String optionsUsingExtension(String extensionName) {
return 'Using extension: $extensionName';
return 'Utilisation de l\'extension: $extensionName';
}
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
@override
String get optionsAutoFallback => 'Auto Fallback';
@override
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
'Essayez d\'autres services si le téléchargement échoue';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
String get optionsUseExtensionProviders =>
'Utiliser des fournisseurs d\'extension';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
@@ -376,16 +382,16 @@ class AppLocalizationsFr extends AppLocalizations {
}
@override
String get extensionsUninstall => 'Uninstall';
String get extensionsUninstall => 'Désinstaller';
@override
String get extensionsSetAsSearch => 'Set as Search Provider';
String get extensionsSetAsSearch => 'Défini comme fournisseur de recherche';
@override
String get storeTitle => 'Extension Store';
String get storeTitle => 'Magasin d\'extension';
@override
String get storeSearch => 'Search extensions...';
String get storeSearch => 'Recherche d\'extensions...';
@override
String get storeInstall => 'Install';
@@ -457,12 +463,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -573,7 +573,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get trackMetadataDuration => 'Duration';
@override
String get trackMetadataQuality => 'Quality';
String get trackMetadataQuality => '';
@override
String get trackMetadataPath => 'File Path';
@@ -585,38 +585,38 @@ class AppLocalizationsFr extends AppLocalizations {
String get trackMetadataService => 'Service';
@override
String get trackMetadataPlay => 'Play';
String get trackMetadataPlay => 'Jouer';
@override
String get trackMetadataShare => 'Share';
String get trackMetadataShare => 'Partager';
@override
String get trackMetadataDelete => 'Delete';
String get trackMetadataDelete => 'Supprimer';
@override
String get trackMetadataRedownload => 'Re-download';
String get trackMetadataRedownload => 'Re-télécharger';
@override
String get trackMetadataOpenFolder => 'Open Folder';
String get trackMetadataOpenFolder => 'Dossier ouvert';
@override
String get setupTitle => 'Welcome to SpotiFLAC';
String get setupTitle => 'Bienvenue chez SpotiFLAC';
@override
String get setupSubtitle => 'Let\'s get you started';
String get setupSubtitle => 'On va commencer';
@override
String get setupStoragePermission => 'Storage Permission';
String get setupStoragePermission => 'Permission de stockage';
@override
String get setupStoragePermissionSubtitle =>
'Required to save downloaded files';
'Requis pour enregistrer les fichiers téléchargés';
@override
String get setupStoragePermissionGranted => 'Permission granted';
String get setupStoragePermissionGranted => 'Permission accordée';
@override
String get setupStoragePermissionDenied => 'Permission denied';
String get setupStoragePermissionDenied => 'Permission refusée';
@override
String get setupGrantPermission => 'Grant Permission';
@@ -741,14 +741,14 @@ class AppLocalizationsFr extends AppLocalizations {
'Get notified when downloads complete or require attention.';
@override
String get setupFolderSelected => 'Download Folder Selected!';
String get setupFolderSelected => 'Dossier de téléchargement sélectionné!';
@override
String get setupFolderChoose => 'Choose Download Folder';
String get setupFolderChoose => 'Choisissez le dossier pour télécharger';
@override
String get setupFolderDescription =>
'Select a folder where your downloaded music will be saved.';
'Sélectionnez un dossier dans lequel votre musique téléchargée sera enregistrée.';
@override
String get setupChangeFolder => 'Change Folder';
@@ -1188,6 +1188,13 @@ class AppLocalizationsFr extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Folder Organization';
@@ -1922,6 +1929,30 @@ class AppLocalizationsFr extends AppLocalizations {
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
return '${bitrate}kbps ($min-$max)';
}
@override
String youtubeBitrateInputHelp(int min, int max) {
return 'Enter custom bitrate ($min-$max kbps)';
}
@override
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
@override
String youtubeBitrateValidationError(int min, int max) {
return 'Bitrate must be between $min and $max kbps';
}
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1945,6 +1976,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2846,6 +2888,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@@ -2889,4 +2934,42 @@ class AppLocalizationsFr extends AppLocalizations {
String trackSaveFailed(String error) {
return 'Failed: $error';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+83 -6
View File
@@ -457,12 +457,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -1188,6 +1182,13 @@ class AppLocalizationsHi extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Folder Organization';
@@ -1922,6 +1923,30 @@ class AppLocalizationsHi extends AppLocalizations {
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
return '${bitrate}kbps ($min-$max)';
}
@override
String youtubeBitrateInputHelp(int min, int max) {
return 'Enter custom bitrate ($min-$max kbps)';
}
@override
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
@override
String youtubeBitrateValidationError(int min, int max) {
return 'Bitrate must be between $min and $max kbps';
}
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1945,6 +1970,17 @@ class AppLocalizationsHi extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2846,6 +2882,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@@ -2889,4 +2928,42 @@ class AppLocalizationsHi extends AppLocalizations {
String trackSaveFailed(String error) {
return 'Failed: $error';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+176 -105
View File
@@ -349,7 +349,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get optionsSpotifyDeprecationWarning =>
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Ekstensi';
@@ -462,12 +462,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutSupport => 'Dukungan';
@override
String get aboutBuyMeCoffee => 'Belikan saya kopi';
@override
String get aboutBuyMeCoffeeSubtitle => 'Dukung pengembangan di Ko-fi';
@override
String get aboutApp => 'Aplikasi';
@@ -1194,6 +1188,13 @@ class AppLocalizationsId extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
@override
String get filenameShowAdvancedTagsDescription =>
'Aktifkan tag format untuk padding nomor lagu dan pola tanggal';
@override
String get folderOrganization => 'Organisasi Folder';
@@ -1934,6 +1935,30 @@ class AppLocalizationsId extends AppLocalizations {
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'Bitrate Opus YouTube';
@override
String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube';
@override
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
return '${bitrate}kbps ($min-$max)';
}
@override
String youtubeBitrateInputHelp(int min, int max) {
return 'Masukkan bitrate manual ($min-$max kbps)';
}
@override
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
@override
String youtubeBitrateValidationError(int min, int max) {
return 'Bitrate harus antara $min dan $max kbps';
}
@override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1947,16 +1972,26 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
@override
String get downloadUseAlbumArtistForFolders =>
'Gunakan Album Artist untuk folder';
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Folder artis memakai Album Artist jika tersedia';
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Folder artis hanya memakai Track Artist';
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Simpan Format';
@@ -2195,10 +2230,10 @@ class AppLocalizationsId extends AppLocalizations {
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'Belum ada item terbaru';
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Tampilkan Semua Download';
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
@@ -2307,10 +2342,10 @@ class AppLocalizationsId extends AppLocalizations {
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Penyimpanan & Cache';
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'Lihat ukuran dan bersihkan data cache';
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2585,221 +2620,219 @@ class AppLocalizationsId extends AppLocalizations {
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Metadata, cover art, dan lirik otomatis tertanam';
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Mencari Musik';
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Tempel URL Spotify atau Deezer langsung di kotak pencarian';
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Atau ketik nama lagu, artis, atau album untuk mencari';
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Mendukung lagu, album, playlist, dan halaman artis';
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Mengunduh Musik';
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Mengunduh musik itu mudah dan cepat. Begini caranya.';
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh';
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)';
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Unduh seluruh album atau playlist dengan satu ketukan';
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Perpustakaan Anda';
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'Semua musik yang Anda unduh terorganisir di tab Perpustakaan.';
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'Lihat progres unduhan dan antrian di tab Perpustakaan';
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Ketuk lagu mana pun untuk memutarnya dengan pemutar musik';
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik';
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Ekstensi';
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.';
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Jelajahi tab Toko untuk menemukan ekstensi berguna';
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Tambahkan provider unduhan atau sumber pencarian baru';
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Dapatkan lirik, metadata lebih baik, dan fitur lainnya';
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Sesuaikan Pengalaman Anda';
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.';
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Ubah lokasi unduhan dan organisasi folder';
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Atur kualitas audio dan preferensi format default';
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Sesuaikan tema dan tampilan aplikasi';
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'Anda siap! Mulai unduh musik favorit Anda sekarang.';
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'CONTOH';
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Pindai Ulang Penuh';
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle =>
'Pindai ulang semua file, abaikan cache';
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Bersihkan Entri Unduhan Tidak Valid';
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Hapus entri riwayat untuk file yang tidak ada lagi';
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Menghapus $count entri unduhan tidak valid dari riwayat';
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone =>
'Tidak ada entri unduhan tidak valid';
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Penyimpanan & Cache';
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Ringkasan cache';
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.';
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimasi penggunaan cache: $size';
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Data Cache';
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Perawatan';
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'Direktori cache aplikasi';
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'Respons HTTP, data WebView, dan data sementara aplikasi.';
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Direktori sementara';
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'File sementara dari proses download dan konversi audio.';
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cache gambar cover';
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.';
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Cache cover library';
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.';
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Cache feed Explore';
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.';
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Cache pencocokan lagu';
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.';
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Hapus entri riwayat download dan library yang filenya sudah tidak ada.';
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'Tidak ada data cache';
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size dalam $count file';
return '$size in $count files';
}
@override
@@ -2809,103 +2842,141 @@ class AppLocalizationsId extends AppLocalizations {
@override
String cacheEntries(int count) {
return '$count entri';
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Berhasil dibersihkan: $target';
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Bersihkan cache?';
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'Ini akan membersihkan data cache untuk $target. File musik yang sudah diunduh tidak akan dihapus.';
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Bersihkan semua cache?';
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.';
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Bersihkan semua cache';
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Bersihkan data tidak terpakai';
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Hapus riwayat unduhan yatim dan entri library yang file-nya hilang';
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Pembersihan selesai: $downloadCount unduhan yatim, $libraryCount entri library hilang';
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Segarkan statistik';
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Simpan Cover Art';
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle =>
'Simpan cover album sebagai file .jpg';
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Simpan Lirik (.lrc)';
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle =>
'Ambil dan simpan lirik sebagai file .lrc';
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Perkaya Ulang Metadata';
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Tanamkan ulang metadata tanpa mengunduh ulang';
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Cari metadata dari internet dan tanamkan ke file';
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art disimpan ke $fileName';
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'Tidak ada sumber cover art';
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lirik disimpan ke $fileName';
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Memperkaya ulang metadata...';
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Mencari metadata dari internet...';
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang';
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed =>
'Gagal menanamkan metadata via FFmpeg';
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Gagal: $error';
return 'Failed: $error';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+83 -6
View File
@@ -453,12 +453,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutSupport => 'サポート';
@override
String get aboutBuyMeCoffee => 'コーヒーを買ってください';
@override
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします';
@override
String get aboutApp => 'アプリ';
@@ -1182,6 +1176,13 @@ class AppLocalizationsJa extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'フォルダ構成';
@@ -1910,6 +1911,30 @@ class AppLocalizationsJa extends AppLocalizations {
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
return '${bitrate}kbps ($min-$max)';
}
@override
String youtubeBitrateInputHelp(int min, int max) {
return 'Enter custom bitrate ($min-$max kbps)';
}
@override
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
@override
String youtubeBitrateValidationError(int min, int max) {
return 'Bitrate must be between $min and $max kbps';
}
@override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1933,6 +1958,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => '形式を保存';
@@ -2832,6 +2868,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@@ -2875,4 +2914,42 @@ class AppLocalizationsJa extends AppLocalizations {
String trackSaveFailed(String error) {
return 'Failed: $error';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+95 -19
View File
@@ -13,7 +13,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get appDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Spotify 트랙을 Tidal, Qobuz, Amazon Music에서 무손실 음질로 다운로드하세요.';
@override
String get navHome => 'Home';
@@ -34,32 +34,32 @@ class AppLocalizationsKo extends AppLocalizations {
String get homeTitle => 'Home';
@override
String get homeSearchHint => 'Paste Spotify URL or search...';
String get homeSearchHint => 'Spotify URL을 붙여 넣거나 검색';
@override
String homeSearchHintExtension(String extensionName) {
return 'Search with $extensionName...';
return '$extensionName에서 검색';
}
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
@override
String get homeRecent => 'Recent';
String get homeRecent => '최근 기록';
@override
String get historyTitle => 'History';
String get historyTitle => '기록';
@override
String historyDownloading(int count) {
return 'Downloading ($count)';
return '다운로드 중... $count';
}
@override
String get historyDownloaded => 'Downloaded';
String get historyDownloaded => '다운로드 목록';
@override
String get historyFilterAll => 'All';
@@ -75,7 +75,7 @@ class AppLocalizationsKo extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
other: '${count}tracks',
one: '1 track',
);
return '$_temp0';
@@ -245,14 +245,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsAutoFallback => 'Auto Fallback';
@override
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
@@ -457,12 +456,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -1188,6 +1181,13 @@ class AppLocalizationsKo extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Folder Organization';
@@ -1922,6 +1922,30 @@ class AppLocalizationsKo extends AppLocalizations {
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
return '${bitrate}kbps ($min-$max)';
}
@override
String youtubeBitrateInputHelp(int min, int max) {
return 'Enter custom bitrate ($min-$max kbps)';
}
@override
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
@override
String youtubeBitrateValidationError(int min, int max) {
return 'Bitrate must be between $min and $max kbps';
}
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1945,6 +1969,17 @@ class AppLocalizationsKo extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2846,6 +2881,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@@ -2889,4 +2927,42 @@ class AppLocalizationsKo extends AppLocalizations {
String trackSaveFailed(String error) {
return 'Failed: $error';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+83 -6
View File
@@ -457,12 +457,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -1188,6 +1182,13 @@ class AppLocalizationsNl extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Folder Organization';
@@ -1922,6 +1923,30 @@ class AppLocalizationsNl extends AppLocalizations {
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
return '${bitrate}kbps ($min-$max)';
}
@override
String youtubeBitrateInputHelp(int min, int max) {
return 'Enter custom bitrate ($min-$max kbps)';
}
@override
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
@override
String youtubeBitrateValidationError(int min, int max) {
return 'Bitrate must be between $min and $max kbps';
}
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1945,6 +1970,17 @@ class AppLocalizationsNl extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2846,6 +2882,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@@ -2889,4 +2928,42 @@ class AppLocalizationsNl extends AppLocalizations {
String trackSaveFailed(String error) {
return 'Failed: $error';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+83 -6
View File
@@ -464,12 +464,6 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get aboutSupport => 'Destek';
@override
String get aboutBuyMeCoffee => 'Bana bir kahve ısmarla';
@override
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi üzerinden uygulamayı destekle';
@override
String get aboutApp => 'Uygulama';
@@ -1195,6 +1189,13 @@ class AppLocalizationsTr extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Klasör Organizasyonu';
@@ -1937,6 +1938,30 @@ class AppLocalizationsTr extends AppLocalizations {
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
return '${bitrate}kbps ($min-$max)';
}
@override
String youtubeBitrateInputHelp(int min, int max) {
return 'Enter custom bitrate ($min-$max kbps)';
}
@override
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
@override
String youtubeBitrateValidationError(int min, int max) {
return 'Bitrate must be between $min and $max kbps';
}
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1960,6 +1985,17 @@ class AppLocalizationsTr extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2861,6 +2897,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@@ -2904,4 +2943,42 @@ class AppLocalizationsTr extends AppLocalizations {
String trackSaveFailed(String error) {
return 'Failed: $error';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
File diff suppressed because it is too large Load Diff
+1246 -242
View File
File diff suppressed because it is too large Load Diff
+81 -5
View File
@@ -326,10 +326,6 @@
"@aboutSocial": {"description": "Section for social links"},
"aboutSupport": "Support",
"@aboutSupport": {"description": "Section for support/donation links"},
"aboutBuyMeCoffee": "Buy me a coffee",
"@aboutBuyMeCoffee": {"description": "Donation link"},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {"description": "Subtitle for donation"},
"aboutApp": "App",
"@aboutApp": {"description": "Section for app info"},
"aboutVersion": "Version",
@@ -878,6 +874,14 @@
"@filenameAvailablePlaceholders": {"description": "Label for placeholder list"},
"filenameHint": "{artist} - {title}",
"@filenameHint": {"description": "Default filename format hint"},
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganization": "Folder Organization",
"@folderOrganization": {"description": "Setting title - folder structure"},
@@ -1416,6 +1420,37 @@
"@qualityNote": {"description": "Note about quality availability"},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {"description": "Title for YouTube Opus bitrate setting"},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {"description": "Title for YouTube MP3 bitrate setting"},
"youtubeBitrateSubtitle": "{bitrate}kbps ({min}-{max})",
"@youtubeBitrateSubtitle": {
"description": "Subtitle showing current bitrate and valid range",
"placeholders": {
"bitrate": {"type": "int"},
"min": {"type": "int"},
"max": {"type": "int"}
}
},
"youtubeBitrateInputHelp": "Enter custom bitrate ({min}-{max} kbps)",
"@youtubeBitrateInputHelp": {
"description": "Helper text for manual YouTube bitrate input",
"placeholders": {
"min": {"type": "int"},
"max": {"type": "int"}
}
},
"youtubeBitrateFieldLabel": "Bitrate (kbps)",
"@youtubeBitrateFieldLabel": {"description": "Label for YouTube bitrate input field"},
"youtubeBitrateValidationError": "Bitrate must be between {min} and {max} kbps",
"@youtubeBitrateValidationError": {
"description": "Validation error for invalid YouTube bitrate input",
"placeholders": {
"min": {"type": "int"},
"max": {"type": "int"}
}
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
@@ -1431,6 +1466,12 @@
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
"@downloadUsePrimaryArtistOnly": {"description": "Setting - strip featured artists from folder name"},
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
"@downloadUsePrimaryArtistOnlyEnabled": {"description": "Subtitle when primary artist only is enabled"},
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
"@downloadUsePrimaryArtistOnlyDisabled": {"description": "Subtitle when primary artist only is disabled"},
"downloadSaveFormat": "Save Format",
"@downloadSaveFormat": {"description": "Setting - output file format"},
"downloadSelectService": "Select Service",
@@ -2144,6 +2185,8 @@
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
"trackSaveLyricsProgress": "Saving lyrics...",
"@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"},
"trackReEnrich": "Re-enrich Metadata",
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
@@ -2182,5 +2225,38 @@
"placeholders": {
"error": {"type": "String"}
}
}
},
"trackConvertFormat": "Convert Format",
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
"trackConvertTitle": "Convert Audio",
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
"trackConvertTargetFormat": "Target Format",
"@trackConvertTargetFormat": {"description": "Label for format selection"},
"trackConvertBitrate": "Bitrate",
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
"trackConvertConfirmTitle": "Confirm Conversion",
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
"@trackConvertConfirmMessage": {
"description": "Confirmation dialog message",
"placeholders": {
"sourceFormat": {"type": "String"},
"targetFormat": {"type": "String"},
"bitrate": {"type": "String"}
}
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {"description": "Snackbar while converting"},
"trackConvertSuccess": "Converted to {format} successfully",
"@trackConvertSuccess": {
"description": "Snackbar after successful conversion",
"placeholders": {
"format": {"type": "String"}
}
},
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
}
-8
View File
@@ -548,14 +548,6 @@
"@aboutSupport": {
"description": "Section for support/donation links"
},
"aboutBuyMeCoffee": "Buy me a coffee",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
"aboutApp": "App",
"@aboutApp": {
"description": "Section for app info"
+1276 -19
View File
File diff suppressed because it is too large Load Diff
+1107 -103
View File
File diff suppressed because it is too large Load Diff
+1028 -24
View File
File diff suppressed because it is too large Load Diff

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