Compare commits

...

359 Commits

Author SHA1 Message Date
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
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
zarzet 3a6b7eed59 perf: swap SpotubeDL as primary YouTube provider, Cobalt as fallback 2026-02-10 00:47:44 +07:00
zarzet 51d02d7764 chore: bump app_info version to 3.6.0+77 2026-02-09 23:36:34 +07:00
zarzet df39d61ed4 feat: save cover art, save lyrics, re-enrich metadata with full SAF support + YouTube Cobalt provider with SpotubeDL fallback + metadata summary logging 2026-02-09 23:07:18 +07:00
Zarz Eleutherius 9cd2b1d8c5 New translations app_en.arb (Russian) 2026-02-09 19:41:00 +07:00
Zarz Eleutherius 49f1fb43fa New translations app_en.arb (Spanish) 2026-02-09 19:40:58 +07:00
zarzet 7ec5d28caf feat: add YouTube provider for lossy downloads via Cobalt API
- New YouTube download provider with Opus 256kbps and MP3 320kbps options
- SongLink/Odesli integration for Spotify/Deezer ID to YouTube URL conversion
- YouTube video ID detection for YT Music extension compatibility
- Parallel cover art and lyrics fetching during download
- Queue progress shows bytes (X.X MB) for streaming downloads
- Full metadata embedding: cover, lyrics, title, artist, album, track#, disc#, year, ISRC
- Removed Tidal HIGH (lossy AAC) option - use YouTube for lossy instead
- Bumped version to 3.6.0
2026-02-09 18:15:43 +07:00
zarzet 23f5aa11b0 feat: responsive layout tuning, cache management page, and improved recent access UX
- Add responsive scaling across album, artist, playlist, downloaded album, local album, queue, setup, and tutorial screens to prevent overflow on smaller devices
- Add new Storage & Cache management page (Settings > Storage & Cache) with per-category clear and cleanup actions
- Extract normalizedHeaderTopPadding utility for consistent app bar padding
- Improve home search Recent Access behavior: show when focused with empty input, hide stale results during active recent mode
- Add excluded-downloaded-count tracking to local library scan stats
- Add recentEmpty and recentShowAllDownloads l10n keys (EN + ID)
- Add full cache management l10n keys (EN + ID)
- Fix about_page indentation and formatting consistency
- Fix appearance_settings_page formatting
- Fix downloaded_album_screen and local_album_screen formatting and responsive sizing
2026-02-09 15:58:50 +07:00
zarzet 5fdf1df5df feat: cross-script transliteration matching for Tidal/Qobuz and skip-downloaded option for CSV import 2026-02-09 10:57:52 +07:00
Zarz Eleutherius 65b521ff8b New translations app_en.arb (Turkish) 2026-02-08 19:17:59 +07:00
Zarz Eleutherius 6d578694e2 New translations app_en.arb (Hindi) 2026-02-08 19:17:58 +07:00
Zarz Eleutherius f7ec649b24 New translations app_en.arb (Indonesian) 2026-02-08 19:17:57 +07:00
Zarz Eleutherius 71a9e1baef New translations app_en.arb (Chinese Traditional) 2026-02-08 19:17:56 +07:00
Zarz Eleutherius 4a4adcb72e New translations app_en.arb (Chinese Simplified) 2026-02-08 19:17:55 +07:00
Zarz Eleutherius 3458f03158 New translations app_en.arb (Russian) 2026-02-08 19:17:54 +07:00
Zarz Eleutherius 4fe4a01840 New translations app_en.arb (Portuguese) 2026-02-08 19:17:53 +07:00
Zarz Eleutherius e5d6fddeda New translations app_en.arb (Dutch) 2026-02-08 19:17:52 +07:00
Zarz Eleutherius 370f5e3b8b New translations app_en.arb (Korean) 2026-02-08 19:17:51 +07:00
Zarz Eleutherius f5bb0820d5 New translations app_en.arb (Japanese) 2026-02-08 19:17:50 +07:00
Zarz Eleutherius feb6da3ecb New translations app_en.arb (German) 2026-02-08 19:17:48 +07:00
Zarz Eleutherius 39f28a12aa New translations app_en.arb (Spanish) 2026-02-08 19:17:47 +07:00
Zarz Eleutherius 416fc79637 New translations app_en.arb (French) 2026-02-08 19:17:46 +07:00
Zarz Eleutherius 1f43780bec Update source file app_en.arb 2026-02-08 19:17:44 +07:00
zarzet f9dd82010f fix: skip M4A conversion for existing files and prevent empty SAF folders on duplicates 2026-02-08 15:44:05 +07:00
zarzet f0790b627d perf: optimize album, artist, and playlist screens
- Scope settingsProvider watches with select() for localLibrary flags

- Wrap popular track items in Consumer for scoped provider watches

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

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

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

- Memoize filter/sort computations per build pass

- Isolate queue header/list into dedicated Consumer slivers

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

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

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

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

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

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

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

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

- Also fixes playlists with >25 tracks

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

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

- Add _embedMetadataToOpus() in download queue provider

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

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

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

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

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

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

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

Fix: Convert quality 'MP3' to 'LOSSLESS' before sending to backend,
then convert the downloaded FLAC to MP3 using FFmpeg (existing logic).
2026-01-31 07:53:13 +07:00
Zarz Eleutherius fc9a2ddc2a New translations app_en.arb (German) 2026-01-25 12:23:03 +07:00
Zarz Eleutherius c49e5adc52 New translations app_en.arb (Russian) 2026-01-24 12:05:26 +07:00
Zarz Eleutherius 0fedd446ca New translations app_en.arb (Spanish) 2026-01-24 12:05:25 +07:00
zarzet 0c7b8a68d9 chore: revert version to 3.2.2+66 2026-01-24 09:06:36 +07:00
zarzet 6dd6accbcc chore: ignore Claude local settings file 2026-01-24 09:02:37 +07:00
zarzet ca67f7f79d fix: disable Impeller on legacy/problematic GPUs for stability
Add dynamic GPU detection to use Skia renderer instead of Impeller on:
- Known problematic device models (Nexus 5, Samsung Tab A7 Lite, etc.)
- Problematic chipsets (MSM8974, MT6762, etc.)
- Legacy GPUs (Adreno 300/400, Mali-400/T6, PowerVR SGX, etc.)
- Android versions < 8.0 (API 26)

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

Uses FlutterShellArgs --enable-impeller=false which is the only
reliable method since AndroidManifest meta-data is broken in
Flutter 3.27+ (flutter/flutter#160595)
2026-01-24 09:02:32 +07:00
zarzet 1aa12c5857 feat: add search filter bar for extension custom search
- Add SearchFilter struct in Go backend and Dart
- Add filters array to SearchBehaviorConfig manifest
- Add selectedSearchFilter state to TrackProvider
- Add filter bar UI with FilterChips below search bar
- Filter bar only shows when search results exist or loading
- Preserve selectedSearchFilter during customSearch loading
- Pass filter option to extension customSearch
2026-01-24 08:50:41 +07:00
Zarz Eleutherius ff121dfeb8 New translations app_en.arb (Indonesian) 2026-01-23 11:53:28 +07:00
zarzet c3aa6a441b fix: update Telegram community link in About page 2026-01-22 07:58:55 +07:00
Zarz Eleutherius 496d32e35b New translations app_en.arb (Turkish) 2026-01-22 07:34:38 +07:00
Zarz Eleutherius 291fa58757 New translations app_en.arb (Hindi) 2026-01-22 07:34:37 +07:00
Zarz Eleutherius eddbc2f986 New translations app_en.arb (Indonesian) 2026-01-22 07:34:36 +07:00
Zarz Eleutherius 81b8281d2c New translations app_en.arb (Chinese Traditional) 2026-01-22 07:34:35 +07:00
Zarz Eleutherius 57f87d9a4c New translations app_en.arb (Chinese Simplified) 2026-01-22 07:34:33 +07:00
Zarz Eleutherius c9d0c57d86 New translations app_en.arb (Russian) 2026-01-22 07:34:32 +07:00
Zarz Eleutherius 54ab5a9243 New translations app_en.arb (Portuguese) 2026-01-22 07:34:31 +07:00
Zarz Eleutherius 17b6b27cd7 New translations app_en.arb (Dutch) 2026-01-22 07:34:30 +07:00
Zarz Eleutherius ed131ca1fd New translations app_en.arb (Korean) 2026-01-22 07:34:29 +07:00
Zarz Eleutherius 190d65cdee New translations app_en.arb (Japanese) 2026-01-22 07:34:28 +07:00
Zarz Eleutherius dbf2e337f0 New translations app_en.arb (German) 2026-01-22 07:34:27 +07:00
Zarz Eleutherius 12e76bed4f New translations app_en.arb (Spanish) 2026-01-22 07:34:26 +07:00
Zarz Eleutherius e00db80dae New translations app_en.arb (French) 2026-01-22 07:34:24 +07:00
Zarz Eleutherius 5de0aa8145 Update source file app_en.arb 2026-01-22 07:34:20 +07:00
zarzet 91ffb25027 chore: bump version to 26.2.1+65 (new year.month.day format) 2026-01-22 07:06:15 +07:00
zarzet 6bcbdfedf0 Merge branch 'main' into dev 2026-01-22 07:04:03 +07:00
zarzet ccb8f98df5 fix: use --data-urlencode for Telegram message to handle special chars (+, &) 2026-01-22 04:26:02 +07:00
Zarz Eleutherius 22f52f4af2 New translations app_en.arb (Turkish) 2026-01-22 02:27:30 +07:00
Zarz Eleutherius ceaaff8c9b New translations app_en.arb (Hindi) 2026-01-22 02:27:29 +07:00
Zarz Eleutherius a318495046 New translations app_en.arb (Indonesian) 2026-01-22 02:27:27 +07:00
Zarz Eleutherius 8ffc6d3821 New translations app_en.arb (Chinese Traditional) 2026-01-22 02:27:26 +07:00
Zarz Eleutherius 2036e46da0 New translations app_en.arb (Chinese Simplified) 2026-01-22 02:27:25 +07:00
Zarz Eleutherius b82000e87c New translations app_en.arb (Russian) 2026-01-22 02:27:24 +07:00
Zarz Eleutherius 144906fd8f New translations app_en.arb (Portuguese) 2026-01-22 02:27:23 +07:00
Zarz Eleutherius 8a109e9013 New translations app_en.arb (Dutch) 2026-01-22 02:27:21 +07:00
Zarz Eleutherius ba05f6b470 New translations app_en.arb (Korean) 2026-01-22 02:27:20 +07:00
Zarz Eleutherius 2f80ae7e84 New translations app_en.arb (Japanese) 2026-01-22 02:27:19 +07:00
Zarz Eleutherius e248fef130 New translations app_en.arb (German) 2026-01-22 02:27:18 +07:00
Zarz Eleutherius 174724ddd3 New translations app_en.arb (Spanish) 2026-01-22 02:27:17 +07:00
Zarz Eleutherius 730945d892 New translations app_en.arb (French) 2026-01-22 02:27:15 +07:00
Zarz Eleutherius 4abdce8c58 Update source file app_en.arb 2026-01-22 02:27:13 +07:00
Zarz Eleutherius 0d98ada479 New translations app_en.arb (Turkish) 2026-01-21 02:22:48 +07:00
Zarz Eleutherius 5d4fc10ab7 New translations app_en.arb (Hindi) 2026-01-21 02:22:46 +07:00
Zarz Eleutherius e37dfeb080 New translations app_en.arb (Indonesian) 2026-01-21 02:22:45 +07:00
Zarz Eleutherius eddae2a9dd New translations app_en.arb (Chinese Traditional) 2026-01-21 02:22:44 +07:00
Zarz Eleutherius 6bd7eec615 New translations app_en.arb (Chinese Simplified) 2026-01-21 02:22:43 +07:00
Zarz Eleutherius b240e91290 New translations app_en.arb (Russian) 2026-01-21 02:22:42 +07:00
Zarz Eleutherius 4e0149df29 New translations app_en.arb (Portuguese) 2026-01-21 02:22:41 +07:00
Zarz Eleutherius 065872e686 New translations app_en.arb (Dutch) 2026-01-21 02:22:39 +07:00
Zarz Eleutherius 7ab0f5b7c8 New translations app_en.arb (Korean) 2026-01-21 02:22:38 +07:00
Zarz Eleutherius fd31682242 New translations app_en.arb (Japanese) 2026-01-21 02:22:37 +07:00
Zarz Eleutherius 56c8b62fcf New translations app_en.arb (German) 2026-01-21 02:22:36 +07:00
Zarz Eleutherius c3f879346a New translations app_en.arb (Spanish) 2026-01-21 02:22:35 +07:00
Zarz Eleutherius 6da65ed033 New translations app_en.arb (French) 2026-01-21 02:22:34 +07:00
Zarz Eleutherius 553c6b6c4a Update source file app_en.arb 2026-01-21 02:22:31 +07:00
Zarz Eleutherius a32487ad88 New translations app_en.arb (Hindi) 2026-01-20 02:16:58 +07:00
Zarz Eleutherius bd4946db37 New translations app_en.arb (Indonesian) 2026-01-20 02:16:57 +07:00
Zarz Eleutherius 69f143dd9d New translations app_en.arb (Chinese Traditional) 2026-01-20 02:16:56 +07:00
Zarz Eleutherius 15408bfa1c New translations app_en.arb (Chinese Simplified) 2026-01-20 02:16:55 +07:00
Zarz Eleutherius edc715021d New translations app_en.arb (Russian) 2026-01-20 02:16:54 +07:00
Zarz Eleutherius 392472b027 New translations app_en.arb (Portuguese) 2026-01-20 02:16:53 +07:00
Zarz Eleutherius 69741fa47c New translations app_en.arb (Dutch) 2026-01-20 02:16:52 +07:00
Zarz Eleutherius 484720bcda New translations app_en.arb (Korean) 2026-01-20 02:16:51 +07:00
Zarz Eleutherius f3cc51fb06 New translations app_en.arb (Japanese) 2026-01-20 02:16:50 +07:00
Zarz Eleutherius 452ea7084a New translations app_en.arb (German) 2026-01-20 02:16:49 +07:00
Zarz Eleutherius bba059fc44 New translations app_en.arb (Spanish) 2026-01-20 02:16:48 +07:00
Zarz Eleutherius 3f75cace2b New translations app_en.arb (French) 2026-01-20 02:16:47 +07:00
177 changed files with 78009 additions and 16326 deletions
-1
View File
@@ -1,4 +1,3 @@
github: zarzet github: zarzet
ko_fi: zarzet ko_fi: zarzet
buy_me_a_coffee: zarzet
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 2 # Need previous commit to compare fetch-depth: 2 # Need previous commit to compare
+44
View File
@@ -0,0 +1,44 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
paths:
- 'site/**'
- '.github/workflows/pages.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
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
+18 -18
View File
@@ -60,23 +60,23 @@ jobs:
df -h df -h
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: "temurin" distribution: "temurin"
java-version: "17" java-version: "17"
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version: "1.25" go-version: "1.26"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds # Cache Gradle for faster builds
- name: Cache Gradle - name: Cache Gradle
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -158,7 +158,7 @@ jobs:
ls -la ls -la
- name: Upload APK artifact - name: Upload APK artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: android-apk name: android-apk
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
@@ -169,17 +169,17 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version: "1.25" go-version: "1.26"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache CocoaPods # Cache CocoaPods
- name: Cache CocoaPods - name: Cache CocoaPods
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ios/Pods path: ios/Pods
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }} key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
@@ -295,7 +295,7 @@ jobs:
fi fi
- name: Upload IPA artifact - name: Upload IPA artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: ios-ipa name: ios-ipa
path: build/ios/ipa/SpotiFLAC-*.ipa path: build/ios/ipa/SpotiFLAC-*.ipa
@@ -308,7 +308,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Extract changelog for version - name: Extract changelog for version
id: changelog id: changelog
@@ -338,13 +338,13 @@ jobs:
cat /tmp/changelog.txt cat /tmp/changelog.txt
- name: Download Android APK - name: Download Android APK
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: android-apk name: android-apk
path: ./release path: ./release
- name: Download iOS IPA - name: Download iOS IPA
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: ios-ipa name: ios-ipa
path: ./release path: ./release
@@ -385,7 +385,7 @@ jobs:
cat /tmp/release_body.txt cat /tmp/release_body.txt
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ needs.get-version.outputs.version }} tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }} name: SpotiFLAC ${{ needs.get-version.outputs.version }}
@@ -403,16 +403,16 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Download Android APK - name: Download Android APK
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: android-apk name: android-apk
path: ./release path: ./release
- name: Download iOS IPA - name: Download iOS IPA
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: ios-ipa name: ios-ipa
path: ./release path: ./release
+607 -33
View File
@@ -1,5 +1,609 @@
# Changelog # Changelog
## [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
- **YouTube Provider (Lossy)**: New download option via Cobalt API for tracks not available on lossless services
- Opus 256kbps (recommended) or MP3 320kbps quality options
- Full metadata embedding: cover art, title, artist, album, track/disc number, year, ISRC
- Lyrics fetching from lrclib.net with embed and external .lrc support
- Works as fallback when Tidal/Qobuz/Amazon downloads fail
- **Edit Metadata**: Edit embedded metadata directly from the Track Metadata screen (FLAC, MP3, Opus)
- Editable fields: Title, Artist, Album, Album Artist, Date, Track#, Disc#, Genre, ISRC
- Advanced fields: Label, Copyright, Composer, Comment
- FLAC: native Go writer, MP3/Opus: FFmpeg-based writer
- UI refreshes in-place after save without needing to re-open the screen
- iOS and Android support
### Added
- Save Cover Art: download high-quality album art as standalone .jpg from track metadata screen
- Save Lyrics (.lrc): fetch and save lyrics as standalone .lrc file without downloading the song
- Re-enrich Metadata: re-embed metadata, cover art, and lyrics into existing audio files without re-downloading (FLAC native, MP3/Opus via FFmpeg)
- Re-enrich now supports local library items: searches Spotify/Deezer by track name + artist to fetch complete metadata from the internet, then embeds cover art, lyrics, genre, label, and all tags into the file
- YouTube download provider using Cobalt API with SongLink/Odesli integration for Spotify/Deezer ID → YouTube URL conversion
- SpotubeDL as fallback Cobalt proxy when primary API fails
- YouTube video ID detection for YT Music extension compatibility
- Parallel cover art and lyrics fetching during YouTube download
- Queue progress now shows "X.X MB" instead of "0%" for streaming downloads where total size is unknown (Cobalt tunnel mode)
- Full metadata pipeline for YouTube downloads: cover art, lyrics, title, artist, album, track#, disc#, year, ISRC
### Changed
- Removed Tidal HIGH (lossy AAC) quality option - use YouTube provider for lossy downloads instead
- Simplified download service picker by removing dead lossy format code
- Removed Amazon from download settings UI (now only used as automatic fallback)
- Cleaned up dead disabled-chip code in download service selector
### Fixed
- Fixed `error.api.youtube.login` by using YouTube Music URLs instead of regular YouTube URLs for Cobalt requests
- Fixed SongLink to prioritize `youtubeMusic` platform URL over `youtube` for Cobalt compatibility
- Fixed YouTube metadata not being overwritten by setting `DisableMetadata: true` in Cobalt requests
- Fixed ISRC validation in metadata enrichment flow - invalid ISRCs no longer trigger failed Deezer lookups
- Fixed YouTube metadata enrichment to work like other providers (SongLink Deezer ID extraction, proper metadata embedding)
- Go metadata parsers now read Composer, Comment, Label, Copyright from FLAC, MP3 (ID3v2.2/v2.3/v2.4), and Opus/OGG files
- Added proper COMM frame parser for ID3v2 (handles language code + description prefix correctly)
- Fixed Re-enrich Metadata failing on SAF storage files (`content://` URIs) - Kotlin now copies SAF file to temp, Go processes temp file, then writes back for FLAC or returns temp path for FFmpeg (MP3/Opus)
- Fixed Save Cover Art and Save Lyrics crashing on SAF-stored download history items - now saves to temp then writes to SAF tree via `createSafFileFromPath`
- Fixed `_getFileDirectory()` crash when called with `content://` URI by adding SAF guard
- Fixed `readAudioMetadata` Kotlin handler not handling SAF URIs - now copies to temp for reading
- Added metadata summary log in Re-enrich flow showing all fields before embedding (title, artist, album, track#, disc#, date, ISRC, genre, label)
---
## [3.5.3] - 2026-02-09
### Added
- CSV import flow now includes a new option: **Skip already downloaded songs** before enqueueing tracks
- Added regression test suite for cross-script matching behavior in Go backend (`go_backend/matching_test.go`)
### Changed
- CSV import confirmation dialog now supports filtering out tracks already present in download history (matched by Spotify ID and ISRC)
- CSV import enqueue feedback now reports added/skipped counts when duplicate downloads are skipped
- Home search now prioritizes **Recent Access** when search field is focused with empty input, even if old search results still exist in memory
- Search filter/result sections are now hidden while Recent Access mode is active to avoid stale-result overlap
- Recent Access now shows a localized empty-state message when no recent items are available
- Normalized collapsing AppBar top inset across iOS/Android so header height/animation stays visually consistent on Apple devices
- Storage & Cache UX improved: `Clear all cache` now preserves web/runtime cache by default (optional), with explicit warnings/actions for runtime cache resets
- Local library settings now include a display count for tracks excluded because they already exist in download history
- Responsive layout tuning applied across key screens to reduce hardcoded-height overflow issues on smaller devices
### Fixed
- Fixed false-positive cross-script matching in Qobuz/Tidal where unrelated titles/artists in different scripts could be incorrectly accepted
- Cross-script title/artist matching now requires transliteration-aware normalization and strict similarity checks instead of auto-accepting script differences
- Qobuz metadata fallback no longer scans all results when zero title matches are found; title verification is now required
- Qobuz metadata final validation now rejects results when title does not match expected track name
- Fixed Home search regression where Recent Access panel could disappear after previous searches
- Fixed Local Library card/layout crash caused by `Flex` usage under unbounded height constraints
- Hardened FFmpeg metadata embedding temp-file naming to prevent rare collisions during parallel downloads/fallback flows (Qobuz → Tidal) that could cause missing embedded metadata
- Fixed SAF external lyrics naming where some providers saved `.lrc` files as `.lrc.txt`; LRC export now uses neutral MIME to preserve `.lrc` extension
## [3.5.2] - 2026-02-08
### Performance
- Home tab search result sections are now virtualized with `SliverList` (lazy item build) instead of eager `Column` rendering, reducing frame drops on large result sets
- Home tab now narrows Riverpod subscriptions using field-level `select(...)` for search/provider state to reduce unnecessary full-tab rebuilds
- Search provider dropdown now watches only required fields (`searchProvider`, `metadataSource`, `extensions`) instead of full provider states
- Track row rendering in Home search now receives precomputed thumbnail sizing/local-library flags from parent to avoid repeated per-item provider watches
- Removed thumbnail `debugPrint` calls inside track row `build()` to reduce runtime overhead during scrolling/rebuilds
- Queue tab root subscription no longer watches full queue item list; it now watches only queue presence (`items.isNotEmpty`) to avoid full Library UI rebuilds on every progress tick
- Queue download header/list rendering has been isolated into dedicated `Consumer` slivers; header now watches only queue length (`items.length`) while item list watches queue item updates
- Queue filter/sort computations are now centralized and memoized per filter mode within a build pass (`all`/`albums`/`singles`), reducing repeated list transforms for chip counts and page content
- Selection bottom bar content is now computed only when selection mode is active, removing hidden-state heavy list preparation
- File existence checks in queue/library rows now use per-path `ValueNotifier` + `ValueListenableBuilder` updates instead of triggering global `setState`, reducing unnecessary whole-tab repaints
### Changed
- Replaced date range filter with sorting options in Library tab: Latest, Oldest, A-Z, Z-A
- Sorting applies to all views: unified items, downloaded albums, and local library albums
- Local library items now use file modification time (`fileModTime`) for sorting instead of scan time, providing more accurate chronological ordering
- Removed redundant manual "Export Failed Downloads" button from Library UI (auto-export setting in Settings is sufficient)
- Library filters (quality, format, source) now correctly apply to album tabs and update tab chip counts (All/Albums/Singles)
### Fixed
- Fixed local library scan crashing on Samsung One UI devices due to MediaStore URI mismatch in SAF tree traversal
- Added MediaStore URI fallback in SAF file reader: when SAF permission is denied for Samsung-returned MediaStore URIs, automatically retries using READ_MEDIA_AUDIO permission
- Hardened SAF scan with per-directory and per-file error handling: scan now skips problematic files instead of aborting entirely
- Added visited directory tracking to prevent infinite loops from circular SAF references
- Fixed metadata enrichment cascading failure after one queued download fails: metadata APIs (Deezer, SongLink, Spotify) now use isolated `metadataTransport` so failed download connections cannot poison metadata requests
- Added immediate connection cleanup on every download failure path (error response and exception), not only periodic cleanup every N downloads
- Fixed incremental SAF scan edge case where `lastModified()` failure could misclassify existing files as removed (`removedUris`)
- Fixed tracks marked "In Library" still showing active download button - download button now shows as completed (checkmark) for local library tracks across all screens (album, playlist, artist, home/search)
- Fixed FFmpeg M4A-to-FLAC conversion erroneously triggered on already-existing FLAC files when re-downloading duplicates via Tidal
- Fixed SAF download creating empty artist/album folders when re-downloading duplicate tracks; directory is now only created after confirming the file does not already exist
## [3.5.1] - 2026-02-08
### Performance
- Removed PaletteService (palette_generator) from all screens for faster navigation and reduced memory usage
- Album, Playlist, Downloaded Album, Local Album, and Track Metadata screens now use blurred cover art as header background instead of dominant color extraction
- Removed `palette_generator` dependency
- App startup now renders immediately (`runApp`) while service initialization runs asynchronously in eager init
- Main shell provider subscriptions now use field-level `select(...)` to reduce unnecessary rebuilds
- Settings persistence now uses single-flight + queued save coalescing to avoid redundant disk writes
- Progress polling cadence adjusted to 800ms for download queue, local library scan progress, and Go log polling
- Android foreground download service progress updates are throttled (change-based updates + 5s heartbeat)
- SAF history repair is now batched (`20` items per batch) and capped per launch (`60`) to reduce startup I/O spikes
- Incremental library scan now builds final item list in-memory instead of reloading from database
- Local cover images in queue/library use direct `Image.file` with `errorBuilder` instead of `FutureBuilder` existence check
- CSV parser `_parseLine` rewritten: correct escaped-quote handling, no quote characters in output
- Removed unused legacy screen files (`home_screen.dart`, `queue_screen.dart`, `settings_screen.dart`, `settings_tab.dart`)
- Incremental local library scan now merges delta results in-memory and sorts once, avoiding full-state reload churn
- Queue local cover rendering now uses direct `Image.file` + `errorBuilder` (removed repeated async file-exists checks)
### Added
- Auto-cleanup orphaned downloads on history load (files that no longer exist are automatically removed from history)
### Changed
- Removed legacy screen files that were no longer used after the tab/part refactor:
- `lib/screens/home_screen.dart`
- `lib/screens/queue_screen.dart`
- `lib/screens/settings_screen.dart`
- `lib/screens/settings_tab.dart`
- Concurrent download limit increased from `3` to `5` (settings clamp + Options UI chips now support `1..5`)
- Download queue now uses a single parallel scheduler path; `1` concurrency is handled as parallel-with-limit-1 (no separate sequential engine)
- Download queue now listens to settings updates in real-time so concurrency/output settings stay in sync while queue is active
### Fixed
- CSV parser now correctly handles escaped quotes (`""`) inside quoted fields during import
- Fixed dynamic concurrency update during active downloads: changing limit (e.g. `1 -> 3`) now schedules additional queued items without waiting current active item to finish
- Queue scheduler now re-checks capacity/queued items on short intervals to avoid blocking on long-running single active download
### Dependencies
#### Flutter
- `flutter_local_notifications` 19.x → 20.0.0 (breaking: all positional params converted to named params)
- `connectivity_plus` 6.x → 7.0.0
- `flutter_secure_storage` 9.x → 10.0.0
- Removed `palette_generator` dependency
#### Go
- `go-flac/go-flac` v1.0.0 → v2.0.4
- `go-flac/flacvorbis` v0.2.0 → v2.0.2
- `go-flac/flacpicture` v0.3.0 → v2.0.2
- Go toolchain 1.24 → 1.25.7
#### Android
- Android Gradle Plugin 8.x → 9.0.0
- Kotlin 2.1.x → 2.3.10
- `desugar_jdk_libs` → 2.1.5
- `kotlinx-coroutines-android` → 1.10.2
- `lifecycle-runtime-ktx` → 2.10.0
- `activity-ktx` → 1.12.3
#### CI/CD
- `actions/cache` v4 → v5
- `actions/checkout` v4 → v6
- `actions/setup-go` v5 → v6
- `actions/setup-java` v4 → v5
- `softprops/action-gh-release` v1 → v2
- GitHub artifact actions updated
---
## [3.5.0] - 2026-02-07
### Highlights
- **SAF Storage (Android 10+)**: Proper Storage Access Framework support for download destination (content URIs)
- Select download folder via SAF tree picker
- Downloads now write to SAF file descriptors (`/proc/self/fd/*`) instead of raw filesystem paths
- Works around Android 10+ scoped storage permission errors
- **Modern Onboarding Experience**: Completely redesigned Setup and Tutorial screens
### Added
- Home feed disk caching via SharedPreferences for instant restore on app startup
- SAF display path resolver in native Android layer (converts tree URIs to readable paths)
- New settings fields for storage mode + SAF tree URI
- SAF platform bridge methods: pick tree, stat/exists/delete, open content URI, copy to temp, write back to SAF
- SAF library scan mode (DocumentFile traversal + metadata read)
- Incremental library scanning for filesystem and SAF paths (only scans new/modified files and detects removed files)
- Force Full Scan action in Library Settings to rescan all files on demand
- Downloaded files are now excluded from Local Library scan results to prevent duplicate entries
- Legacy library rows now support `file_mod_time` backfill before incremental scans (faster follow-up scans after upgrade)
- Library UI toggle to show SAF-repaired history items
- Scan cancelled banner + retry action for library scans
- Android DocumentFile dependency for SAF operations
- Post-processing API v2 (SAF-aware, ready to replace v1)
- Donate page in Settings with Ko-fi and Buy Me a Coffee links
- Per-App Language support on Android 13+ (locale_config.xml)
- Interactive tutorial with working search bar simulation and clickable download buttons
- Tutorial completion state is persisted after onboarding
- Visual feedback animations for page transitions, entrance effects, and feature lists
- New dedicated welcome step in setup wizard with improved branding
### Changed
- Download pipeline supports `output_path` + `output_ext` for Go backend
- Tidal/Qobuz/Amazon/Extension downloads use SAF-aware output when enabled
- Post-processing hooks run for SAF content URIs (via temp file bridge)
- File operations in Library/Queue/Track screens now SAF-aware (`open`, `exists`, `delete`, `stat`)
- Local Library scan defaults to incremental mode; full rescan is available via Force Full Scan
- Local library database upgraded to schema v3 with `file_mod_time` tracking for incremental scan cache
- Platform channels expanded with incremental scan APIs (`scanLibraryFolderIncremental`) on Android and iOS
- Android platform channel adds `getSafFileModTimes` for SAF legacy cache backfill
- Android build tooling upgraded to Gradle 9.3.1 (wrapper)
- Android build path validated with Java 25 (Gradle/Kotlin/assemble debug)
- SAF tree picker flow in `MainActivity` migrated to Activity Result API (`registerForActivityResult`)
- `MainActivity` host migrated to `FlutterFragmentActivity` for SAF picker compatibility
- Legacy `startActivityForResult` / `onActivityResult` SAF picker path removed
- Setup screen UI polish: smaller logo, thin outline borders on text fields
- Removed support section from About page (moved to Donate page)
- Qobuz squid.wtf region fallback for blocked regions
- Setup screen converted to PageView flow with animated progress bar and modern card layouts
- Tutorial screen aligned with Setup Screen design, updated typography and softened UI shapes
- Larger, more accessible navigation buttons for onboarding flow
- Reduced visual noise by removing unnecessary glow effects
### Fixed
- Android 10+ `permission denied` when writing to `/storage/emulated/0` (now handled via SAF)
- SAF history repair: auto-resolve missing content URIs using tree + filename
- SAF download fallback: retry in app-private storage when SAF write fails
- Tidal DASH manifest writing when output path is a file descriptor (no invalid `.m4a` path)
- External LRC output in SAF mode
- Restored old-device renderer fallback while using `FlutterFragmentActivity` by injecting shell args from a custom `FlutterFragment` (`--enable-impeller=false` on problematic devices)
- Preserved Flutter fragment creation behavior (cached engine, engine group, new engine) while adding Impeller fallback support
- SAF tree picker result now consistently returns `tree_uri` payload with persisted URI permission handling
- SAF share file now copies to temp before sharing (fixes share from SAF content URI)
- Home feed not updating after installing extension with homeFeed capability (no longer requires app restart)
- Library scan hero card showing 0 tracks during scan (now shows scanned file count in real-time)
- Library folder picker no longer requires MANAGE_EXTERNAL_STORAGE on Android 10+ (uses SAF tree picker)
- One-time SAF migration prompt for users updating from pre-SAF versions
- Fixed `fileModTime` propagation across Go/Android/Dart so incremental scan cache is stored and reused correctly
- Fixed SAF incremental scan key mismatch (`lastModified` vs `fileModTime`) and normalized result fields (`skippedCount`, `totalFiles`)
- Fixed incremental scan progress when all files are skipped (`scanned_files` now reaches total files)
- Removed duplicate `"removeExtension"` branch in Android method channel handler (eliminates Kotlin duplicate-branch warning)
---
## [3.4.2] - 2026-02-04
### Improved
- **Mobile Network Reliability**: All providers (Qobuz, Tidal, Amazon, Deezer) now have retry logic with exponential backoff
- Increased API timeouts: 15s → 25s (Deezer, Qobuz, Tidal), 30s (Amazon)
- Up to 3 retry attempts per API call (500ms → 1s → 2s backoff)
- Retryable: timeout, connection reset/refused, EOF, HTTP 5xx, HTTP 429
- **SongLink ID Extraction**: Extract QobuzID/TidalID directly from SongLink URLs
- New fields in `TrackAvailability`: `QobuzID`, `TidalID`
- Qobuz/Tidal now use direct Track ID from SongLink instead of re-parsing URLs
- **Qobuz Download Flow**: New Strategy 3 - get QobuzID from SongLink before ISRC search
- Cache hit now uses `GetTrackByID()` directly instead of searching again
- Pre-warm cache tries SongLink first before direct ISRC search
- **Tidal Download Flow**: Use `availability.TidalID` directly from SongLink struct
---
## [3.4.1] - 2026-02-04
### Fixed
- Metadata Priority order now persists after app restart
- Download Provider Priority order now persists after app restart
---
## [3.4.0] - 2026-02-03
### Highlights
- **Local Library Scanning** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
- **Duplicate Detection** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
- **Unified Library Tab**: History renamed to Library, shows Downloaded + Local Library tracks with source badges
### Added
- Local Album Screen with cover art, disc grouping, and selection mode
- Albums tab shows local library albums with folder icon badge
- Singles filter includes local library singles
- Advanced library filters: Source, Quality, Format, Date
- Cover art extraction from embedded tags (FLAC, MP3, Opus/Ogg)
- "Already in Library" notification when downloading existing tracks
- Spotify secrets now stored in secure storage (`flutter_secure_storage`)
- **Multi-Service Link Support**: Share links from Deezer, Tidal, and YouTube Music (in addition to Spotify)
- Deezer: Full support for track, album, playlist, artist links
- Tidal: Track links converted via SongLink to Spotify/Deezer for metadata
- YouTube Music: Handled via ytmusic extension URL handler
- Local library tracks now open metadata screen on tap
### Changed
- Extension HTTP sandbox enforces HTTPS and blocks private IPs
- Extension file sandbox validates paths with boundary-safe checks
### Fixed
- Search filter bar now only appears after results load, not during loading
- MP3/Ogg metadata parsing (ID3v2 extended headers, Ogg packet reassembly)
- Library scan metadata (ISRC, disc number, release date)
- Cover cache robustness (size + mtime cache key)
- Local library selection and delete in list/grid views
- Albums/Singles count includes local library items
---
## [3.3.6] - 2026-02-02
### Added
- **WiFi-Only Download Mode**: Pause downloads on mobile data, auto-resume on WiFi (Settings > Download > Download Network)
- Added `connectivity_plus: ^6.0.3` dependency
---
## [3.3.5] - 2026-02-01
Same as 3.3.1 but fixes crash issues caused by FFmpeg.
### Added
- **Export Failed Downloads**: Export failed downloads to TXT file for easy lookup on other platforms
- **Auto-Export Setting**: Option to automatically export failed downloads when queue finishes
### Fixed
- **FFmpeg Crash**: Fixed crash issues during M4A to MP3/Opus conversion
- **Service Selection Ignored**: Fixed bug where selecting Qobuz/Amazon from service picker was ignored and always used Tidal instead
- **iOS iCloud Drive Permission Error**: Block iCloud Drive folder selection on iOS (Go backend cannot access iCloud due to sandboxing)
### Changed
- **Amazon Fallback Only**: Amazon Music is now grayed out in service picker and can only be used as fallback provider
---
## [3.3.1] - 2026-02-01 ## [3.3.1] - 2026-02-01
### Added ### Added
@@ -9,7 +613,7 @@
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps) - **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists) - **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
- **Album/Playlist Search**: Deezer search now includes albums and playlists - **Album/Playlist Search**: Deezer search now includes albums and playlists
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re*Index.(ot_inc)) - **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re\*Index.(ot_inc))
- **Optional All Files Access**: Android 13+ no longer requires full storage access; enable in Settings if needed - **Optional All Files Access**: Android 13+ no longer requires full storage access; enable in Settings if needed
- **Improved VPN Compatibility**: Better HTTP/2 support for users behind VPN or restricted networks - **Improved VPN Compatibility**: Better HTTP/2 support for users behind VPN or restricted networks
@@ -110,31 +714,26 @@
- Perfect for players like Samsung Music that prefer external .lrc files - Perfect for players like Samsung Music that prefer external .lrc files
- LRC files include metadata headers (title, artist, by:SpotiFLAC-Mobile) - LRC files include metadata headers (title, artist, by:SpotiFLAC-Mobile)
- Works with all download services (Tidal, Qobuz, Amazon) - Works with all download services (Tidal, Qobuz, Amazon)
- **CSV Import Quality Selection**: Choose audio quality when importing CSV playlists - **CSV Import Quality Selection**: Choose audio quality when importing CSV playlists
- Quality picker now appears before adding CSV tracks to download queue - Quality picker now appears before adding CSV tracks to download queue
- Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3 - Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3
- Respects "Ask quality before download" setting - uses default quality if disabled - Respects "Ask quality before download" setting - uses default quality if disabled
- **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory - **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory
- Cover images no longer disappear when app is closed or device restarts - Cover images no longer disappear when app is closed or device restarts
- Cache stored in `app_flutter/cover_cache/` directory (not cleared by system) - Cache stored in `app_flutter/cover_cache/` directory (not cleared by system)
- Maximum 1000 images cached for up to 365 days - Maximum 1000 images cached for up to 365 days
- Covers are cached when displayed in History, Home, Album, Artist, or any other screen - Covers are cached when displayed in History, Home, Album, Artist, or any other screen
- New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management - New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management
- **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer - **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer
- New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre` - New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre`
- Metadata fetched during `enrichTrack()` via Deezer album API - Metadata fetched during `enrichTrack()` via Deezer album API
- Embedded as FLAC Vorbis comments: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT` - Embedded as FLAC Vorbis comments: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT`
- Works for both extension downloads and built-in provider downloads (Tidal, Qobuz, Amazon) - Works for both extension downloads and built-in provider downloads (Tidal, Qobuz, Amazon)
- **Track Metadata Screen Extended Info**: Genre, label, and copyright now displayed in track metadata screen - **Track Metadata Screen Extended Info**: Genre, label, and copyright now displayed in track metadata screen
- Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model - Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model
- Metadata is stored in download history and persists across app restarts - Metadata is stored in download history and persists across app restarts
- New localization strings: `trackGenre`, `trackLabel`, `trackCopyright` - New localization strings: `trackGenre`, `trackLabel`, `trackCopyright`
- `**utils.randomUserAgent()` for Extensions\*\*: New utility function for extensions to get random browser User-Agent strings
- **`utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings
- Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0` - Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0`
- Useful for extensions that need to rotate User-Agents to avoid detection - Useful for extensions that need to rotate User-Agents to avoid detection
@@ -143,7 +742,6 @@
- **Portuguese Language Bug**: Fixed locale parsing for languages with country codes (e.g., pt_PT, es_ES) - **Portuguese Language Bug**: Fixed locale parsing for languages with country codes (e.g., pt_PT, es_ES)
- App now correctly loads Portuguese and Spanish translations - App now correctly loads Portuguese and Spanish translations
- Updated Portuguese label to "Português (Brasil)" - Updated Portuguese label to "Português (Brasil)"
- **VM Race Condition Panic**: Fixed `panic during execution: runtime error: index out of range [-2]` crash when switching search providers - **VM Race Condition Panic**: Fixed `panic during execution: runtime error: index out of range [-2]` crash when switching search providers
- Root cause: Goja VM was being accessed concurrently by multiple goroutines without synchronization - Root cause: Goja VM was being accessed concurrently by multiple goroutines without synchronization
- Added `VMMu sync.Mutex` to `LoadedExtension` struct - Added `VMMu sync.Mutex` to `LoadedExtension` struct
@@ -152,16 +750,13 @@
- `EnrichTrack`, `CheckAvailability`, `GetDownloadURL`, `Download` - `EnrichTrack`, `CheckAvailability`, `GetDownloadURL`, `Download`
- `CustomSearch`, `HandleURL`, `MatchTrack`, `PostProcess` - `CustomSearch`, `HandleURL`, `MatchTrack`, `PostProcess`
- Prevents race conditions when rapidly switching between extension search providers - Prevents race conditions when rapidly switching between extension search providers
- **Tidal Release Date Fallback**: Fixed missing release date in FLAC metadata when downloading from Tidal - **Tidal Release Date Fallback**: Fixed missing release date in FLAC metadata when downloading from Tidal
- Now uses Tidal API's release date when `req.ReleaseDate` is empty - Now uses Tidal API's release date when `req.ReleaseDate` is empty
- Ensures release date is always embedded in downloaded files - Ensures release date is always embedded in downloaded files
- **Extended Metadata for M4A→FLAC Conversion**: Fixed genre, label, and copyright not being embedded when converting Amazon M4A to FLAC - **Extended Metadata for M4A→FLAC Conversion**: Fixed genre, label, and copyright not being embedded when converting Amazon M4A to FLAC
- Flutter now extracts extended metadata from Go backend response - Flutter now extracts extended metadata from Go backend response
- Passes `genre`, `label`, `copyright` parameters to `_embedMetadataAndCover()` - Passes `genre`, `label`, `copyright` parameters to `_embedMetadataAndCover()`
- Tags correctly embedded during FFmpeg conversion - Tags correctly embedded during FFmpeg conversion
- **Extended Metadata for MP3 Conversion**: Genre, label, and copyright now embedded in MP3 files when converting from FLAC - **Extended Metadata for MP3 Conversion**: Genre, label, and copyright now embedded in MP3 files when converting from FLAC
- Added `genre`, `label`, `copyright` parameters to `_embedMetadataToMp3()` - Added `genre`, `label`, `copyright` parameters to `_embedMetadataToMp3()`
- Tags embedded as ID3v2: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT` - Tags embedded as ID3v2: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT`
@@ -222,7 +817,6 @@
- `go_backend/httputil.go`: Updated `getRandomUserAgent()` to use modern Chrome versions - `go_backend/httputil.go`: Updated `getRandomUserAgent()` to use modern Chrome versions
- `go_backend/tidal.go`: Added release date fallback logic - `go_backend/tidal.go`: Added release date fallback logic
- `go_backend/exports.go`: Added `Genre`, `Label`, `Copyright` fields to `DownloadResponse` - `go_backend/exports.go`: Added `Genre`, `Label`, `Copyright` fields to `DownloadResponse`
- **Flutter Changes**: - **Flutter Changes**:
- `lib/services/cover_cache_manager.dart`: New persistent cache manager for cover images (365 days, 1000 images max) - `lib/services/cover_cache_manager.dart`: New persistent cache manager for cover images (365 days, 1000 images max)
- `lib/widgets/cached_cover_image.dart`: Wrapper widget for CachedNetworkImage with persistent cache - `lib/widgets/cached_cover_image.dart`: Wrapper widget for CachedNetworkImage with persistent cache
@@ -247,7 +841,6 @@
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125)) - Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro)) - Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot)) - Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching - **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
- Tap the search icon to reveal a dropdown menu with all available search providers - Tap the search icon to reveal a dropdown menu with all available search providers
- Shows default provider (Deezer based on metadata source setting) at the top - Shows default provider (Deezer based on metadata source setting) at the top
@@ -257,56 +850,46 @@
- Search hint text updates immediately when switching providers - Search hint text updates immediately when switching providers
- Re-triggers search automatically if there's existing text in the search bar - Re-triggers search automatically if there's existing text in the search bar
- Eliminates need to navigate to Settings > Extensions > Search Provider - Eliminates need to navigate to Settings > Extensions > Search Provider
- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions - **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
- Extensions can define `button` type in manifest settings - Extensions can define `button` type in manifest settings
- Triggers JavaScript function when tapped (e.g., start OAuth flow) - Triggers JavaScript function when tapped (e.g., start OAuth flow)
- Useful for authentication, manual sync, or any custom action - Useful for authentication, manual sync, or any custom action
- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information - **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
- Fetches genre and label from Deezer album API for each track - Fetches genre and label from Deezer album API for each track
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files - Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
- Works automatically when Deezer track ID is available (via ISRC matching) - Works automatically when Deezer track ID is available (via ISRC matching)
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads - Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion - **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality - New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options - When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
- Available in both the quality picker dialog and default quality settings - Available in both the quality picker dialog and default quality settings
- Works with all services (Tidal, Qobuz, Amazon) and extensions - Works with all services (Tidal, Qobuz, Amazon) and extensions
- **MP3 Metadata Embedding**: Full metadata support for MP3 files - **MP3 Metadata Embedding**: Full metadata support for MP3 files
- Cover art embedded using ID3v2 tags - Cover art embedded using ID3v2 tags
- Synced lyrics embedded (fetched from lrclib.net) - Synced lyrics embedded (fetched from lrclib.net)
- All metadata preserved: title, artist, album, album artist, track/disc number, date, ISRC - All metadata preserved: title, artist, album, album artist, track/disc number, date, ISRC
- Automatic tag conversion from Vorbis comments (FLAC) to ID3v2 (MP3) - Automatic tag conversion from Vorbis comments (FLAC) to ID3v2 (MP3)
- **Dominant Color Header**: Album, Playlist, Downloaded Album, and Track Metadata screens now feature dynamic header backgrounds - **Dominant Color Header**: Album, Playlist, Downloaded Album, and Track Metadata screens now feature dynamic header backgrounds
- Extracts dominant color from cover art using `palette_generator` - Extracts dominant color from cover art using `palette_generator`
- Creates a gradient from dominant color to theme surface color - Creates a gradient from dominant color to theme surface color
- Smooth 500ms color transition animation - Smooth 500ms color transition animation
- **Larger Cover Art**: Cover images on detail screens are now 50% of screen width (previously 140px fixed) - **Larger Cover Art**: Cover images on detail screens are now 50% of screen width (previously 140px fixed)
- More prominent album artwork display - More prominent album artwork display
- Larger shadow and rounded corners (20px radius) - Larger shadow and rounded corners (20px radius)
- Higher resolution cover caching - Higher resolution cover caching
- **Sticky Title**: Title appears in AppBar when scrolling past the info card - **Sticky Title**: Title appears in AppBar when scrolling past the info card
- Smooth fade-in animation (200ms) when scrolling down - Smooth fade-in animation (200ms) when scrolling down
- Title hidden when header is expanded (shows in info card instead) - Title hidden when header is expanded (shows in info card instead)
- AppBar uses theme color (surface) for clean, native look - AppBar uses theme color (surface) for clean, native look
- Works on Album, Playlist, Downloaded Album, Track Metadata, and Artist screens - Works on Album, Playlist, Downloaded Album, Track Metadata, and Artist screens
- **Artist Name in Album Screen**: Album info card now displays artist name below album title - **Artist Name in Album Screen**: Album info card now displays artist name below album title
- Extracted from first track's artist metadata - Extracted from first track's artist metadata
- Styled with `onSurfaceVariant` color for visual hierarchy - Styled with `onSurfaceVariant` color for visual hierarchy
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc - **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
- Visual disc separator header showing "Disc 1", "Disc 2", etc. - Visual disc separator header showing "Disc 1", "Disc 2", etc.
- Tracks sorted by disc number first, then by track number - Tracks sorted by disc number first, then by track number
- Single-disc albums display normally without separators - Single-disc albums display normally without separators
- Fixes confusion when albums have duplicate track numbers across discs - Fixes confusion when albums have duplicate track numbers across discs
- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section - **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
- Prevents flooding the recents list when downloading full albums - Prevents flooding the recents list when downloading full albums
- Groups tracks by album name and artist - Groups tracks by album name and artist
@@ -319,7 +902,6 @@
- MP3 files now saved in the same folder as FLAC (no separate MP3 subfolder) - MP3 files now saved in the same folder as FLAC (no separate MP3 subfolder)
- Original FLAC file automatically deleted after successful conversion - Original FLAC file automatically deleted after successful conversion
- New `embedMetadataToMp3()` method for MP3-specific tag embedding - New `embedMetadataToMp3()` method for MP3-specific tag embedding
- **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed - **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed
- Dark theme: Black background with white text - Dark theme: Black background with white text
- Light theme: White background with black text - Light theme: White background with black text
@@ -331,12 +913,10 @@
- MP3 files now show "320kbps" instead of FLAC's bit depth/sample rate - MP3 files now show "320kbps" instead of FLAC's bit depth/sample rate
- History no longer stores FLAC audio specs for converted MP3 files - History no longer stores FLAC audio specs for converted MP3 files
- Both File Info badges and metadata grid show correct MP3 quality - Both File Info badges and metadata grid show correct MP3 quality
- **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks - **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks
- `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored - `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored
- `track_provider.dart`: Added comments explaining why availability check errors are silently ignored - `track_provider.dart`: Added comments explaining why availability check errors are silently ignored
- `ffmpeg_service.dart`: Added proper error logging for temp file cleanup failures - `ffmpeg_service.dart`: Added proper error logging for temp file cleanup failures
- **Russian Plural Forms**: Fixed ICU syntax warnings in Russian localization - **Russian Plural Forms**: Fixed ICU syntax warnings in Russian localization
- Removed redundant `=1` clauses that were overriding `one` plural category - Removed redundant `=1` clauses that were overriding `one` plural category
- Affected 10 plural strings including track counts and delete confirmations - Affected 10 plural strings including track counts and delete confirmations
@@ -356,24 +936,20 @@
- Thread-safe cache with automatic expiration - Thread-safe cache with automatic expiration
- Cache key based on artist, track, and duration - Cache key based on artist, track, and duration
- Log indicator shows "(cached)" when lyrics are served from cache - Log indicator shows "(cached)" when lyrics are served from cache
- **Lyrics Duration Matching**: Improved lyrics accuracy with duration-based matching - **Lyrics Duration Matching**: Improved lyrics accuracy with duration-based matching
- Compares track duration with lrclib.net results - Compares track duration with lrclib.net results
- 10-second tolerance to handle version differences (radio edit, remaster, etc.) - 10-second tolerance to handle version differences (radio edit, remaster, etc.)
- Prioritizes synced lyrics over plain text when duration matches - Prioritizes synced lyrics over plain text when duration matches
- Falls back gracefully if no duration match found - Falls back gracefully if no duration match found
- **Deezer Cover Art Upgrade**: Cover art from Deezer CDN now automatically upgraded to maximum quality - **Deezer Cover Art Upgrade**: Cover art from Deezer CDN now automatically upgraded to maximum quality
- Detects Deezer CDN URLs (`cdn-images.dzcdn.net`) - Detects Deezer CDN URLs (`cdn-images.dzcdn.net`)
- Upgrades cover resolution to 1800x1800 (max available) - Upgrades cover resolution to 1800x1800 (max available)
- Works alongside existing cover upgrade - Works alongside existing cover upgrade
- **Live Search for Extensions**: Search-as-you-type functionality for extension search - **Live Search for Extensions**: Search-as-you-type functionality for extension search
- 800ms debounce delay to prevent excessive API calls - 800ms debounce delay to prevent excessive API calls
- Minimum 3 characters required before searching - Minimum 3 characters required before searching
- Concurrency control to prevent race conditions in extension runtime - Concurrency control to prevent race conditions in extension runtime
- Queues pending searches if a search is already in progress - Queues pending searches if a search is already in progress
- **Russian Language Support**: Added Russian (Русский) translation - 99% complete - **Russian Language Support**: Added Russian (Русский) translation - 99% complete
- Translated via Crowdin community contributions - Translated via Crowdin community contributions
- Covers all UI elements, settings, and error messages - Covers all UI elements, settings, and error messages
@@ -384,12 +960,10 @@
- Added per-directory build lock using `sync.Map` and `sync.Mutex` - Added per-directory build lock using `sync.Map` and `sync.Mutex`
- Double-check locking pattern ensures index is built only once - Double-check locking pattern ensures index is built only once
- Significantly improves performance during CSV import with many tracks - Significantly improves performance during CSV import with many tracks
- **Queue Tab Scroll Exception**: Fixed Flutter rendering exception with NestedScrollView - **Queue Tab Scroll Exception**: Fixed Flutter rendering exception with NestedScrollView
- Disabled Material 3 stretch overscroll indicator that caused `_StretchController` assertion - Disabled Material 3 stretch overscroll indicator that caused `_StretchController` assertion
- Wrapped NestedScrollView with ScrollConfiguration to prevent `setState during build` errors - Wrapped NestedScrollView with ScrollConfiguration to prevent `setState during build` errors
- Issue was especially noticeable during rapid queue updates (CSV import) - Issue was especially noticeable during rapid queue updates (CSV import)
- **CSV Import**: Fixed CSV export not being parsed correctly - **CSV Import**: Fixed CSV export not being parsed correctly
- Added support for `Artist Name(s)` header (with parentheses) - Added support for `Artist Name(s)` header (with parentheses)
- Added support for `Track URI` header for track IDs - Added support for `Track URI` header for track IDs
@@ -456,4 +1030,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
--- ---
*For older versions, see [GitHub Releases](https://github.com/zarzet/SpotiFLAC-Mobile/releases)* _For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
+15 -21
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) [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3) [![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) [![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"> <div align="center">
@@ -24,15 +24,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
<img src="assets/images/4.jpg?v=2" width="200" /> <img src="assets/images/4.jpg?v=2" width="200" />
</p> </p>
## Search Source
SpotiFLAC supports multiple search sources for finding music metadata:
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Extensions** | Install additional search providers from the Store |
## Extensions ## Extensions
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently. Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
@@ -54,15 +45,8 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
## Telegram ## Telegram
<p align="center"> [![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
<a href="https://t.me/spotiflac"> [![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflac_chat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
## FAQ ## FAQ
@@ -87,9 +71,9 @@ A: Some countries have restricted access to certain streaming service APIs. If d
### Want to support SpotiFLAC-Mobile? ### 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 ## Disclaimer
@@ -108,6 +92,16 @@ You are solely responsible for:
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use. The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
## API Credits
- **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), [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)
> [!TIP] > [!TIP]
> >
> **Star Us**, You will receive all release notifications from GitHub without any delay ~ > **Star Us**, You will receive all release notifications from GitHub without any delay ~
+5 -3
View File
@@ -96,11 +96,13 @@ repositories {
} }
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// Include all AAR and JAR files from libs folder // Include all AAR and JAR files from libs folder
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar")))) implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.activity:activity-ktx:1.12.3")
} }
+3
View File
@@ -28,6 +28,9 @@
# FFmpeg Kit # FFmpeg Kit
-keep class com.arthenica.ffmpegkit.** { *; } -keep class com.arthenica.ffmpegkit.** { *; }
-keep class com.arthenica.smartexception.** { *; } -keep class com.arthenica.smartexception.** { *; }
# FFmpeg Kit (new fork package)
-keep class com.antonkarpenko.ffmpegkit.** { *; }
-keep class com.antonkarpenko.smartexception.** { *; }
# Apache Tika (if used by FFmpeg) # Apache Tika (if used by FFmpeg)
-dontwarn org.apache.tika.** -dontwarn org.apache.tika.**
+31 -4
View File
@@ -20,9 +20,9 @@
android:label="SpotiFLAC" android:label="SpotiFLAC"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true" android:usesCleartextTraffic="false"
android:usesCleartextTraffic="true" android:enableOnBackInvokedCallback="true"
android:enableOnBackInvokedCallback="true"> android:localeConfig="@xml/locale_config">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -43,7 +43,7 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- Handle Spotify URL sharing --> <!-- Handle music URL sharing (Spotify, Deezer, Tidal, YT Music) -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@@ -57,6 +57,33 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="open.spotify.com" /> <data android:scheme="https" android:host="open.spotify.com" />
</intent-filter> </intent-filter>
<!-- Handle Deezer deep links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="www.deezer.com" />
<data android:scheme="https" android:host="deezer.com" />
<data android:scheme="https" android:host="deezer.page.link" />
</intent-filter>
<!-- Handle Tidal deep links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="tidal.com" />
<data android:scheme="https" android:host="listen.tidal.com" />
</intent-filter>
<!-- Handle YouTube Music deep links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="music.youtube.com" />
</intent-filter>
</activity> </activity>
<!-- Download Service --> <!-- Download Service -->
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ru" />
<locale android:name="es-ES" />
<locale android:name="id" />
<locale android:name="pt-PT" />
<locale android:name="ja" />
<locale android:name="tr" />
<locale android:name="de" />
<locale android:name="fr" />
<locale android:name="hi" />
<locale android:name="ko" />
<locale android:name="nl" />
<locale android:name="zh" />
</locale-config>
+1 -1
View File
@@ -22,7 +22,7 @@ subprojects {
} }
// Add desugaring dependency to all Android subprojects // Add desugaring dependency to all Android subprojects
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4") project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
} }
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
+1 -1
View File
@@ -1,2 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.3.0" apply false id("org.jetbrains.kotlin.android") version "2.2.21" apply false
} }
include(":app") include(":app")
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

+387 -135
View File
@@ -17,6 +17,13 @@ import (
"time" "time"
) )
// Amazon API timeout and retry configuration for mobile networks
const (
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
amazonMaxRetries = 2 // Number of retry attempts
amazonRetryDelay = 500 * time.Millisecond
)
type AmazonDownloader struct { type AmazonDownloader struct {
client *http.Client client *http.Client
} }
@@ -24,6 +31,8 @@ type AmazonDownloader struct {
var ( var (
globalAmazonDownloader *AmazonDownloader globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once 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 // AfkarXYZResponse is the response from AfkarXYZ API
@@ -36,13 +45,10 @@ type AfkarXYZResponse struct {
} `json:"data"` } `json:"data"`
} }
func amazonIsASCIIString(s string) bool { // AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin}
for _, r := range s { type AmazonStreamResponse struct {
if r > 127 { StreamURL string `json:"streamUrl"`
return false DecryptionKey string `json:"decryptionKey"`
}
}
return true
} }
func NewAmazonDownloader() *AmazonDownloader { func NewAmazonDownloader() *AmazonDownloader {
@@ -54,43 +60,195 @@ func NewAmazonDownloader() *AmazonDownloader {
return globalAmazonDownloader return globalAmazonDownloader
} }
// downloadFromAfkarXYZ downloads a track using AfkarXYZ API // fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
// Returns: downloadURL, fileName, error // Returns downloadURL, suggested fileName, optional decryptionKey.
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) { func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
// AfkarXYZ API endpoint var lastErr error
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
if attempt > 0 {
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
time.Sleep(delay)
}
GoLog("[Amazon] Fetching from AfkarXYZ API...\n") downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
if err == nil {
return downloadURL, fileName, decryptionKey, nil
}
req, err := http.NewRequest("GET", apiURL, nil) lastErr = err
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, "status 5") ||
strings.Contains(errStr, "status 429") ||
strings.Contains(errStr, "http 429")
if !isRetryable {
return "", "", "", err
}
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
}
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
}
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 { 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()) req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { 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() defer resp.Body.Close()
if resp.StatusCode != 200 { 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) body, err := io.ReadAll(resp.Body)
if err != nil { 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 var apiResp AfkarXYZResponse
if err := json.Unmarshal(body, &apiResp); err != nil { 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 == "" { if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found") return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
} }
fileName := apiResp.Data.FileName fileName := apiResp.Data.FileName
@@ -98,19 +256,30 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin
fileName = "track.flac" fileName = "track.flac"
} }
// Sanitize filename
reg := regexp.MustCompile(`[<>:"/\\|?*]`) reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "") fileName = reg.ReplaceAllString(fileName, "")
GoLog("[Amazon] AfkarXYZ returned: %s (%.2f MB)\n", fileName, float64(apiResp.Data.FileSize)/(1024*1024)) return apiResp.Data.DirectLink, fileName, "", nil
return apiResp.Data.DirectLink, fileName, nil
} }
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
if err != nil {
return "", "", "", err
}
if decryptionKey != "" {
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
}
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
return downloadURL, fileName, decryptionKey, nil
}
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
ctx := context.Background() ctx := context.Background()
// Initialize item progress (required for all downloads)
if itemID != "" { if itemID != "" {
StartItemProgress(itemID) StartItemProgress(itemID)
defer CompleteItemProgress(itemID) defer CompleteItemProgress(itemID)
@@ -147,7 +316,7 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
SetItemBytesTotal(itemID, expectedSize) SetItemBytesTotal(itemID, expectedSize)
} }
out, err := os.Create(outputPath) out, err := openOutputForWrite(outputPath, outputFD)
if err != nil { if err != nil {
return err return err
} }
@@ -162,29 +331,27 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
written, err = io.Copy(bufWriter, resp.Body) written, err = io.Copy(bufWriter, resp.Body)
} }
// Flush buffer before checking for errors
flushErr := bufWriter.Flush() flushErr := bufWriter.Flush()
closeErr := out.Close() closeErr := out.Close()
if err != nil { if err != nil {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
return ErrDownloadCancelled return ErrDownloadCancelled
} }
return fmt.Errorf("download interrupted: %w", err) return fmt.Errorf("download interrupted: %w", err)
} }
if flushErr != nil { if flushErr != nil {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr) return fmt.Errorf("failed to flush buffer: %w", flushErr)
} }
if closeErr != nil { if closeErr != nil {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr) return fmt.Errorf("failed to close file: %w", closeErr)
} }
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize { if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
} }
@@ -194,55 +361,74 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
// AmazonDownloadResult contains download result with quality info // AmazonDownloadResult contains download result with quality info
type AmazonDownloadResult struct { type AmazonDownloadResult struct {
FilePath string FilePath string
BitDepth int BitDepth int
SampleRate int SampleRate int
Title string Title string
Artist string Artist string
Album string Album string
ReleaseDate string ReleaseDate string
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
LyricsLRC string
DecryptionKey string
} }
// downloadFromAmazon uses AfkarXYZ API to download from Amazon Music
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader() downloader := NewAmazonDownloader()
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
amazonURL := ""
if req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
amazonURL = cached.AmazonURL
GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC)
}
} }
songlink := NewSongLinkClient() songlink := NewSongLinkClient()
var availability *TrackAvailability var availability *TrackAvailability
var err error var err error
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found { if amazonURL == "" {
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID) if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
} else if req.SpotifyID != "" { availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) } else if req.SpotifyID != "" {
} else { availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup") } else {
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
}
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
if !availability.Amazon || availability.AmazonURL == "" {
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
amazonURL = availability.AmazonURL
if req.ISRC != "" {
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
}
} }
if err != nil { if !isSafOutput && req.OutputDir != "." {
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
if !availability.Amazon || availability.AmazonURL == "" {
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
if req.OutputDir != "." {
if err := os.MkdirAll(req.OutputDir, 0755); err != nil { if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err) return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
} }
} }
// Download using AfkarXYZ API // Download using AfkarXYZ API
downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL) downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
if err != nil { if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err) return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
} }
@@ -255,13 +441,25 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
"album": req.AlbumName, "album": req.AlbumName,
"track": req.TrackNumber, "track": req.TrackNumber,
"year": extractYear(req.ReleaseDate), "year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber, "disc": req.DiscNumber,
}) })
filename = sanitizeFilename(filename) + ".flac" var outputPath string
outputPath := filepath.Join(req.OutputDir, filename) if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { if outputPath == "" && isFDOutput(req.OutputFD) {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
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
}
} }
// START PARALLEL: Fetch cover and lyrics while downloading audio // START PARALLEL: Fetch cover and lyrics while downloading audio
@@ -281,13 +479,19 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}() }()
// Download audio file with item ID for progress tracking // Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) { if errors.Is(err, ErrDownloadCancelled) {
return AmazonDownloadResult{}, ErrDownloadCancelled return AmazonDownloadResult{}, ErrDownloadCancelled
} }
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err) 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 // Wait for parallel operations to complete
<-parallelDone <-parallelDone
@@ -296,28 +500,43 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
SetItemFinalizing(req.ItemID) SetItemFinalizing(req.ItemID)
} }
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber actualDiscNum := req.DiscNumber
actualDate := req.ReleaseDate
actualAlbum := req.AlbumName
actualTitle := req.TrackName
actualArtist := req.ArtistName
if metaErr == nil && existingMeta != nil { if !needsDecryption {
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { existingMeta, metaErr := ReadMetadata(actualOutputPath)
actualTrackNum = existingMeta.TrackNumber if metaErr == nil && existingMeta != nil {
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
} actualTrackNum = existingMeta.TrackNumber
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) { GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
actualDiscNum = existingMeta.DiscNumber }
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber) 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)
} }
} }
// Embed metadata using Spotify data
metadata := Metadata{ metadata := Metadata{
Title: req.TrackName, Title: actualTitle,
Artist: req.ArtistName, Artist: actualArtist,
Album: req.AlbumName, Album: actualAlbum,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate, Date: actualDate,
TrackNumber: actualTrackNum, TrackNumber: actualTrackNum,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNum, DiscNumber: actualDiscNum,
@@ -327,66 +546,92 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
Copyright: req.Copyright, Copyright: req.Copyright,
} }
// Use cover data from parallel fetch
var coverData []byte var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil { if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
coverData = parallelResult.CoverData coverData = parallelResult.CoverData
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} else {
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))
} else {
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
}
} }
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { if isSafOutput || needsDecryption {
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err) GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
} } else {
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { if isFlacOutput {
lyricsMode := req.LyricsMode if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
if lyricsMode == "" { GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
lyricsMode = "embed" // default
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
} }
} else {
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
} }
if lyricsMode == "embed" || lyricsMode == "both" { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) lyricsMode := req.LyricsMode
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { if lyricsMode == "" {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) lyricsMode = "embed"
} else {
GoLog("[Amazon] Lyrics embedded successfully\n")
} }
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n")
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") && isFlacOutput {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
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")
} }
} else if req.EmbedLyrics {
GoLog("[Amazon] No lyrics available from parallel fetch\n")
} }
GoLog("[Amazon] Downloaded successfully from Amazon Music\n") GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
quality, err := GetAudioQuality(outputPath) quality := AudioQuality{}
if err != nil { if isSafOutput || needsDecryption {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err) GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
} else { } else {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) 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 { if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date) finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
actualTrackNum = finalMeta.TrackNumber actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" { if finalMeta.Date != "" {
req.ReleaseDate = finalMeta.Date req.ReleaseDate = finalMeta.Date
}
} }
} }
// Add to ISRC index for fast duplicate checking // Add to ISRC index for fast duplicate checking.
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) // When decryption is pending in Flutter, postpone indexing until final file is settled.
if !isSafOutput && !needsDecryption {
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
}
bitDepth := 0 bitDepth := 0
sampleRate := 0 sampleRate := 0
@@ -395,16 +640,23 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
sampleRate = quality.SampleRate sampleRate = quality.SampleRate
} }
lyricsLRC := ""
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return AmazonDownloadResult{ return AmazonDownloadResult{
FilePath: outputPath, FilePath: outputPath,
BitDepth: bitDepth, BitDepth: bitDepth,
SampleRate: sampleRate, SampleRate: sampleRate,
Title: req.TrackName, Title: req.TrackName,
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
ReleaseDate: req.ReleaseDate, ReleaseDate: req.ReleaseDate,
TrackNumber: actualTrackNum, TrackNumber: actualTrackNum,
DiscNumber: actualDiscNum, DiscNumber: actualDiscNum,
ISRC: req.ISRC, ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
DecryptionKey: decryptionKey,
}, nil }, 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)
}
})
}
}
File diff suppressed because it is too large Load Diff
+178 -53
View File
@@ -23,15 +23,28 @@ const (
deezerCacheTTL = 10 * time.Minute deezerCacheTTL = 10 * time.Minute
deezerMaxParallelISRC = 10 deezerMaxParallelISRC = 10
// Deezer API timeout and retry configuration for mobile networks
deezerAPITimeoutMobile = 25 * time.Second
deezerMaxRetries = 2
deezerRetryDelay = 500 * time.Millisecond
deezerMaxSearchCacheEntries = 300
deezerMaxAlbumCacheEntries = 200
deezerMaxArtistCacheEntries = 200
deezerMaxISRCCacheEntries = 4000
deezerCacheCleanupInterval = 5 * time.Minute
) )
type DeezerClient struct { type DeezerClient struct {
httpClient *http.Client httpClient *http.Client
searchCache map[string]*cacheEntry searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry artistCache map[string]*cacheEntry
isrcCache map[string]string isrcCache map[string]string
cacheMu sync.RWMutex cacheMu sync.RWMutex
lastCacheCleanup time.Time
cacheCleanupInterval time.Duration
} }
var ( var (
@@ -42,20 +55,115 @@ var (
func GetDeezerClient() *DeezerClient { func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() { deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{ deezerClient = &DeezerClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
searchCache: make(map[string]*cacheEntry), searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry), albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry), artistCache: make(map[string]*cacheEntry),
isrcCache: make(map[string]string), isrcCache: make(map[string]string),
cacheCleanupInterval: deezerCacheCleanupInterval,
} }
}) })
return deezerClient 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 { type deezerTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Duration int `json:"duration"` // in seconds Duration int `json:"duration"`
TrackPosition int `json:"track_position"` TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"` DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
@@ -121,7 +229,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
AlbumArtist: track.Artist.Name, AlbumArtist: track.Artist.Name,
DurationMS: track.Duration * 1000, DurationMS: track.Duration * 1000,
Images: albumImage, Images: albumImage,
ReleaseDate: releaseDate, // Added this ReleaseDate: releaseDate,
TrackNumber: track.TrackPosition, TrackNumber: track.TrackPosition,
DiscNumber: track.DiskNumber, DiscNumber: track.DiskNumber,
ExternalURL: track.Link, ExternalURL: track.Link,
@@ -182,15 +290,12 @@ type deezerPlaylistFull struct {
} `json:"tracks"` } `json:"tracks"`
} }
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
// filter can be: "" (all), "track", "artist", "album", "playlist"
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) { func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter) GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
albumLimit := 5 // Same as artistLimit for consistency albumLimit := 5
playlistLimit := 5 playlistLimit := 5
// When filter is specified, increase limits for that type only
if filter != "" { if filter != "" {
switch filter { switch filter {
case "track": case "track":
@@ -233,7 +338,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
Playlists: make([]SearchPlaylistResult, 0, playlistLimit), Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
} }
// Search tracks - NO ISRC fetch for performance
if trackLimit > 0 { if trackLimit > 0 {
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit) trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL) GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
@@ -263,7 +367,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
} }
} }
// Search artists
if artistLimit > 0 { if artistLimit > 0 {
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit) artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL) GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
@@ -296,7 +399,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
} }
} }
// Search albums
if albumLimit > 0 { if albumLimit > 0 {
albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit) albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit)
GoLog("[Deezer] Fetching albums from: %s\n", albumURL) GoLog("[Deezer] Fetching albums from: %s\n", albumURL)
@@ -358,7 +460,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
} }
} }
// Search playlists
if playlistLimit > 0 { if playlistLimit > 0 {
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit) playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL) GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
@@ -416,16 +517,17 @@ 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)) 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() c.cacheMu.Lock()
now := time.Now()
c.searchCache[cacheKey] = &cacheEntry{ c.searchCache[cacheKey] = &cacheEntry{
data: result, data: result,
expiresAt: time.Now().Add(deezerCacheTTL), expiresAt: now.Add(deezerCacheTTL),
} }
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock() c.cacheMu.Unlock()
return result, nil return result, nil
} }
// GetTrack fetches a single track by Deezer ID
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) { func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID) trackURL := fmt.Sprintf(deezerTrackURL, trackID)
@@ -439,7 +541,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
}, nil }, nil
} }
// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) { func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() { if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
@@ -465,7 +566,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
artistName = strings.Join(names, ", ") artistName = strings.Join(names, ", ")
} }
// Extract genres as comma-separated string
var genres []string var genres []string
for _, g := range album.Genres.Data { for _, g := range album.Genres.Data {
if g.Name != "" { if g.Name != "" {
@@ -481,14 +581,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
Artists: artistName, Artists: artistName,
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID), ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage, Images: albumImage,
Genre: genreStr, // From Deezer album Genre: genreStr,
Label: album.Label, // From Deezer album Label: album.Label,
} }
// Fetch all tracks with pagination (Deezer default limit is 25)
allTracks := album.Tracks.Data allTracks := album.Tracks.Data
// If album has more tracks than returned, fetch remaining pages
if album.NbTracks > len(allTracks) { if album.NbTracks > len(allTracks) {
GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks)) GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
@@ -523,7 +621,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
isrcMap := c.fetchISRCsParallel(ctx, allTracks) isrcMap := c.fetchISRCsParallel(ctx, allTracks)
tracks := make([]AlbumTrackMetadata, 0, len(allTracks)) tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
// Normalize record_type (Deezer uses "compile" instead of "compilation")
albumType := album.RecordType albumType := album.RecordType
if albumType == "compile" { if albumType == "compile" {
albumType = "compilation" albumType = "compilation"
@@ -533,7 +630,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
trackIDStr := fmt.Sprintf("%d", track.ID) trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr] isrc := isrcMap[trackIDStr]
// Use track position from API, fallback to index+1 if not provided
trackNum := track.TrackPosition trackNum := track.TrackPosition
if trackNum == 0 { if trackNum == 0 {
trackNum = i + 1 trackNum = i + 1
@@ -564,10 +660,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
} }
c.cacheMu.Lock() c.cacheMu.Lock()
now := time.Now()
c.albumCache[albumID] = &cacheEntry{ c.albumCache[albumID] = &cacheEntry{
data: result, data: result,
expiresAt: time.Now().Add(deezerCacheTTL), expiresAt: now.Add(deezerCacheTTL),
} }
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock() c.cacheMu.Unlock()
return result, nil return result, nil
@@ -581,7 +679,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Fetch artist info
artistURL := fmt.Sprintf(deezerArtistURL, artistID) artistURL := fmt.Sprintf(deezerArtistURL, artistID)
var artist deezerArtistFull var artist deezerArtistFull
if err := c.getJSON(ctx, artistURL, &artist); err != nil { if err := c.getJSON(ctx, artistURL, &artist); err != nil {
@@ -596,7 +693,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
Popularity: 0, Popularity: 0,
} }
// Fetch artist albums
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID)) albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
var albumsResp struct { var albumsResp struct {
Data []struct { Data []struct {
@@ -608,7 +704,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
CoverMedium string `json:"cover_medium"` CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"` CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
RecordType string `json:"record_type"` // album, single, ep, compile RecordType string `json:"record_type"`
} `json:"data"` } `json:"data"`
} }
@@ -649,10 +745,12 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
} }
c.cacheMu.Lock() c.cacheMu.Lock()
now := time.Now()
c.artistCache[artistID] = &cacheEntry{ c.artistCache[artistID] = &cacheEntry{
data: result, data: result,
expiresAt: time.Now().Add(deezerCacheTTL), expiresAt: now.Add(deezerCacheTTL),
} }
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock() c.cacheMu.Unlock()
return result, nil return result, nil
@@ -680,10 +778,8 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
info.Owner.Name = playlist.Title info.Owner.Name = playlist.Title
info.Owner.Images = playlistImage info.Owner.Images = playlistImage
// Fetch all tracks with pagination (Deezer default limit is 25)
allTracks := playlist.Tracks.Data allTracks := playlist.Tracks.Data
// If playlist has more tracks than returned, fetch remaining pages
if playlist.NbTracks > len(allTracks) { if playlist.NbTracks > len(allTracks) {
GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks)) GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
@@ -789,7 +885,6 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
return &track, nil return &track, nil
} }
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string { func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
result := make(map[string]string, len(tracks)) result := make(map[string]string, len(tracks))
var resultMu sync.Mutex var resultMu sync.Mutex
@@ -821,6 +916,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
for trackIDStr, isrc := range directISRCs { for trackIDStr, isrc := range directISRCs {
c.isrcCache[trackIDStr] = isrc c.isrcCache[trackIDStr] = isrc
} }
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock() c.cacheMu.Unlock()
} }
@@ -828,7 +924,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return result return result
} }
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, deezerMaxParallelISRC) sem := make(chan struct{}, deezerMaxParallelISRC)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -850,13 +945,13 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return return
} }
// Store in result and cache
resultMu.Lock() resultMu.Lock()
result[trackIDStr] = fullTrack.ISRC result[trackIDStr] = fullTrack.ISRC
resultMu.Unlock() resultMu.Unlock()
c.cacheMu.Lock() c.cacheMu.Lock()
c.isrcCache[trackIDStr] = fullTrack.ISRC c.isrcCache[trackIDStr] = fullTrack.ISRC
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock() c.cacheMu.Unlock()
}(track) }(track)
} }
@@ -865,7 +960,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return result return result
} }
// Use this when you need ISRC for download
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) { func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
c.cacheMu.RLock() c.cacheMu.RLock()
if isrc, ok := c.isrcCache[trackID]; ok { if isrc, ok := c.isrcCache[trackID]; ok {
@@ -881,6 +975,7 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
c.cacheMu.Lock() c.cacheMu.Lock()
c.isrcCache[trackID] = fullTrack.ISRC c.isrcCache[trackID] = fullTrack.ISRC
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock() c.cacheMu.Unlock()
return fullTrack.ISRC, nil return fullTrack.ISRC, nil
@@ -926,11 +1021,10 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
} }
type AlbumExtendedMetadata struct { type AlbumExtendedMetadata struct {
Genre string // Comma-separated list of genres Genre string
Label string // Record label name Label string
} }
// Uses the album ID from a track to fetch extended metadata
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) { func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
if albumID == "" { if albumID == "" {
return nil, fmt.Errorf("empty album ID") return nil, fmt.Errorf("empty album ID")
@@ -964,10 +1058,12 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
} }
c.cacheMu.Lock() c.cacheMu.Lock()
now := time.Now()
c.searchCache[cacheKey] = &cacheEntry{ c.searchCache[cacheKey] = &cacheEntry{
data: result, data: result,
expiresAt: time.Now().Add(deezerCacheTTL), expiresAt: now.Add(deezerCacheTTL),
} }
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock() c.cacheMu.Unlock()
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label) GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
@@ -975,7 +1071,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
return result, nil return result, nil
} }
// GetTrackAlbumID fetches the album ID for a Deezer track
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) { func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID) trackURL := fmt.Sprintf(deezerTrackURL, trackID)
@@ -987,7 +1082,6 @@ func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (str
return fmt.Sprintf("%d", track.Album.ID), nil return fmt.Sprintf("%d", track.Album.ID), nil
} }
// This is a convenience function that first gets the album ID, then fetches album metadata
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) { func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
albumID, err := c.GetTrackAlbumID(ctx, trackID) albumID, err := c.GetTrackAlbumID(ctx, trackID)
if err != nil { if err != nil {
@@ -997,30 +1091,62 @@ func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID
return c.GetAlbumExtendedMetadata(ctx, albumID) return c.GetAlbumExtendedMetadata(ctx, albumID)
} }
// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label)
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) { func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
if isrc == "" { if isrc == "" {
return nil, fmt.Errorf("empty ISRC") return nil, fmt.Errorf("empty ISRC")
} }
// First, search for track by ISRC
track, err := c.SearchByISRC(ctx, isrc) track, err := c.SearchByISRC(ctx, isrc)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to find track by ISRC: %w", err) return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
} }
// SpotifyID contains "deezer:123" format, extract the ID
deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:") deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:")
if deezerID == "" { if deezerID == "" {
return nil, fmt.Errorf("track found but no Deezer ID") return nil, fmt.Errorf("track found but no Deezer ID")
} }
// Then fetch extended metadata using the Deezer track ID
return c.GetExtendedMetadataByTrackID(ctx, deezerID) return c.GetExtendedMetadataByTrackID(ctx, deezerID)
} }
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error { func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
var lastErr error
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if attempt > 0 {
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay)
}
err := c.doGetJSON(ctx, endpoint, dst)
if err == nil {
return nil
}
lastErr = err
errStr := err.Error()
// 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, "status 5") ||
strings.Contains(errStr, "status 429")
if !isRetryable {
return err
}
GoLog("[Deezer] Attempt %d failed (retryable): %v\n", attempt+1, err)
}
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
}
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
return err return err
@@ -1046,7 +1172,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
return json.Unmarshal(body, dst) return json.Unmarshal(body, dst)
} }
// parseDeezerURL is internal function, returns type and ID
func parseDeezerURL(input string) (string, string, error) { func parseDeezerURL(input string) (string, string, error) {
trimmed := strings.TrimSpace(input) trimmed := strings.TrimSpace(input)
if trimmed == "" { if trimmed == "" {
+1 -18
View File
@@ -10,7 +10,6 @@ import (
"time" "time"
) )
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
type ISRCIndex struct { type ISRCIndex struct {
index map[string]string // ISRC (uppercase) -> file path index map[string]string // ISRC (uppercase) -> file path
outputDir string outputDir string
@@ -25,8 +24,6 @@ var (
isrcIndexTTL = 5 * time.Minute isrcIndexTTL = 5 * time.Minute
) )
// GetISRCIndex returns or builds an ISRC index for the given directory
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
func GetISRCIndex(outputDir string) *ISRCIndex { func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first // Fast path: check cache first
isrcIndexCacheMu.RLock() isrcIndexCacheMu.RLock()
@@ -56,7 +53,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
return buildISRCIndex(outputDir) return buildISRCIndex(outputDir)
} }
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
func buildISRCIndex(outputDir string) *ISRCIndex { func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{ idx := &ISRCIndex{
index: make(map[string]string), index: make(map[string]string),
@@ -91,7 +87,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return nil return nil
}) })
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n", fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond)) outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
isrcIndexCacheMu.Lock() isrcIndexCacheMu.Lock()
@@ -113,7 +109,6 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
return path, exists return path, exists
} }
// remove deletes an ISRC entry from the index (internal use)
func (idx *ISRCIndex) remove(isrc string) { func (idx *ISRCIndex) remove(isrc string) {
if isrc == "" { if isrc == "" {
return return
@@ -125,14 +120,11 @@ func (idx *ISRCIndex) remove(isrc string) {
delete(idx.index, strings.ToUpper(isrc)) delete(idx.index, strings.ToUpper(isrc))
} }
// Lookup checks if an ISRC exists in the index (gomobile compatible)
// Returns filepath if found, empty string if not found
func (idx *ISRCIndex) Lookup(isrc string) (string, error) { func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
path, _ := idx.lookup(isrc) path, _ := idx.lookup(isrc)
return path, nil return path, nil
} }
// Add adds a new ISRC to the index (call after successful download)
func (idx *ISRCIndex) Add(isrc, filePath string) { func (idx *ISRCIndex) Add(isrc, filePath string) {
if isrc == "" || filePath == "" { if isrc == "" || filePath == "" {
return return
@@ -144,15 +136,12 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
idx.index[strings.ToUpper(isrc)] = filePath idx.index[strings.ToUpper(isrc)] = filePath
} }
// InvalidateCache clears the ISRC index cache for a directory
func InvalidateISRCCache(outputDir string) { func InvalidateISRCCache(outputDir string) {
isrcIndexCacheMu.Lock() isrcIndexCacheMu.Lock()
delete(isrcIndexCache, outputDir) delete(isrcIndexCache, outputDir)
isrcIndexCacheMu.Unlock() isrcIndexCacheMu.Unlock()
} }
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
// Uses ISRC index for fast lookup
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) { func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
if isrc == "" || outputDir == "" { if isrc == "" || outputDir == "" {
return "", false return "", false
@@ -173,13 +162,11 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
return filePath, true return filePath, true
} }
// CheckISRCExists is the exported version for gomobile (returns string, error)
func CheckISRCExists(outputDir, isrc string) (string, error) { func CheckISRCExists(outputDir, isrc string) (string, error) {
filepath, _ := checkISRCExistsInternal(outputDir, isrc) filepath, _ := checkISRCExistsInternal(outputDir, isrc)
return filepath, nil return filepath, nil
} }
// CheckFileExists checks if a file with the given name exists
func CheckFileExists(filePath string) bool { func CheckFileExists(filePath string) bool {
info, err := os.Stat(filePath) info, err := os.Stat(filePath)
if err != nil { if err != nil {
@@ -188,7 +175,6 @@ func CheckFileExists(filePath string) bool {
return !info.IsDir() && info.Size() > 0 return !info.IsDir() && info.Size() > 0
} }
// FileExistenceResult represents the result of checking if a file exists
type FileExistenceResult struct { type FileExistenceResult struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Exists bool `json:"exists"` Exists bool `json:"exists"`
@@ -249,8 +235,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
return string(resultJSON), nil return string(resultJSON), nil
} }
// PreBuildISRCIndex pre-builds the ISRC index for a directory
// Call this when app starts or when entering album/playlist screen
func PreBuildISRCIndex(outputDir string) error { func PreBuildISRCIndex(outputDir string) error {
if outputDir == "" { if outputDir == "" {
return fmt.Errorf("output directory is required") return fmt.Errorf("output directory is required")
@@ -260,7 +244,6 @@ func PreBuildISRCIndex(outputDir string) error {
return nil return nil
} }
// AddToISRCIndex adds a new file to the ISRC index after successful download
func AddToISRCIndex(outputDir, isrc, filePath string) { func AddToISRCIndex(outputDir, isrc, filePath string) {
if outputDir == "" || isrc == "" || filePath == "" { if outputDir == "" || isrc == "" || filePath == "" {
return return
+1291 -232
View File
File diff suppressed because it is too large Load Diff
+7 -32
View File
@@ -47,7 +47,7 @@ type LoadedExtension struct {
ID string `json:"id"` ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"` Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"` VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access VMMu sync.Mutex `json:"-"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"` DataDir string `json:"data_dir"`
@@ -55,12 +55,11 @@ type LoadedExtension struct {
IconPath string `json:"icon_path"` IconPath string `json:"icon_path"`
} }
// ExtensionManager manages all loaded extensions
type ExtensionManager struct { type ExtensionManager struct {
mu sync.RWMutex mu sync.RWMutex
extensions map[string]*LoadedExtension extensions map[string]*LoadedExtension
extensionsDir string // Base directory for extensions extensionsDir string
dataDir string // Base directory for extension data dataDir string
} }
var ( var (
@@ -99,7 +98,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
// Open the zip file
zipReader, err := zip.OpenReader(filePath) zipReader, err := zip.OpenReader(filePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package") return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
@@ -222,7 +220,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
SourceDir: extDir, SourceDir: extDir,
} }
// Initialize Goja VM
if err := m.initializeVM(ext); err != nil { if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error() ext.Error = err.Error()
ext.Enabled = false ext.Enabled = false
@@ -269,13 +266,11 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
return goja.Undefined() return goja.Undefined()
}) })
// Run the extension code
_, err = vm.RunString(string(jsCode)) _, err = vm.RunString(string(jsCode))
if err != nil { if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err) return fmt.Errorf("failed to execute extension code: %w", err)
} }
// Verify extension was registered
if registeredExtension == nil || goja.IsUndefined(registeredExtension) { if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
return fmt.Errorf("extension did not call registerExtension()") return fmt.Errorf("extension did not call registerExtension()")
} }
@@ -283,7 +278,6 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
return nil return nil
} }
// UnloadExtension unloads an extension by ID
func (m *ExtensionManager) UnloadExtension(extensionID string) error { func (m *ExtensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -293,9 +287,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return fmt.Errorf("Extension not found") return fmt.Errorf("Extension not found")
} }
// Call cleanup if VM is initialized
if ext.VM != nil { if ext.VM != nil {
// Try to call cleanup function
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null") cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
if err != nil { if err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err) GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
@@ -304,14 +296,12 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
} }
} }
// Remove from registry
delete(m.extensions, extensionID) delete(m.extensions, extensionID)
GoLog("[Extension] Unloaded extension: %s\n", extensionID) GoLog("[Extension] Unloaded extension: %s\n", extensionID)
return nil return nil
} }
// Returns error if extension not found (gomobile compatible)
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) { func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -323,7 +313,6 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
return ext, nil return ext, nil
} }
// GetAllExtensions returns all loaded extensions
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension { func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -347,7 +336,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
ext.Enabled = enabled ext.Enabled = enabled
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled]) GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
// Persist enabled state to settings store
store := GetExtensionSettingsStore() store := GetExtensionSettingsStore()
if err := store.Set(extensionID, "_enabled", enabled); err != nil { if err := store.Set(extensionID, "_enabled", enabled); err != nil {
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err) GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
@@ -356,7 +344,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return nil return nil
} }
// LoadExtensionsFromDirectory scans a directory and loads all valid extensions
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) { func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
var loaded []string var loaded []string
var errors []error var errors []error
@@ -443,7 +430,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
} }
} }
// Initialize Goja VM
if err := m.initializeVM(ext); err != nil { if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error() ext.Error = err.Error()
ext.Enabled = false ext.Enabled = false
@@ -456,19 +442,16 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return ext, nil return ext, nil
} }
// RemoveExtension completely removes an extension (unload + delete files)
func (m *ExtensionManager) RemoveExtension(extensionID string) error { func (m *ExtensionManager) RemoveExtension(extensionID string) error {
ext, err := m.GetExtension(extensionID) ext, err := m.GetExtension(extensionID)
if err != nil { if err != nil {
return err return err
} }
// Unload first
if err := m.UnloadExtension(extensionID); err != nil { if err := m.UnloadExtension(extensionID); err != nil {
return err return err
} }
// Remove source directory
if ext.SourceDir != "" { if ext.SourceDir != "" {
if err := os.RemoveAll(ext.SourceDir); err != nil { if err := os.RemoveAll(ext.SourceDir); err != nil {
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err) GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
@@ -490,7 +473,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
// Open the zip file
zipReader, err := zip.OpenReader(filePath) zipReader, err := zip.OpenReader(filePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package") return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
@@ -554,11 +536,9 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
extDir := existing.SourceDir extDir := existing.SourceDir
wasEnabled := existing.Enabled wasEnabled := existing.Enabled
// Cleanup and unload existing extension
m.CleanupExtension(existing.ID) m.CleanupExtension(existing.ID)
m.UnloadExtension(existing.ID) m.UnloadExtension(existing.ID)
// Remove old source files but keep data directory
if extDir != "" { if extDir != "" {
if err := os.RemoveAll(extDir); err != nil { if err := os.RemoveAll(extDir); err != nil {
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err) GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
@@ -637,16 +617,14 @@ type ExtensionUpgradeInfo struct {
IsInstalled bool `json:"is_installed"` IsInstalled bool `json:"is_installed"`
} }
// checkExtensionUpgradeInternal checks if a package file is an upgrade for an existing extension
// Internal function that returns struct
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) { func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
// Validate file extension // Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
// Open the zip file
zipReader, err := zip.OpenReader(filePath) zipReader, err := zip.OpenReader(filePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("Cannot open extension file") return nil, fmt.Errorf("Cannot open extension file")
} }
@@ -714,7 +692,6 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions() extensions := m.GetAllExtensions()
@@ -736,6 +713,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
HasMetadataProvider bool `json:"has_metadata_provider"` HasMetadataProvider bool `json:"has_metadata_provider"`
HasDownloadProvider bool `json:"has_download_provider"` HasDownloadProvider bool `json:"has_download_provider"`
HasLyricsProvider bool `json:"has_lyrics_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
@@ -793,6 +771,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
Permissions: permissions, Permissions: permissions,
HasMetadataProvider: ext.Manifest.IsMetadataProvider(), HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
HasDownloadProvider: ext.Manifest.IsDownloadProvider(), HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment, SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
SearchBehavior: ext.Manifest.SearchBehavior, SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching, TrackMatching: ext.Manifest.TrackMatching,
@@ -809,8 +788,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== Extension Lifecycle ====================
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error { func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -923,7 +900,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return nil return nil
} }
// UnloadAllExtensions unloads all extensions gracefully
func (m *ExtensionManager) UnloadAllExtensions() { func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Lock() m.mu.Lock()
extensionIDs := make([]string, 0, len(m.extensions)) extensionIDs := make([]string, 0, len(m.extensions))
@@ -940,7 +916,6 @@ func (m *ExtensionManager) UnloadAllExtensions() {
GoLog("[Extension] All extensions unloaded\n") GoLog("[Extension] All extensions unloaded\n")
} }
// The function is called as extension.<actionName>() and can return a result
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) { func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
+50 -73
View File
@@ -7,15 +7,14 @@ import (
"strings" "strings"
) )
// ExtensionType represents the type of extension
type ExtensionType string type ExtensionType string
const ( const (
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider" ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
ExtensionTypeDownloadProvider ExtensionType = "download_provider" ExtensionTypeDownloadProvider ExtensionType = "download_provider"
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
) )
// SettingType represents the type of a setting field
type SettingType string type SettingType string
const ( const (
@@ -26,14 +25,12 @@ const (
SettingTypeButton SettingType = "button" // Action button that calls a JS function SettingTypeButton SettingType = "button" // Action button that calls a JS function
) )
// ExtensionPermissions defines what resources an extension can access
type ExtensionPermissions struct { type ExtensionPermissions struct {
Network []string `json:"network"` // List of allowed domains Network []string `json:"network"`
Storage bool `json:"storage"` // Whether extension can use storage API Storage bool `json:"storage"`
File bool `json:"file"` // Whether extension can use file API File bool `json:"file"`
} }
// ExtensionSetting defines a configurable setting for an extension
type ExtensionSetting struct { type ExtensionSetting struct {
Key string `json:"key"` Key string `json:"key"`
Type SettingType `json:"type"` Type SettingType `json:"type"`
@@ -42,19 +39,17 @@ type ExtensionSetting struct {
Required bool `json:"required,omitempty"` Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"` Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"` Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type Options []string `json:"options,omitempty"`
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin") Action string `json:"action,omitempty"`
} }
// QualityOption represents a quality option for download providers
type QualityOption struct { type QualityOption struct {
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128") ID string `json:"id"`
Label string `json:"label"` // Display name (e.g., "MP3 320kbps") Label string `json:"label"`
Description string `json:"description"` // Optional description (e.g., "Best quality MP3") Description string `json:"description"`
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings Settings []QualitySpecificSetting `json:"settings,omitempty"`
} }
// QualitySpecificSetting represents a setting that's specific to a quality option
type QualitySpecificSetting struct { type QualitySpecificSetting struct {
Key string `json:"key"` Key string `json:"key"`
Type SettingType `json:"type"` Type SettingType `json:"type"`
@@ -63,57 +58,50 @@ type QualitySpecificSetting struct {
Required bool `json:"required,omitempty"` Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"` Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"` Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type Options []string `json:"options,omitempty"`
} }
// SearchFilter defines a filter option for search
type SearchFilter struct { type SearchFilter struct {
ID string `json:"id"` // Filter identifier (e.g., "track", "album", "artist", "playlist") ID string `json:"id"`
Label string `json:"label,omitempty"` // Display label (e.g., "Songs", "Albums", "Artists", "Playlists") Label string `json:"label,omitempty"`
Icon string `json:"icon,omitempty"` // Optional icon name Icon string `json:"icon,omitempty"`
} }
// SearchBehaviorConfig defines custom search behavior for an extension
type SearchBehaviorConfig struct { type SearchBehaviorConfig struct {
Enabled bool `json:"enabled"` // Whether extension provides custom search Enabled bool `json:"enabled"`
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box Placeholder string `json:"placeholder,omitempty"`
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab Primary bool `json:"primary,omitempty"`
Icon string `json:"icon,omitempty"` // Icon for search tab Icon string `json:"icon,omitempty"`
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3) ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
Filters []SearchFilter `json:"filters,omitempty"` // Available search filters (e.g., track, album, artist, playlist) Filters []SearchFilter `json:"filters,omitempty"`
} }
// URLHandlerConfig defines custom URL handling for an extension
type URLHandlerConfig struct { type URLHandlerConfig struct {
Enabled bool `json:"enabled"` // Whether extension handles URLs Enabled bool `json:"enabled"`
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com") Patterns []string `json:"patterns,omitempty"`
} }
// TrackMatchingConfig defines custom track matching behavior
type TrackMatchingConfig struct { type TrackMatchingConfig struct {
CustomMatching bool `json:"customMatching"` // Whether extension handles matching CustomMatching bool `json:"customMatching"`
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom" Strategy string `json:"strategy,omitempty"`
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching DurationTolerance int `json:"durationTolerance,omitempty"`
} }
// PostProcessingHook defines a post-processing hook
type PostProcessingHook struct { type PostProcessingHook struct {
ID string `json:"id"` // Unique identifier ID string `json:"id"`
Name string `json:"name"` // Display name Name string `json:"name"`
Description string `json:"description,omitempty"` // Description Description string `json:"description,omitempty"`
DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default DefaultEnabled bool `json:"defaultEnabled,omitempty"`
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"]) SupportedFormats []string `json:"supportedFormats,omitempty"`
} }
// PostProcessingConfig defines post-processing capabilities
type PostProcessingConfig struct { type PostProcessingConfig struct {
Enabled bool `json:"enabled"` // Whether extension provides post-processing Enabled bool `json:"enabled"`
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks Hooks []PostProcessingHook `json:"hooks,omitempty"`
} }
// ExtensionManifest represents the manifest.json of an extension
type ExtensionManifest struct { type ExtensionManifest struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
@@ -121,22 +109,21 @@ type ExtensionManifest struct {
Author string `json:"author"` Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png") Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"` Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"` Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"` Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"` MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon) SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.) Capabilities map[string]interface{} `json:"capabilities,omitempty"`
} }
// ManifestValidationError represents a validation error in the manifest
type ManifestValidationError struct { type ManifestValidationError struct {
Field string Field string
Message string Message string
@@ -146,7 +133,6 @@ func (e *ManifestValidationError) Error() string {
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message) return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
} }
// ParseManifest parses and validates a manifest from JSON bytes
func ParseManifest(data []byte) (*ExtensionManifest, error) { func ParseManifest(data []byte) (*ExtensionManifest, error) {
var manifest ExtensionManifest var manifest ExtensionManifest
if err := json.Unmarshal(data, &manifest); err != nil { if err := json.Unmarshal(data, &manifest); err != nil {
@@ -182,15 +168,14 @@ func (m *ExtensionManifest) Validate() error {
} }
for _, t := range m.Types { for _, t := range m.Types {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider { if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
return &ManifestValidationError{ return &ManifestValidationError{
Field: "type", 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),
} }
} }
} }
// Validate settings if present
for i, setting := range m.Settings { for i, setting := range m.Settings {
if strings.TrimSpace(setting.Key) == "" { if strings.TrimSpace(setting.Key) == "" {
return &ManifestValidationError{ return &ManifestValidationError{
@@ -225,7 +210,6 @@ func (m *ExtensionManifest) Validate() error {
return nil return nil
} }
// HasType checks if the extension has a specific type
func (m *ExtensionManifest) HasType(t ExtensionType) bool { func (m *ExtensionManifest) HasType(t ExtensionType) bool {
for _, et := range m.Types { for _, et := range m.Types {
if et == t { if et == t {
@@ -235,17 +219,18 @@ func (m *ExtensionManifest) HasType(t ExtensionType) bool {
return false return false
} }
// IsMetadataProvider returns true if extension provides metadata
func (m *ExtensionManifest) IsMetadataProvider() bool { func (m *ExtensionManifest) IsMetadataProvider() bool {
return m.HasType(ExtensionTypeMetadataProvider) return m.HasType(ExtensionTypeMetadataProvider)
} }
// IsDownloadProvider returns true if extension provides downloads
func (m *ExtensionManifest) IsDownloadProvider() bool { func (m *ExtensionManifest) IsDownloadProvider() bool {
return m.HasType(ExtensionTypeDownloadProvider) return m.HasType(ExtensionTypeDownloadProvider)
} }
// IsDomainAllowed checks if a domain is in the allowed network permissions func (m *ExtensionManifest) IsLyricsProvider() bool {
return m.HasType(ExtensionTypeLyricsProvider)
}
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool { func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain)) domain = strings.ToLower(strings.TrimSpace(domain))
for _, allowed := range m.Permissions.Network { for _, allowed := range m.Permissions.Network {
@@ -255,7 +240,7 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
} }
// Support wildcard subdomains (e.g., *.example.com) // Support wildcard subdomains (e.g., *.example.com)
if strings.HasPrefix(allowed, "*.") { if strings.HasPrefix(allowed, "*.") {
suffix := allowed[1:] // Remove the * suffix := allowed[1:]
if strings.HasSuffix(domain, suffix) { if strings.HasSuffix(domain, suffix) {
return true return true
} }
@@ -264,27 +249,22 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
return false return false
} }
// HasCustomSearch returns true if extension provides custom search
func (m *ExtensionManifest) HasCustomSearch() bool { func (m *ExtensionManifest) HasCustomSearch() bool {
return m.SearchBehavior != nil && m.SearchBehavior.Enabled return m.SearchBehavior != nil && m.SearchBehavior.Enabled
} }
// HasCustomMatching returns true if extension provides custom track matching
func (m *ExtensionManifest) HasCustomMatching() bool { func (m *ExtensionManifest) HasCustomMatching() bool {
return m.TrackMatching != nil && m.TrackMatching.CustomMatching return m.TrackMatching != nil && m.TrackMatching.CustomMatching
} }
// HasPostProcessing returns true if extension provides post-processing
func (m *ExtensionManifest) HasPostProcessing() bool { func (m *ExtensionManifest) HasPostProcessing() bool {
return m.PostProcessing != nil && m.PostProcessing.Enabled return m.PostProcessing != nil && m.PostProcessing.Enabled
} }
// HasURLHandler returns true if extension handles custom URLs
func (m *ExtensionManifest) HasURLHandler() bool { func (m *ExtensionManifest) HasURLHandler() bool {
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0 return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
} }
// MatchesURL checks if a URL matches any of the extension's URL patterns
func (m *ExtensionManifest) MatchesURL(urlStr string) bool { func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
if !m.HasURLHandler() { if !m.HasURLHandler() {
return false return false
@@ -293,7 +273,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
urlStr = strings.ToLower(strings.TrimSpace(urlStr)) urlStr = strings.ToLower(strings.TrimSpace(urlStr))
for _, pattern := range m.URLHandler.Patterns { for _, pattern := range m.URLHandler.Patterns {
pattern = strings.ToLower(strings.TrimSpace(pattern)) pattern = strings.ToLower(strings.TrimSpace(pattern))
// Check if URL contains the pattern (host match)
if strings.Contains(urlStr, pattern) { if strings.Contains(urlStr, pattern) {
return true return true
} }
@@ -301,7 +280,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
return false return false
} }
// GetPostProcessingHooks returns all post-processing hooks
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook { func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
if m.PostProcessing == nil { if m.PostProcessing == nil {
return nil return nil
@@ -309,7 +287,6 @@ func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
return m.PostProcessing.Hooks return m.PostProcessing.Hooks
} }
// ToJSON serializes the manifest to JSON
func (m *ExtensionManifest) ToJSON() ([]byte, error) { func (m *ExtensionManifest) ToJSON() ([]byte, error) {
return json.Marshal(m) return json.Marshal(m)
} }
File diff suppressed because it is too large Load Diff
+49 -40
View File
@@ -1,8 +1,11 @@
package gobackend package gobackend
import ( import (
"fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"sync" "sync"
"time" "time"
@@ -23,9 +26,8 @@ type ExtensionAuthState struct {
RefreshToken string RefreshToken string
ExpiresAt time.Time ExpiresAt time.Time
IsAuthenticated bool IsAuthenticated bool
// PKCE support PKCEVerifier string
PKCEVerifier string PKCEChallenge string
PKCEChallenge string
} }
type PendingAuthRequest struct { type PendingAuthRequest struct {
@@ -39,7 +41,6 @@ var (
pendingAuthRequestsMu sync.RWMutex pendingAuthRequestsMu sync.RWMutex
) )
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest { func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
pendingAuthRequestsMu.RLock() pendingAuthRequestsMu.RLock()
defer pendingAuthRequestsMu.RUnlock() defer pendingAuthRequestsMu.RUnlock()
@@ -105,8 +106,16 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
Jar: jar, Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Validate redirect target domain against allowed domains if req.URL.Scheme != "https" {
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
return fmt.Errorf("redirect blocked: only https is allowed")
}
domain := req.URL.Hostname() domain := req.URL.Hostname()
if domain == "" {
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
return fmt.Errorf("redirect blocked: hostname is required")
}
if !ext.Manifest.IsDomainAllowed(domain) { if !ext.Manifest.IsDomainAllowed(domain) {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain} return &RedirectBlockedError{Domain: domain}
@@ -115,7 +124,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true} return &RedirectBlockedError{Domain: domain, IsPrivate: true}
} }
// Default redirect limit (10)
if len(via) >= 10 { if len(via) >= 10 {
return http.ErrUseLastResponse return http.ErrUseLastResponse
} }
@@ -141,35 +149,48 @@ func (e *RedirectBlockedError) Error() string {
// isPrivateIP checks if a hostname resolves to a private/local IP address // isPrivateIP checks if a hostname resolves to a private/local IP address
func isPrivateIP(host string) bool { func isPrivateIP(host string) bool {
// Block common private network patterns hostLower := strings.ToLower(strings.TrimSpace(host))
// This is a simple check - for production, consider DNS resolution if hostLower == "" {
privatePatterns := []string{ return false
"localhost",
"127.",
"10.",
"172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.",
"172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.",
"192.168.",
"169.254.",
"::1",
"fc00:",
"fe80:",
} }
hostLower := host if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
for _, pattern := range privatePatterns { return true
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern { }
if ip := net.ParseIP(hostLower); ip != nil {
return isPrivateIPAddr(ip)
}
ips, err := net.LookupIP(hostLower)
if err != nil {
return false
}
for _, ip := range ips {
if isPrivateIPAddr(ip) {
return true return true
} }
} }
// Also block .local domains return false
if len(host) > 6 && host[len(host)-6:] == ".local" { }
func isPrivateIPAddr(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified() {
return true
}
if !ip.IsGlobalUnicast() {
return true return true
} }
return false return false
} }
@@ -201,18 +222,16 @@ func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings r.settings = settings
} }
// RegisterAPIs registers all sandboxed APIs to the Goja VM
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
r.vm = vm r.vm = vm
// HTTP client (sandboxed to allowed domains)
httpObj := vm.NewObject() httpObj := vm.NewObject()
httpObj.Set("get", r.httpGet) httpObj.Set("get", r.httpGet)
httpObj.Set("post", r.httpPost) httpObj.Set("post", r.httpPost)
httpObj.Set("put", r.httpPut) httpObj.Set("put", r.httpPut)
httpObj.Set("delete", r.httpDelete) httpObj.Set("delete", r.httpDelete)
httpObj.Set("patch", r.httpPatch) httpObj.Set("patch", r.httpPatch)
httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.) httpObj.Set("request", r.httpRequest)
httpObj.Set("clearCookies", r.httpClearCookies) httpObj.Set("clearCookies", r.httpClearCookies)
vm.Set("http", httpObj) vm.Set("http", httpObj)
@@ -222,7 +241,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
storageObj.Set("remove", r.storageRemove) storageObj.Set("remove", r.storageRemove)
vm.Set("storage", storageObj) vm.Set("storage", storageObj)
// Secure Credentials API (encrypted storage for sensitive data)
credentialsObj := vm.NewObject() credentialsObj := vm.NewObject()
credentialsObj.Set("store", r.credentialsStore) credentialsObj.Set("store", r.credentialsStore)
credentialsObj.Set("get", r.credentialsGet) credentialsObj.Set("get", r.credentialsGet)
@@ -237,14 +255,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
authObj.Set("clearAuth", r.authClear) authObj.Set("clearAuth", r.authClear)
authObj.Set("isAuthenticated", r.authIsAuthenticated) authObj.Set("isAuthenticated", r.authIsAuthenticated)
authObj.Set("getTokens", r.authGetTokens) authObj.Set("getTokens", r.authGetTokens)
// PKCE support
authObj.Set("generatePKCE", r.authGeneratePKCE) authObj.Set("generatePKCE", r.authGeneratePKCE)
authObj.Set("getPKCE", r.authGetPKCE) authObj.Set("getPKCE", r.authGetPKCE)
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE) authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE) authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
vm.Set("auth", authObj) vm.Set("auth", authObj)
// File operations (sandboxed)
fileObj := vm.NewObject() fileObj := vm.NewObject()
fileObj.Set("download", r.fileDownload) fileObj.Set("download", r.fileDownload)
fileObj.Set("exists", r.fileExists) fileObj.Set("exists", r.fileExists)
@@ -262,7 +278,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
ffmpegObj.Set("convert", r.ffmpegConvert) ffmpegObj.Set("convert", r.ffmpegConvert)
vm.Set("ffmpeg", ffmpegObj) vm.Set("ffmpeg", ffmpegObj)
// Track matching API
matchingObj := vm.NewObject() matchingObj := vm.NewObject()
matchingObj.Set("compareStrings", r.matchingCompareStrings) matchingObj.Set("compareStrings", r.matchingCompareStrings)
matchingObj.Set("compareDuration", r.matchingCompareDuration) matchingObj.Set("compareDuration", r.matchingCompareDuration)
@@ -279,14 +294,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("hmacSHA1", r.hmacSHA1) utilsObj.Set("hmacSHA1", r.hmacSHA1)
utilsObj.Set("parseJSON", r.parseJSON) utilsObj.Set("parseJSON", r.parseJSON)
utilsObj.Set("stringifyJSON", r.stringifyJSON) utilsObj.Set("stringifyJSON", r.stringifyJSON)
// Crypto utilities for developers
utilsObj.Set("encrypt", r.cryptoEncrypt) utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt) utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("generateKey", r.cryptoGenerateKey) utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent) utilsObj.Set("randomUserAgent", r.randomUserAgent)
vm.Set("utils", utilsObj) vm.Set("utils", utilsObj)
// Log object (already set in extension_manager.go, but we can enhance it)
logObj := vm.NewObject() logObj := vm.NewObject()
logObj.Set("debug", r.logDebug) logObj.Set("debug", r.logDebug)
logObj.Set("info", r.logInfo) logObj.Set("info", r.logInfo)
@@ -298,10 +311,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper) gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
vm.Set("gobackend", gobackendObj) vm.Set("gobackend", gobackendObj)
// ==================== Browser-like Polyfills ====================
// These make porting browser/Node.js libraries easier
// Global fetch() - Promise-style HTTP API (browser-compatible)
vm.Set("fetch", r.fetchPolyfill) vm.Set("fetch", r.fetchPolyfill)
vm.Set("atob", r.atobPolyfill) vm.Set("atob", r.atobPolyfill)
+59 -18
View File
@@ -18,6 +18,43 @@ import (
// ==================== Auth API (OAuth Support) ==================== // ==================== 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 { func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ 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() 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() pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID, ExtensionID: r.extensionID,
@@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
state.AuthCode = "" state.AuthCode = ""
extensionAuthStateMu.Unlock() 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{}{ return r.vm.ToValue(map[string]interface{}{
"success": true, "success": true,
@@ -70,13 +114,11 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(state.AuthCode) return r.vm.ToValue(state.AuthCode)
} }
// authSetCode sets auth code and tokens (can be called by extension after token exchange)
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
// Can accept either just auth code or an object with tokens
arg := call.Arguments[0].Export() arg := call.Arguments[0].Export()
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
@@ -123,7 +165,6 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// authIsAuthenticated checks if extension has valid auth
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -196,7 +237,6 @@ func generatePKCEChallenge(verifier string) string {
} }
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
// Default length is 64 characters
length := 64 length := 64
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 { if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
@@ -249,9 +289,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
}) })
} }
// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL
// config: { authUrl, clientId, redirectUri, scope, extraParams } // config: { authUrl, clientId, redirectUri, scope, extraParams }
// Returns: { success, authUrl, pkce: { verifier, challenge } }
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -269,7 +307,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
}) })
} }
// Required fields
authURL, _ := config["authUrl"].(string) authURL, _ := config["authUrl"].(string)
clientID, _ := config["clientId"].(string) clientID, _ := config["clientId"].(string)
redirectURI, _ := config["redirectUri"].(string) redirectURI, _ := config["redirectUri"].(string)
@@ -280,12 +317,16 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
"error": "authUrl, clientId, and redirectUri are required", "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(),
})
}
// Optional fields
scope, _ := config["scope"].(string) scope, _ := config["scope"].(string)
extraParams, _ := config["extraParams"].(map[string]interface{}) extraParams, _ := config["extraParams"].(map[string]interface{})
// Generate PKCE
verifier, err := generatePKCEVerifier(64) verifier, err := generatePKCEVerifier(64)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -295,7 +336,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
} }
challenge := generatePKCEChallenge(verifier) challenge := generatePKCEChallenge(verifier)
// Store PKCE in auth state
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID] state, exists := extensionAuthState[r.extensionID]
if !exists { if !exists {
@@ -304,10 +344,9 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
} }
state.PKCEVerifier = verifier state.PKCEVerifier = verifier
state.PKCEChallenge = challenge state.PKCEChallenge = challenge
state.AuthCode = "" // Clear any previous auth code state.AuthCode = ""
extensionAuthStateMu.Unlock() extensionAuthStateMu.Unlock()
// Build OAuth URL with PKCE parameters
parsedURL, err := url.Parse(authURL) parsedURL, err := url.Parse(authURL)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -327,7 +366,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
query.Set("scope", scope) query.Set("scope", scope)
} }
// Add extra params
for k, v := range extraParams { for k, v := range extraParams {
query.Set(k, fmt.Sprintf("%v", v)) query.Set(k, fmt.Sprintf("%v", v))
} }
@@ -335,7 +373,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
parsedURL.RawQuery = query.Encode() parsedURL.RawQuery = query.Encode()
fullAuthURL := parsedURL.String() fullAuthURL := parsedURL.String()
// Store pending auth request for Flutter
pendingAuthRequestsMu.Lock() pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID, ExtensionID: r.extensionID,
@@ -344,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
} }
pendingAuthRequestsMu.Unlock() 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{}{ return r.vm.ToValue(map[string]interface{}{
"success": true, "success": true,
@@ -454,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(), "error": err.Error(),
}) })
} }
bodyPreview := sanitizeSensitiveLogText(string(body))
if len(bodyPreview) > 1000 {
bodyPreview = bodyPreview[:1000] + "...[truncated]"
}
var tokenResp map[string]interface{} var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil { if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
"error": fmt.Sprintf("failed to parse token response: %v", err), "error": fmt.Sprintf("failed to parse token response: %v", err),
"body": string(body), "body": bodyPreview,
}) })
} }
@@ -481,7 +522,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
"error": "no access_token in response", "error": "no access_token in response",
"body": string(body), "body": bodyPreview,
}) })
} }
-12
View File
@@ -64,7 +64,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
command := call.Arguments[0].String() command := call.Arguments[0].String()
// Generate unique command ID
ffmpegCommandsMu.Lock() ffmpegCommandsMu.Lock()
ffmpegCommandID++ ffmpegCommandID++
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID) cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
@@ -77,7 +76,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID) GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
// Wait for completion (with timeout)
timeout := 5 * time.Minute timeout := 5 * time.Minute
start := time.Now() start := time.Now()
for { for {
@@ -97,7 +95,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
} }
ffmpegCommandsMu.RUnlock() ffmpegCommandsMu.RUnlock()
// Cleanup
ClearFFmpegCommand(cmdID) ClearFFmpegCommand(cmdID)
return r.vm.ToValue(result) return r.vm.ToValue(result)
} }
@@ -124,7 +121,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
filePath := call.Arguments[0].String() filePath := call.Arguments[0].String()
// Use Go's built-in audio quality function
quality, err := GetAudioQuality(filePath) quality, err := GetAudioQuality(filePath)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -153,7 +149,6 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
inputPath := call.Arguments[0].String() inputPath := call.Arguments[0].String()
outputPath := call.Arguments[1].String() outputPath := call.Arguments[1].String()
// Get options if provided
options := map[string]interface{}{} options := map[string]interface{}{}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok { if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
@@ -161,36 +156,29 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
} }
} }
// Build FFmpeg command
var cmdParts []string var cmdParts []string
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath)) cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
// Audio codec
if codec, ok := options["codec"].(string); ok { if codec, ok := options["codec"].(string); ok {
cmdParts = append(cmdParts, "-c:a", codec) cmdParts = append(cmdParts, "-c:a", codec)
} }
// Bitrate
if bitrate, ok := options["bitrate"].(string); ok { if bitrate, ok := options["bitrate"].(string); ok {
cmdParts = append(cmdParts, "-b:a", bitrate) cmdParts = append(cmdParts, "-b:a", bitrate)
} }
// Sample rate
if sampleRate, ok := options["sample_rate"].(float64); ok { if sampleRate, ok := options["sample_rate"].(float64); ok {
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate))) cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
} }
// Channels
if channels, ok := options["channels"].(float64); ok { if channels, ok := options["channels"].(float64); ok {
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels))) cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
} }
// Overwrite output
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath)) cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
command := strings.Join(cmdParts, " ") command := strings.Join(cmdParts, " ")
// Execute via ffmpegExecute
execCall := goja.FunctionCall{ execCall := goja.FunctionCall{
Arguments: []goja.Value{r.vm.ToValue(command)}, Arguments: []goja.Value{r.vm.ToValue(command)},
} }
+28 -8
View File
@@ -15,7 +15,6 @@ import (
// ==================== File API (Sandboxed) ==================== // ==================== File API (Sandboxed) ====================
// List of allowed directories for file operations (set by Go backend for download operations)
var ( var (
allowedDownloadDirs []string allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex allowedDownloadDirsMu sync.RWMutex
@@ -42,18 +41,40 @@ func isPathInAllowedDirs(absPath string) bool {
defer allowedDownloadDirsMu.RUnlock() defer allowedDownloadDirsMu.RUnlock()
for _, allowedDir := range allowedDownloadDirs { for _, allowedDir := range allowedDownloadDirs {
if strings.HasPrefix(absPath, allowedDir) { if isPathWithinBase(allowedDir, absPath) {
return true return true
} }
} }
return false return false
} }
// validatePath checks if the path is within the extension's sandbox func isPathWithinBase(baseDir, targetPath string) bool {
// Security: Absolute paths are BLOCKED unless they're in allowed download directories baseAbs, err := filepath.Abs(baseDir)
// Extensions should use relative paths for their own data storage if err != nil {
return false
}
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return false
}
rel, err := filepath.Rel(baseAbs, targetAbs)
if err != nil {
return false
}
rel = filepath.Clean(rel)
if rel == "." {
return true
}
prefix := ".." + string(filepath.Separator)
if rel == ".." || strings.HasPrefix(rel, prefix) {
return false
}
return true
}
func (r *ExtensionRuntime) validatePath(path string) (string, error) { func (r *ExtensionRuntime) validatePath(path string) (string, error) {
// Check if extension has file permission
if !r.manifest.Permissions.File { if !r.manifest.Permissions.File {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission") return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
} }
@@ -81,7 +102,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
} }
absDataDir, _ := filepath.Abs(r.dataDir) absDataDir, _ := filepath.Abs(r.dataDir)
if !strings.HasPrefix(absPath, absDataDir) { if !isPathWithinBase(absDataDir, absPath) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path) return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
} }
@@ -327,7 +348,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
}) })
} }
// Create directory if needed
dir := filepath.Dir(fullPath) dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
+23 -46
View File
@@ -14,23 +14,33 @@ import (
// ==================== HTTP API (Sandboxed) ==================== // ==================== HTTP API (Sandboxed) ====================
// HTTPResponse represents the response from an HTTP request
type HTTPResponse struct { type HTTPResponse struct {
StatusCode int `json:"statusCode"` StatusCode int `json:"statusCode"`
Body string `json:"body"` Body string `json:"body"`
Headers map[string]string `json:"headers"` Headers map[string]string `json:"headers"`
} }
// validateDomain checks if the domain is allowed by the extension's permissions
func (r *ExtensionRuntime) validateDomain(urlStr string) error { func (r *ExtensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr) parsed, err := url.Parse(urlStr)
if err != nil { if err != nil {
return fmt.Errorf("invalid URL: %w", err) return fmt.Errorf("invalid URL: %w", err)
} }
domain := parsed.Hostname() if parsed.Scheme == "" {
return fmt.Errorf("invalid URL: scheme is required")
}
if parsed.Scheme != "https" {
return fmt.Errorf("network access denied: only https is allowed")
}
if parsed.User != nil {
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
}
domain := parsed.Hostname()
if domain == "" {
return fmt.Errorf("invalid URL: hostname is required")
}
// Block private/local network access (SSRF protection)
if isPrivateIP(domain) { if isPrivateIP(domain) {
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain) return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
} }
@@ -42,7 +52,6 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
return nil return nil
} }
// httpGet performs a GET request (sandboxed)
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -76,16 +85,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
// Only set default User-Agent if not provided by extension
if req.Header.Get("User-Agent") == "" { if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -101,26 +108,24 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}) })
} }
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
respHeaders[k] = v[0] respHeaders[k] = v[0]
} else { } else {
respHeaders[k] = v // Return as array if multiple values respHeaders[k] = v
} }
} }
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
}) })
} }
// httpPost performs a POST request (sandboxed)
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -137,7 +142,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
// Get body if provided - support both string and object
var bodyStr string var bodyStr string
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export() bodyArg := call.Arguments[1].Export()
@@ -145,7 +149,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
case string: case string:
bodyStr = v bodyStr = v
case map[string]interface{}, []interface{}: case map[string]interface{}, []interface{}:
// Auto-stringify objects and arrays to JSON
jsonBytes, err := json.Marshal(v) jsonBytes, err := json.Marshal(v)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -154,12 +157,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
} }
bodyStr = string(jsonBytes) bodyStr = string(jsonBytes)
default: default:
// Fallback to string conversion
bodyStr = call.Arguments[1].String() bodyStr = call.Arguments[1].String()
} }
} }
// Get headers if provided
headers := make(map[string]string) headers := make(map[string]string)
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
headersObj := call.Arguments[2].Export() headersObj := call.Arguments[2].Export()
@@ -177,11 +178,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
// Only set defaults if not provided by extension
if req.Header.Get("User-Agent") == "" { if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
} }
@@ -189,7 +189,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -205,19 +204,18 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
// Extract response headers - return all values as arrays for multi-value headers
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
respHeaders[k] = v[0] respHeaders[k] = v[0]
} else { } else {
respHeaders[k] = v // Return as array if multiple values respHeaders[k] = v
} }
} }
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
@@ -240,27 +238,22 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
// Default options
method := "GET" method := "GET"
var bodyStr string var bodyStr string
headers := make(map[string]string) headers := make(map[string]string)
// Parse options if provided
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
optionsObj := call.Arguments[1].Export() optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok { if opts, ok := optionsObj.(map[string]interface{}); ok {
// Get method
if m, ok := opts["method"].(string); ok { if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m) method = strings.ToUpper(m)
} }
// Get body - support both string and object
if bodyArg, ok := opts["body"]; ok && bodyArg != nil { if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) { switch v := bodyArg.(type) {
case string: case string:
bodyStr = v bodyStr = v
case map[string]interface{}, []interface{}: case map[string]interface{}, []interface{}:
// Auto-stringify objects and arrays to JSON
jsonBytes, err := json.Marshal(v) jsonBytes, err := json.Marshal(v)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -273,7 +266,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
} }
} }
// Get headers
if h, ok := opts["headers"].(map[string]interface{}); ok { if h, ok := opts["headers"].(map[string]interface{}); ok {
for k, v := range h { for k, v := range h {
headers[k] = fmt.Sprintf("%v", v) headers[k] = fmt.Sprintf("%v", v)
@@ -282,7 +274,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
} }
} }
// Create request
var reqBody io.Reader var reqBody io.Reader
if bodyStr != "" { if bodyStr != "" {
reqBody = strings.NewReader(bodyStr) reqBody = strings.NewReader(bodyStr)
@@ -295,11 +286,10 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
// Only set defaults if not provided by extension
if req.Header.Get("User-Agent") == "" { if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
} }
@@ -307,7 +297,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -323,20 +312,18 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
// Extract response headers - return all values as arrays for multi-value headers
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
respHeaders[k] = v[0] respHeaders[k] = v[0]
} else { } else {
respHeaders[k] = v // Return as array if multiple values respHeaders[k] = v
} }
} }
// Return response with helper properties
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
@@ -347,7 +334,6 @@ func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call) return r.httpMethodShortcut("PUT", call)
} }
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call) return r.httpMethodShortcut("DELETE", call)
} }
@@ -356,8 +342,6 @@ func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call) return r.httpMethodShortcut("PATCH", call)
} }
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -377,9 +361,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
var bodyStr string var bodyStr string
headers := make(map[string]string) headers := make(map[string]string)
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
if method == "DELETE" { if method == "DELETE" {
// http.delete(url, headers)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export() headersObj := call.Arguments[1].Export()
if h, ok := headersObj.(map[string]interface{}); ok { if h, ok := headersObj.(map[string]interface{}); ok {
@@ -389,7 +371,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
} }
} }
} else { } else {
// http.put(url, body, headers) / http.patch(url, body, headers)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export() bodyArg := call.Arguments[1].Export()
switch v := bodyArg.(type) { switch v := bodyArg.(type) {
@@ -418,7 +399,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
} }
} }
// Create request
var reqBody io.Reader var reqBody io.Reader
if bodyStr != "" { if bodyStr != "" {
reqBody = strings.NewReader(bodyStr) reqBody = strings.NewReader(bodyStr)
@@ -431,7 +411,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
@@ -442,7 +421,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -458,7 +436,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}) })
} }
// Extract response headers
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
+4 -19
View File
@@ -9,7 +9,6 @@ import (
// ==================== Track Matching API ==================== // ==================== Track Matching API ====================
// matchingCompareStrings compares two strings with fuzzy matching
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(0.0) return r.vm.ToValue(0.0)
@@ -22,12 +21,10 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
return r.vm.ToValue(1.0) return r.vm.ToValue(1.0)
} }
// Calculate Levenshtein distance-based similarity
similarity := calculateStringSimilarity(str1, str2) similarity := calculateStringSimilarity(str1, str2)
return r.vm.ToValue(similarity) return r.vm.ToValue(similarity)
} }
// matchingCompareDuration compares two durations with tolerance
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -36,8 +33,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
dur1 := int(call.Arguments[0].ToInteger()) dur1 := int(call.Arguments[0].ToInteger())
dur2 := int(call.Arguments[1].ToInteger()) dur2 := int(call.Arguments[1].ToInteger())
// Default tolerance: 3 seconds tolerance := 3000
tolerance := 3000 // milliseconds
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) { if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
tolerance = int(call.Arguments[2].ToInteger()) tolerance = int(call.Arguments[2].ToInteger())
} }
@@ -50,7 +46,6 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
return r.vm.ToValue(diff <= tolerance) return r.vm.ToValue(diff <= tolerance)
} }
// matchingNormalizeString normalizes a string for comparison
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -61,7 +56,6 @@ func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.
return r.vm.ToValue(normalized) return r.vm.ToValue(normalized)
} }
// calculateStringSimilarity calculates similarity between two strings (0-1)
func calculateStringSimilarity(s1, s2 string) float64 { func calculateStringSimilarity(s1, s2 string) float64 {
if len(s1) == 0 && len(s2) == 0 { if len(s1) == 0 && len(s2) == 0 {
return 1.0 return 1.0
@@ -70,7 +64,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
return 0.0 return 0.0
} }
// Use Levenshtein distance
distance := levenshteinDistance(s1, s2) distance := levenshteinDistance(s1, s2)
maxLen := len(s1) maxLen := len(s1)
if len(s2) > maxLen { if len(s2) > maxLen {
@@ -80,7 +73,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
return 1.0 - float64(distance)/float64(maxLen) return 1.0 - float64(distance)/float64(maxLen)
} }
// levenshteinDistance calculates the Levenshtein distance between two strings
func levenshteinDistance(s1, s2 string) int { func levenshteinDistance(s1, s2 string) int {
if len(s1) == 0 { if len(s1) == 0 {
return len(s2) return len(s2)
@@ -89,7 +81,6 @@ func levenshteinDistance(s1, s2 string) int {
return len(s1) return len(s1)
} }
// Create matrix
matrix := make([][]int, len(s1)+1) matrix := make([][]int, len(s1)+1)
for i := range matrix { for i := range matrix {
matrix[i] = make([]int, len(s2)+1) matrix[i] = make([]int, len(s2)+1)
@@ -99,7 +90,6 @@ func levenshteinDistance(s1, s2 string) int {
matrix[0][j] = j matrix[0][j] = j
} }
// Fill matrix
for i := 1; i <= len(s1); i++ { for i := 1; i <= len(s1); i++ {
for j := 1; j <= len(s2); j++ { for j := 1; j <= len(s2); j++ {
cost := 1 cost := 1
@@ -107,9 +97,9 @@ func levenshteinDistance(s1, s2 string) int {
cost = 0 cost = 0
} }
matrix[i][j] = min( matrix[i][j] = min(
matrix[i-1][j]+1, // deletion matrix[i-1][j]+1,
matrix[i][j-1]+1, // insertion matrix[i][j-1]+1,
matrix[i-1][j-1]+cost, // substitution matrix[i-1][j-1]+cost,
) )
} }
} }
@@ -117,12 +107,9 @@ func levenshteinDistance(s1, s2 string) int {
return matrix[len(s1)][len(s2)] return matrix[len(s1)][len(s2)]
} }
// normalizeStringForMatching normalizes a string for comparison
func normalizeStringForMatching(s string) string { func normalizeStringForMatching(s string) string {
// Convert to lowercase
s = strings.ToLower(s) s = strings.ToLower(s)
// Remove common suffixes/prefixes
suffixes := []string{ suffixes := []string{
" (remastered)", " (remaster)", " - remastered", " - remaster", " (remastered)", " (remaster)", " - remastered", " - remaster",
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition", " (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
@@ -136,7 +123,6 @@ func normalizeStringForMatching(s string) string {
} }
} }
// Remove special characters
var result strings.Builder var result strings.Builder
for _, r := range s { for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
@@ -144,7 +130,6 @@ func normalizeStringForMatching(s string) string {
} }
} }
// Collapse multiple spaces
s = strings.Join(strings.Fields(result.String()), " ") s = strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(s) return strings.TrimSpace(s)
+1 -41
View File
@@ -25,14 +25,11 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
} }
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
return r.createFetchError(err.Error()) return r.createFetchError(err.Error())
} }
// Parse options
method := "GET" method := "GET"
var bodyStr string var bodyStr string
headers := make(map[string]string) headers := make(map[string]string)
@@ -40,7 +37,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
optionsObj := call.Arguments[1].Export() optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok { if opts, ok := optionsObj.(map[string]interface{}); ok {
// Method
if m, ok := opts["method"].(string); ok { if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m) method = strings.ToUpper(m)
} }
@@ -61,7 +57,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
} }
} }
// Headers
if h, ok := opts["headers"]; ok && h != nil { if h, ok := opts["headers"]; ok && h != nil {
switch hv := h.(type) { switch hv := h.(type) {
case map[string]interface{}: case map[string]interface{}:
@@ -73,7 +68,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
} }
} }
// Create HTTP request
var reqBody io.Reader var reqBody io.Reader
if bodyStr != "" { if bodyStr != "" {
reqBody = strings.NewReader(bodyStr) reqBody = strings.NewReader(bodyStr)
@@ -84,11 +78,9 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return r.createFetchError(err.Error()) return r.createFetchError(err.Error())
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
// Set defaults if not provided
if req.Header.Get("User-Agent") == "" { if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
} }
@@ -96,20 +88,17 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.createFetchError(err.Error()) return r.createFetchError(err.Error())
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.createFetchError(err.Error()) return r.createFetchError(err.Error())
} }
// Extract response headers
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
@@ -119,7 +108,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
} }
} }
// Create Response object (browser-compatible)
responseObj := r.vm.NewObject() responseObj := r.vm.NewObject()
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300) responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
responseObj.Set("status", resp.StatusCode) responseObj.Set("status", resp.StatusCode)
@@ -127,15 +115,12 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
responseObj.Set("headers", respHeaders) responseObj.Set("headers", respHeaders)
responseObj.Set("url", urlStr) responseObj.Set("url", urlStr)
// Store body for methods
bodyString := string(body) bodyString := string(body)
// text() method - returns body as string
responseObj.Set("text", func(call goja.FunctionCall) goja.Value { responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(bodyString) return r.vm.ToValue(bodyString)
}) })
// json() method - parses body as JSON
responseObj.Set("json", func(call goja.FunctionCall) goja.Value { responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
var result interface{} var result interface{}
if err := json.Unmarshal(body, &result); err != nil { if err := json.Unmarshal(body, &result); err != nil {
@@ -145,9 +130,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result) return r.vm.ToValue(result)
}) })
// arrayBuffer() method - returns body as array (simplified)
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value { responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
// Return as array of bytes
byteArray := make([]interface{}, len(body)) byteArray := make([]interface{}, len(body))
for i, b := range body { for i, b := range body {
byteArray[i] = int(b) byteArray[i] = int(b)
@@ -182,7 +165,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
input := call.Arguments[0].String() input := call.Arguments[0].String()
decoded, err := base64.StdEncoding.DecodeString(input) decoded, err := base64.StdEncoding.DecodeString(input)
if err != nil { if err != nil {
// Try URL-safe base64
decoded, err = base64.URLEncoding.DecodeString(input) decoded, err = base64.URLEncoding.DecodeString(input)
if err != nil { if err != nil {
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err) GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
@@ -203,12 +185,10 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes // registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) { func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
// TextEncoder constructor
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object { vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
encoder := call.This encoder := call.This
encoder.Set("encoding", "utf-8") encoder.Set("encoding", "utf-8")
// encode() method - string to Uint8Array
encoder.Set("encode", func(call goja.FunctionCall) goja.Value { encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return vm.ToValue([]byte{}) return vm.ToValue([]byte{})
@@ -216,7 +196,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
input := call.Arguments[0].String() input := call.Arguments[0].String()
bytes := []byte(input) bytes := []byte(input)
// Return as array (Uint8Array-like)
result := make([]interface{}, len(bytes)) result := make([]interface{}, len(bytes))
for i, b := range bytes { for i, b := range bytes {
result[i] = int(b) result[i] = int(b)
@@ -224,7 +203,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
return vm.ToValue(result) return vm.ToValue(result)
}) })
// encodeInto() method
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value { encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
// Simplified implementation // Simplified implementation
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
@@ -240,11 +218,9 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
return nil return nil
}) })
// TextDecoder constructor
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object { vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
decoder := call.This decoder := call.This
// Get encoding from arguments (default: utf-8)
encoding := "utf-8" encoding := "utf-8"
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
encoding = call.Arguments[0].String() encoding = call.Arguments[0].String()
@@ -253,13 +229,11 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
decoder.Set("fatal", false) decoder.Set("fatal", false)
decoder.Set("ignoreBOM", false) decoder.Set("ignoreBOM", false)
// decode() method - Uint8Array to string
decoder.Set("decode", func(call goja.FunctionCall) goja.Value { decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return vm.ToValue("") return vm.ToValue("")
} }
// Handle different input types
input := call.Arguments[0].Export() input := call.Arguments[0].Export()
var bytes []byte var bytes []byte
@@ -279,7 +253,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
} }
} }
case string: case string:
// Already a string, just return it
return vm.ToValue(v) return vm.ToValue(v)
default: default:
return vm.ToValue("") return vm.ToValue("")
@@ -292,7 +265,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
}) })
} }
// registerURLClass registers the URL class for URL parsing
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) { func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object { vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
urlObj := call.This urlObj := call.This
@@ -304,7 +276,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Handle relative URLs with base
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
baseStr := call.Arguments[1].String() baseStr := call.Arguments[1].String()
baseURL, err := url.Parse(baseStr) baseURL, err := url.Parse(baseStr)
@@ -322,7 +293,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
return nil return nil
} }
// Set URL properties
urlObj.Set("href", parsed.String()) urlObj.Set("href", parsed.String())
urlObj.Set("protocol", parsed.Scheme+":") urlObj.Set("protocol", parsed.Scheme+":")
urlObj.Set("host", parsed.Host) urlObj.Set("host", parsed.Host)
@@ -342,10 +312,9 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
password, _ := parsed.User.Password() password, _ := parsed.User.Password()
urlObj.Set("password", password) urlObj.Set("password", password)
// searchParams object
searchParams := vm.NewObject()
queryValues := parsed.Query() queryValues := parsed.Query()
searchParams := vm.NewObject()
searchParams.Set("get", func(call goja.FunctionCall) goja.Value { searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Null() return goja.Null()
@@ -379,12 +348,10 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
urlObj.Set("searchParams", searchParams) urlObj.Set("searchParams", searchParams)
// toString method
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value { urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(parsed.String()) return vm.ToValue(parsed.String())
}) })
// toJSON method
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value { urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(parsed.String()) return vm.ToValue(parsed.String())
}) })
@@ -392,17 +359,14 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
return nil return nil
}) })
// URLSearchParams constructor
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object { vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
paramsObj := call.This paramsObj := call.This
values := url.Values{} values := url.Values{}
// Parse initial value if provided
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
init := call.Arguments[0].Export() init := call.Arguments[0].Export()
switch v := init.(type) { switch v := init.(type) {
case string: case string:
// Parse query string
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?")) parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
values = parsed values = parsed
case map[string]interface{}: case map[string]interface{}:
@@ -468,10 +432,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
// registerJSONGlobal ensures JSON global is properly set up // registerJSONGlobal ensures JSON global is properly set up
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) { func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
// JSON is already built-in to Goja, but we can enhance it // JSON is already built-in to Goja, but we can enhance it
// This ensures JSON.parse and JSON.stringify work as expected
// The built-in JSON object should already work, but let's verify
// and add any missing functionality if needed
jsonScript := ` jsonScript := `
if (typeof JSON === 'undefined') { if (typeof JSON === 'undefined') {
var JSON = { var JSON = {
+2 -30
View File
@@ -17,12 +17,10 @@ import (
// ==================== Storage API ==================== // ==================== Storage API ====================
// getStoragePath returns the path to the extension's storage file
func (r *ExtensionRuntime) getStoragePath() string { func (r *ExtensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json") return filepath.Join(r.dataDir, "storage.json")
} }
// loadStorage loads the storage data from disk
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
storagePath := r.getStoragePath() storagePath := r.getStoragePath()
data, err := os.ReadFile(storagePath) data, err := os.ReadFile(storagePath)
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
return storage, nil return storage, nil
} }
// saveStorage saves the storage data to disk
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error { func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
storagePath := r.getStoragePath() storagePath := r.getStoragePath()
data, err := json.MarshalIndent(storage, "", " ") data, err := json.MarshalIndent(storage, "", " ")
@@ -49,10 +46,9 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
return err return err
} }
return os.WriteFile(storagePath, data, 0644) return os.WriteFile(storagePath, data, 0600)
} }
// storageGet retrieves a value from storage
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
@@ -68,7 +64,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
value, exists := storage[key] value, exists := storage[key]
if !exists { if !exists {
// Return default value if provided
if len(call.Arguments) > 1 { if len(call.Arguments) > 1 {
return call.Arguments[1] return call.Arguments[1]
} }
@@ -78,7 +73,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value) return r.vm.ToValue(value)
} }
// storageSet stores a value in storage
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -103,7 +97,6 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// storageRemove removes a value from storage
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -127,19 +120,14 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// ==================== Credentials API (Encrypted Storage) ====================
// getCredentialsPath returns the path to the extension's encrypted credentials file
func (r *ExtensionRuntime) getCredentialsPath() string { func (r *ExtensionRuntime) getCredentialsPath() string {
return filepath.Join(r.dataDir, ".credentials.enc") return filepath.Join(r.dataDir, ".credentials.enc")
} }
// getSaltPath returns the path to the extension's encryption salt file
func (r *ExtensionRuntime) getSaltPath() string { func (r *ExtensionRuntime) getSaltPath() string {
return filepath.Join(r.dataDir, ".cred_salt") return filepath.Join(r.dataDir, ".cred_salt")
} }
// getOrCreateSalt gets existing salt or creates a new random one
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) { func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath() saltPath := r.getSaltPath()
@@ -160,22 +148,17 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
return salt, nil return salt, nil
} }
// getEncryptionKey derives an encryption key from extension ID + random salt
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) { func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
// Get or create per-installation random salt
salt, err := r.getOrCreateSalt() salt, err := r.getOrCreateSalt()
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Combine extension ID + random salt for key derivation
// This makes each installation unique, preventing mass decryption attacks
combined := append([]byte(r.extensionID), salt...) combined := append([]byte(r.extensionID), salt...)
hash := sha256.Sum256(combined) hash := sha256.Sum256(combined)
return hash[:], nil return hash[:], nil
} }
// loadCredentials loads and decrypts credentials from disk
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
credPath := r.getCredentialsPath() credPath := r.getCredentialsPath()
data, err := os.ReadFile(credPath) data, err := os.ReadFile(credPath)
@@ -186,7 +169,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
return nil, err return nil, err
} }
// Decrypt the data
key, err := r.getEncryptionKey() key, err := r.getEncryptionKey()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get encryption key: %w", err) return nil, fmt.Errorf("failed to get encryption key: %w", err)
@@ -204,7 +186,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
return creds, nil return creds, nil
} }
// saveCredentials encrypts and saves credentials to disk
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
data, err := json.Marshal(creds) data, err := json.Marshal(creds)
if err != nil { if err != nil {
@@ -221,10 +202,9 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
} }
credPath := r.getCredentialsPath() credPath := r.getCredentialsPath()
return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions return os.WriteFile(credPath, encrypted, 0600)
} }
// credentialsStore stores an encrypted credential
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -260,7 +240,6 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
}) })
} }
// credentialsGet retrieves a decrypted credential
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
@@ -276,7 +255,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
value, exists := creds[key] value, exists := creds[key]
if !exists { if !exists {
// Return default value if provided
if len(call.Arguments) > 1 { if len(call.Arguments) > 1 {
return call.Arguments[1] return call.Arguments[1]
} }
@@ -286,7 +264,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value) return r.vm.ToValue(value)
} }
// credentialsRemove removes a credential
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -310,7 +287,6 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// credentialsHas checks if a credential exists
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -327,9 +303,6 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(exists) return r.vm.ToValue(exists)
} }
// ==================== Crypto Utilities ====================
// encryptAES encrypts data using AES-GCM
func encryptAES(plaintext []byte, key []byte) ([]byte, error) { func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
@@ -350,7 +323,6 @@ func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
return ciphertext, nil return ciphertext, nil
} }
// decryptAES decrypts data using AES-GCM
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) { func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
+2 -30
View File
@@ -19,7 +19,6 @@ import (
// ==================== Utility Functions ==================== // ==================== Utility Functions ====================
// base64Encode encodes a string to base64
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -28,7 +27,6 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
} }
// base64Decode decodes a base64 string
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded)) return r.vm.ToValue(string(decoded))
} }
// md5Hash computes MD5 hash of a string
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -51,7 +48,6 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:])) return r.vm.ToValue(hex.EncodeToString(hash[:]))
} }
// sha256Hash computes SHA256 hash of a string
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -61,7 +57,6 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:])) return r.vm.ToValue(hex.EncodeToString(hash[:]))
} }
// hmacSHA256 computes HMAC-SHA256 of a message with a key
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -74,7 +69,6 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil))) return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
} }
// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -87,9 +81,6 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil))) return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
} }
// hmacSHA1 computes HMAC-SHA1 of a message with a key (for TOTP)
// Arguments: message (string or array of bytes), key (string or array of bytes)
// Returns: array of bytes (for TOTP dynamic truncation)
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue([]byte{}) return r.vm.ToValue([]byte{})
@@ -142,7 +133,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(jsArray) return r.vm.ToValue(jsArray)
} }
// parseJSON parses a JSON string
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
@@ -158,7 +148,6 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result) return r.vm.ToValue(result)
} }
// stringifyJSON converts a value to JSON string
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -174,9 +163,6 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(data)) return r.vm.ToValue(string(data))
} }
// ==================== Crypto Utilities for Extensions ====================
// cryptoEncrypt encrypts a string using AES-GCM (for extension use)
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -188,7 +174,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
plaintext := call.Arguments[0].String() plaintext := call.Arguments[0].String()
keyStr := call.Arguments[1].String() keyStr := call.Arguments[1].String()
// Derive 32-byte key from provided key string
keyHash := sha256.Sum256([]byte(keyStr)) keyHash := sha256.Sum256([]byte(keyStr))
encrypted, err := encryptAES([]byte(plaintext), keyHash[:]) encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
@@ -205,7 +190,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
}) })
} }
// cryptoDecrypt decrypts a string using AES-GCM (for extension use)
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -225,14 +209,13 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
}) })
} }
// Derive 32-byte key from provided key string
keyHash := sha256.Sum256([]byte(keyStr)) keyHash := sha256.Sum256([]byte(keyStr))
decrypted, err := decryptAES(ciphertext, keyHash[:]) decrypted, err := decryptAES(ciphertext, keyHash[:])
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
"error": err.Error(), "error": "invalid base64 ciphertext",
}) })
} }
@@ -242,9 +225,8 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
}) })
} }
// cryptoGenerateKey generates a random encryption key
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
length := 32 // Default 256-bit key length := 32
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok { if l, ok := call.Arguments[0].Export().(float64); ok {
length = int(l) length = int(l)
@@ -266,13 +248,10 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
}) })
} }
// randomUserAgent returns a random Chrome User-Agent string
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent()) return r.vm.ToValue(getRandomUserAgent())
} }
// ==================== Logging Functions ====================
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments) msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg) GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
@@ -305,8 +284,6 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
return strings.Join(parts, " ") return strings.Join(parts, " ")
} }
// ==================== Go Backend Wrappers ====================
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -315,7 +292,6 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
return r.vm.ToValue(sanitizeFilename(input)) return r.vm.ToValue(sanitizeFilename(input))
} }
// RegisterGoBackendAPIs adds more Go backend functions to the VM
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
gobackendObj := vm.Get("gobackend") gobackendObj := vm.Get("gobackend")
if gobackendObj == nil || goja.IsUndefined(gobackendObj) { if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
@@ -325,7 +301,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
obj := gobackendObj.(*goja.Object) obj := gobackendObj.(*goja.Object)
// Expose sanitizeFilename
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value { obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return vm.ToValue("") return vm.ToValue("")
@@ -333,7 +308,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(sanitizeFilename(call.Arguments[0].String())) return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
}) })
// Expose getAudioQuality
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value { obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return vm.ToValue(map[string]interface{}{ return vm.ToValue(map[string]interface{}{
@@ -356,7 +330,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
}) })
}) })
// Expose buildFilename
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value { obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return vm.ToValue("") return vm.ToValue("")
@@ -373,7 +346,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(buildFilenameFromTemplate(template, metadata)) return vm.ToValue(buildFilenameFromTemplate(template, metadata))
}) })
// Expose getLocalTime - returns device local time info
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value { obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
now := time.Now() now := time.Now()
_, offsetSeconds := now.Zone() _, offsetSeconds := now.Zone()
-19
View File
@@ -9,20 +9,17 @@ import (
"sync" "sync"
) )
// ExtensionSettingsStore manages settings for all extensions
type ExtensionSettingsStore struct { type ExtensionSettingsStore struct {
mu sync.RWMutex mu sync.RWMutex
dataDir string dataDir string
settings map[string]map[string]interface{} // extensionID -> settings settings map[string]map[string]interface{} // extensionID -> settings
} }
// Global settings store
var ( var (
globalSettingsStore *ExtensionSettingsStore globalSettingsStore *ExtensionSettingsStore
globalSettingsStoreOnce sync.Once globalSettingsStoreOnce sync.Once
) )
// GetExtensionSettingsStore returns the global settings store
func GetExtensionSettingsStore() *ExtensionSettingsStore { func GetExtensionSettingsStore() *ExtensionSettingsStore {
globalSettingsStoreOnce.Do(func() { globalSettingsStoreOnce.Do(func() {
globalSettingsStore = &ExtensionSettingsStore{ globalSettingsStore = &ExtensionSettingsStore{
@@ -32,7 +29,6 @@ func GetExtensionSettingsStore() *ExtensionSettingsStore {
return globalSettingsStore return globalSettingsStore
} }
// SetDataDir sets the data directory for settings storage
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error { func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -45,12 +41,10 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
return s.loadAllSettings() return s.loadAllSettings()
} }
// getSettingsPath returns the path to an extension's settings file
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string { func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
return filepath.Join(s.dataDir, extensionID, "settings.json") return filepath.Join(s.dataDir, extensionID, "settings.json")
} }
// loadAllSettings loads settings for all extensions from disk
func (s *ExtensionSettingsStore) loadAllSettings() error { func (s *ExtensionSettingsStore) loadAllSettings() error {
entries, err := os.ReadDir(s.dataDir) entries, err := os.ReadDir(s.dataDir)
if err != nil { if err != nil {
@@ -75,7 +69,6 @@ func (s *ExtensionSettingsStore) loadAllSettings() error {
return nil return nil
} }
// loadSettings loads settings for a specific extension
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) { func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
settingsPath := s.getSettingsPath(extensionID) settingsPath := s.getSettingsPath(extensionID)
data, err := os.ReadFile(settingsPath) data, err := os.ReadFile(settingsPath)
@@ -94,7 +87,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
return settings, nil return settings, nil
} }
// saveSettings saves settings for a specific extension
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error { func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
settingsPath := s.getSettingsPath(extensionID) settingsPath := s.getSettingsPath(extensionID)
@@ -111,8 +103,6 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s
return os.WriteFile(settingsPath, data, 0644) return os.WriteFile(settingsPath, data, 0644)
} }
// Get retrieves a setting value for an extension
// Returns error if extension or key not found (gomobile compatible)
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) { func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -129,7 +119,6 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, erro
return value, nil return value, nil
} }
// GetAll retrieves all settings for an extension
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} { func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -139,7 +128,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
return make(map[string]interface{}) return make(map[string]interface{})
} }
// Return a copy
result := make(map[string]interface{}) result := make(map[string]interface{})
for k, v := range extSettings { for k, v := range extSettings {
result[k] = v result[k] = v
@@ -147,7 +135,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
return result return result
} }
// Set stores a setting value for an extension
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error { func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -161,18 +148,15 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
return s.saveSettings(extensionID, s.settings[extensionID]) return s.saveSettings(extensionID, s.settings[extensionID])
} }
// SetAll stores all settings for an extension
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error { func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.settings[extensionID] = settings s.settings[extensionID] = settings
// Persist to disk
return s.saveSettings(extensionID, settings) return s.saveSettings(extensionID, settings)
} }
// Remove removes a setting for an extension
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error { func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -184,11 +168,9 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
delete(extSettings, key) delete(extSettings, key)
// Persist to disk
return s.saveSettings(extensionID, extSettings) return s.saveSettings(extensionID, extSettings)
} }
// RemoveAll removes all settings for an extension
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error { func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -203,7 +185,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
return nil return nil
} }
// GetAllExtensionSettings returns settings for all extensions as JSON
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) { func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
+40 -39
View File
@@ -5,13 +5,13 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
) )
// Extension categories
const ( const (
CategoryMetadata = "metadata" CategoryMetadata = "metadata"
CategoryDownload = "download" CategoryDownload = "download"
@@ -20,28 +20,26 @@ const (
CategoryIntegration = "integration" CategoryIntegration = "integration"
) )
// StoreExtension represents an extension in the store
type StoreExtension struct { type StoreExtension struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"` Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"` IconURL string `json:"icon_url,omitempty"`
Category string `json:"category"` Category string `json:"category"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Downloads int `json:"downloads"` Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"` MinAppVersion string `json:"min_app_version,omitempty"`
DisplayNameAlt string `json:"displayName,omitempty"` DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"` DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"` IconURLAlt string `json:"iconUrl,omitempty"`
MinAppVersionAlt string `json:"minAppVersion,omitempty"` MinAppVersionAlt string `json:"minAppVersion,omitempty"`
} }
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
func (e *StoreExtension) getDisplayName() string { func (e *StoreExtension) getDisplayName() string {
if e.DisplayName != "" { if e.DisplayName != "" {
return e.DisplayName return e.DisplayName
@@ -52,7 +50,6 @@ func (e *StoreExtension) getDisplayName() string {
return e.Name return e.Name
} }
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getDownloadURL() string { func (e *StoreExtension) getDownloadURL() string {
if e.DownloadURL != "" { if e.DownloadURL != "" {
return e.DownloadURL return e.DownloadURL
@@ -60,7 +57,6 @@ func (e *StoreExtension) getDownloadURL() string {
return e.DownloadURLAlt return e.DownloadURLAlt
} }
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getIconURL() string { func (e *StoreExtension) getIconURL() string {
if e.IconURL != "" { if e.IconURL != "" {
return e.IconURL return e.IconURL
@@ -68,7 +64,6 @@ func (e *StoreExtension) getIconURL() string {
return e.IconURLAlt return e.IconURLAlt
} }
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getMinAppVersion() string { func (e *StoreExtension) getMinAppVersion() string {
if e.MinAppVersion != "" { if e.MinAppVersion != "" {
return e.MinAppVersion return e.MinAppVersion
@@ -76,7 +71,6 @@ func (e *StoreExtension) getMinAppVersion() string {
return e.MinAppVersionAlt return e.MinAppVersionAlt
} }
// StoreRegistry represents the extension registry
type StoreRegistry struct { type StoreRegistry struct {
Version int `json:"version"` Version int `json:"version"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
@@ -103,7 +97,6 @@ type StoreExtensionResponse struct {
HasUpdate bool `json:"has_update"` HasUpdate bool `json:"has_update"`
} }
// ToResponse converts StoreExtension to normalized response
func (e *StoreExtension) ToResponse() StoreExtensionResponse { func (e *StoreExtension) ToResponse() StoreExtensionResponse {
return StoreExtensionResponse{ return StoreExtensionResponse{
ID: e.ID, ID: e.ID,
@@ -122,7 +115,6 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
} }
} }
// ExtensionStore manages the extension store
type ExtensionStore struct { type ExtensionStore struct {
registryURL string registryURL string
cacheDir string cacheDir string
@@ -143,7 +135,6 @@ const (
cacheFileName = "store_cache.json" cacheFileName = "store_cache.json"
) )
// InitExtensionStore initializes the extension store
func InitExtensionStore(cacheDir string) *ExtensionStore { func InitExtensionStore(cacheDir string) *ExtensionStore {
extensionStoreMu.Lock() extensionStoreMu.Lock()
defer extensionStoreMu.Unlock() defer extensionStoreMu.Unlock()
@@ -154,20 +145,17 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
cacheDir: cacheDir, cacheDir: cacheDir,
cacheTTL: cacheTTL, cacheTTL: cacheTTL,
} }
// Try to load from disk cache
extensionStore.loadDiskCache() extensionStore.loadDiskCache()
} }
return extensionStore return extensionStore
} }
// GetExtensionStore returns the singleton store instance
func GetExtensionStore() *ExtensionStore { func GetExtensionStore() *ExtensionStore {
extensionStoreMu.Lock() extensionStoreMu.Lock()
defer extensionStoreMu.Unlock() defer extensionStoreMu.Unlock()
return extensionStore return extensionStore
} }
// loadDiskCache loads cached registry from disk
func (s *ExtensionStore) loadDiskCache() { func (s *ExtensionStore) loadDiskCache() {
if s.cacheDir == "" { if s.cacheDir == "" {
return return
@@ -193,7 +181,6 @@ func (s *ExtensionStore) loadDiskCache() {
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions)) LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
} }
// saveDiskCache saves registry to disk cache
func (s *ExtensionStore) saveDiskCache() { func (s *ExtensionStore) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil { if s.cacheDir == "" || s.cache == nil {
return return
@@ -216,23 +203,24 @@ func (s *ExtensionStore) saveDiskCache() {
os.WriteFile(cachePath, data, 0644) os.WriteFile(cachePath, data, 0644)
} }
// FetchRegistry fetches the extension registry from GitHub
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) { func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
// Return cached if valid and not forcing refresh
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL { if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions)) LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
return s.cache, nil return s.cache, nil
} }
if err := requireHTTPSURL(s.registryURL, "registry"); err != nil {
return nil, err
}
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL) LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
client := &http.Client{Timeout: 30 * time.Second} client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(s.registryURL) resp, err := client.Get(s.registryURL)
if err != nil { if err != nil {
// Return cached data if available on network error
if s.cache != nil { if s.cache != nil {
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err) LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
return s.cache, nil return s.cache, nil
@@ -267,7 +255,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return &registry, nil return &registry, nil
} }
// GetExtensionsWithStatus returns extensions with installation status
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) { func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(false) registry, err := s.FetchRegistry(false)
if err != nil { if err != nil {
@@ -299,7 +286,6 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
return result, nil return result, nil
} }
// DownloadExtension downloads an extension package to the specified path
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error { func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false) registry, err := s.FetchRegistry(false)
if err != nil { if err != nil {
@@ -318,6 +304,10 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return fmt.Errorf("extension %s not found in store", extensionID) return fmt.Errorf("extension %s not found in store", extensionID)
} }
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
return err
}
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL()) LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := &http.Client{Timeout: 5 * time.Minute} client := &http.Client{Timeout: 5 * time.Minute}
@@ -347,7 +337,20 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return nil return nil
} }
// GetCategories returns all available categories func requireHTTPSURL(rawURL string, context string) error {
if rawURL == "" {
return fmt.Errorf("%s URL is empty", context)
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
}
if parsed.Scheme != "https" {
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
}
return nil
}
func (s *ExtensionStore) GetCategories() []string { func (s *ExtensionStore) GetCategories() []string {
return []string{ return []string{
CategoryMetadata, CategoryMetadata,
@@ -358,7 +361,6 @@ func (s *ExtensionStore) GetCategories() []string {
} }
} }
// SearchExtensions searches extensions by query
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) { func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus() extensions, err := s.GetExtensionsWithStatus()
if err != nil { if err != nil {
@@ -404,7 +406,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
return result, nil return result, nil
} }
// ClearCache clears the in-memory and disk cache
func (s *ExtensionStore) ClearCache() { func (s *ExtensionStore) ClearCache() {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
+8 -23
View File
@@ -112,7 +112,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
runtime := NewExtensionRuntime(ext) runtime := NewExtensionRuntime(ext)
// Test allowed domains
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil { if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err) t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
} }
@@ -121,7 +120,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err) t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
} }
// Test blocked domains
if err := runtime.validateDomain("https://blocked.com/path"); err == nil { if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
t.Error("Expected blocked.com to be denied") t.Error("Expected blocked.com to be denied")
} }
@@ -139,7 +137,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext", Name: "test-ext",
Permissions: ExtensionPermissions{ Permissions: ExtensionPermissions{
File: true, // Enable file permission for test File: true,
}, },
}, },
DataDir: tempDir, DataDir: tempDir,
@@ -147,7 +145,6 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
runtime := NewExtensionRuntime(ext) runtime := NewExtensionRuntime(ext)
// Test valid path within sandbox
validPath, err := runtime.validatePath("test.txt") validPath, err := runtime.validatePath("test.txt")
if err != nil { if err != nil {
t.Errorf("Expected relative path to be valid, got error: %v", err) t.Errorf("Expected relative path to be valid, got error: %v", err)
@@ -156,13 +153,11 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
t.Error("Expected non-empty path") t.Error("Expected non-empty path")
} }
// Test path traversal attack
_, err = runtime.validatePath("../../../etc/passwd") _, err = runtime.validatePath("../../../etc/passwd")
if err == nil { if err == nil {
t.Error("Expected path traversal to be blocked") t.Error("Expected path traversal to be blocked")
} }
// Test nested path within sandbox (should be allowed)
nestedPath, err := runtime.validatePath("subdir/file.txt") nestedPath, err := runtime.validatePath("subdir/file.txt")
if err != nil { if err != nil {
t.Errorf("Expected nested path to be valid, got error: %v", err) t.Errorf("Expected nested path to be valid, got error: %v", err)
@@ -171,26 +166,23 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
t.Error("Expected non-empty nested path") t.Error("Expected non-empty nested path")
} }
// Test absolute path should be blocked (security fix)
// Use platform-appropriate absolute path
var absPath string var absPath string
if filepath.IsAbs("C:\\Windows\\System32") { if filepath.IsAbs("C:\\Windows\\System32") {
absPath = "C:\\Windows\\System32\\test.txt" // Windows absPath = "C:\\Windows\\System32\\test.txt"
} else { } else {
absPath = "/etc/passwd" // Unix absPath = "/etc/passwd"
} }
_, err = runtime.validatePath(absPath) _, err = runtime.validatePath(absPath)
if err == nil { if err == nil {
t.Error("Expected absolute path to be blocked") t.Error("Expected absolute path to be blocked")
} }
// Test that extension without file permission is blocked
extNoFile := &LoadedExtension{ extNoFile := &LoadedExtension{
ID: "test-ext-no-file", ID: "test-ext-no-file",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext-no-file", Name: "test-ext-no-file",
Permissions: ExtensionPermissions{ Permissions: ExtensionPermissions{
File: false, // No file permission File: false,
}, },
}, },
DataDir: tempDir, DataDir: tempDir,
@@ -215,7 +207,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
vm := goja.New() vm := goja.New()
runtime.RegisterAPIs(vm) runtime.RegisterAPIs(vm)
// Test base64 encode/decode
result, err := vm.RunString(`utils.base64Encode("hello")`) result, err := vm.RunString(`utils.base64Encode("hello")`)
if err != nil { if err != nil {
t.Fatalf("base64Encode failed: %v", err) t.Fatalf("base64Encode failed: %v", err)
@@ -232,7 +223,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
t.Errorf("Expected 'hello', got '%s'", result.String()) t.Errorf("Expected 'hello', got '%s'", result.String())
} }
// Test MD5
result, err = vm.RunString(`utils.md5("hello")`) result, err = vm.RunString(`utils.md5("hello")`)
if err != nil { if err != nil {
t.Fatalf("md5 failed: %v", err) t.Fatalf("md5 failed: %v", err)
@@ -241,7 +231,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String()) t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
} }
// Test JSON parse/stringify
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`) result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
if err != nil { if err != nil {
t.Fatalf("stringifyJSON failed: %v", err) t.Fatalf("stringifyJSON failed: %v", err)
@@ -267,7 +256,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
runtime := NewExtensionRuntime(ext) runtime := NewExtensionRuntime(ext)
// Test that private IPs are blocked (SSRF protection)
privateIPs := []string{ privateIPs := []string{
"http://localhost/admin", "http://localhost/admin",
"http://127.0.0.1/admin", "http://127.0.0.1/admin",
@@ -285,7 +273,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
} }
} }
// Test that allowed public domain still works
if err := runtime.validateDomain("https://api.example.com/path"); err != nil { if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
t.Errorf("Expected api.example.com to be allowed, got error: %v", err) t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
} }
@@ -296,7 +283,6 @@ func TestIsPrivateIP(t *testing.T) {
host string host string
expected bool expected bool
}{ }{
// Private IPs should be blocked
{"localhost", true}, {"localhost", true},
{"127.0.0.1", true}, {"127.0.0.1", true},
{"127.0.0.2", true}, {"127.0.0.2", true},
@@ -306,18 +292,17 @@ func TestIsPrivateIP(t *testing.T) {
{"172.31.255.255", true}, {"172.31.255.255", true},
{"192.168.0.1", true}, {"192.168.0.1", true},
{"192.168.255.255", true}, {"192.168.255.255", true},
{"169.254.169.254", true}, // AWS metadata {"169.254.169.254", true},
{"router.local", true}, {"router.local", true},
{"mydevice.local", true}, {"mydevice.local", true},
// Public IPs should be allowed
{"8.8.8.8", false}, {"8.8.8.8", false},
{"1.1.1.1", false}, {"1.1.1.1", false},
{"api.example.com", false}, {"api.example.com", false},
{"google.com", false}, {"google.com", false},
{"172.15.0.1", false}, // Just outside 172.16-31 range {"172.15.0.1", false},
{"172.32.0.1", false}, // Just outside 172.16-31 range {"172.32.0.1", false},
{"192.167.0.1", false}, // Not 192.168.x.x {"192.167.0.1", false},
} }
for _, tt := range tests { for _, tt := range tests {
+2 -13
View File
@@ -4,13 +4,13 @@ package gobackend
import ( import (
"context" "context"
"fmt" "fmt"
"runtime/debug"
"sync" "sync"
"time" "time"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
// JSExecutionError represents an error during JS execution
type JSExecutionError struct { type JSExecutionError struct {
Message string Message string
IsTimeout bool IsTimeout bool
@@ -20,8 +20,6 @@ func (e *JSExecutionError) Error() string {
return e.Message return e.Message
} }
// RunWithTimeout executes JavaScript code with a timeout
// Returns the result value and any error (including timeout)
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) { func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
if timeout <= 0 { if timeout <= 0 {
timeout = DefaultJSTimeout timeout = DefaultJSTimeout
@@ -30,22 +28,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
// Channel to receive result
type result struct { type result struct {
value goja.Value value goja.Value
err error err error
} }
resultCh := make(chan result, 1) resultCh := make(chan result, 1)
// Track if we've interrupted
var interrupted bool var interrupted bool
var interruptMu sync.Mutex var interruptMu sync.Mutex
// Run script in goroutine
go func() { go func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
// Check if this was our interrupt
interruptMu.Lock() interruptMu.Lock()
wasInterrupted := interrupted wasInterrupted := interrupted
interruptMu.Unlock() interruptMu.Unlock()
@@ -56,6 +50,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
IsTimeout: true, IsTimeout: true,
}} }}
} else { } 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)} resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
} }
} }
@@ -65,22 +60,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
resultCh <- result{val, err} resultCh <- result{val, err}
}() }()
// Wait for result or timeout
select { select {
case res := <-resultCh: case res := <-resultCh:
return res.value, res.err return res.value, res.err
case <-ctx.Done(): case <-ctx.Done():
// Timeout - interrupt the VM
interruptMu.Lock() interruptMu.Lock()
interrupted = true interrupted = true
interruptMu.Unlock() interruptMu.Unlock()
vm.Interrupt("execution timeout") vm.Interrupt("execution timeout")
// Wait a bit for the goroutine to finish
select { select {
case res := <-resultCh: case res := <-resultCh:
// If we got a result after interrupt, it might be the timeout error
if res.err != nil { if res.err != nil {
return nil, res.err return nil, res.err
} }
@@ -89,7 +80,6 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
IsTimeout: true, IsTimeout: true,
} }
case <-time.After(1 * time.Second): case <-time.After(1 * time.Second):
// Force return timeout error
return nil, &JSExecutionError{ return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)", Message: "execution timeout exceeded (force)",
IsTimeout: true, IsTimeout: true,
@@ -109,7 +99,6 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura
return result, err return result, err
} }
// IsTimeoutError checks if an error is a timeout error
func IsTimeoutError(err error) bool { func IsTimeoutError(err error) bool {
if jsErr, ok := err.(*JSExecutionError); ok { if jsErr, ok := err.(*JSExecutionError); ok {
return jsErr.IsTimeout return jsErr.IsTimeout
+234 -29
View File
@@ -3,28 +3,35 @@ package gobackend
import ( import (
"fmt" "fmt"
"regexp" "regexp"
"strconv"
"strings" "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 { func sanitizeFilename(filename string) string {
sanitized := invalidChars.ReplaceAllString(filename, "_") sanitized := invalidChars.ReplaceAllString(filename, "_")
sanitized = strings.TrimSpace(sanitized) sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".") sanitized = strings.Trim(sanitized, ".")
multiUnderscore := regexp.MustCompile(`_+`)
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_") sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
if len(sanitized) > 200 { if len(sanitized) > 200 {
sanitized = sanitized[:200] sanitized = sanitized[:200]
} }
if sanitized == "" { if sanitized == "" {
sanitized = "untitled" sanitized = "untitled"
} }
return sanitized return sanitized
} }
@@ -32,45 +39,120 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
if template == "" { if template == "" {
template = "{artist} - {title}" template = "{artist} - {title}"
} }
result := template result := replaceFormattedNumberPlaceholders(template, metadata)
result = replaceDateFormatPlaceholders(result, metadata)
placeholders := map[string]string{
"{title}": getString(metadata, "title"), dateValue := getDateValue(metadata)
"{artist}": getString(metadata, "artist"), yearValue := getString(metadata, "year")
"{album}": getString(metadata, "album"), if yearValue == "" {
"{track}": formatTrackNumber(getInt(metadata, "track")), yearValue = extractYear(dateValue)
"{year}": getString(metadata, "year"),
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
} }
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 { for placeholder, value := range placeholders {
result = strings.ReplaceAll(result, placeholder, value) result = strings.ReplaceAll(result, placeholder, value)
} }
return result 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 { func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok { if v, ok := m[key]; ok {
if s, ok := v.(string); ok { switch value := v.(type) {
return strings.TrimSpace(s) 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 "" return ""
} }
func getInt(m map[string]interface{}, key string) int { func getInt(m map[string]interface{}, key string) int {
if v, ok := m[key]; ok { candidateKeys := []string{key}
switch n := v.(type) { switch key {
case int: case "track":
return n candidateKeys = append(candidateKeys, "track_number")
case int64: case "disc":
return int(n) candidateKeys = append(candidateKeys, "disc_number")
case float64: }
return int(n)
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 return 0
} }
@@ -88,6 +170,129 @@ func formatDiscNumber(n int) string {
return fmt.Sprintf("%d", n) 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 { func extractYear(date string) string {
if len(date) >= 4 { if len(date) >= 4 {
return 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)
}
}
+11 -11
View File
@@ -2,16 +2,16 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0 go 1.25.0
toolchain go1.25.6 toolchain go1.26.0
require ( require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac v1.0.0 github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2 github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
golang.org/x/net v0.49.0 golang.org/x/net v0.50.0
) )
require ( require (
@@ -20,10 +20,10 @@ require (
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/compress v1.17.4 // indirect
golang.org/x/crypto v0.47.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect golang.org/x/tools v0.42.0 // indirect
) )
+30 -8
View File
@@ -2,16 +2,18 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 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-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs= github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI= github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -20,23 +22,43 @@ github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I= 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/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= 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.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 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 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 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= 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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+32 -31
View File
@@ -15,11 +15,7 @@ import (
"time" "time"
) )
// getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses modern Chrome format with build and patch numbers
// Windows 11 still reports as "Windows NT 10.0" for compatibility
func getRandomUserAgent() string { func getRandomUserAgent() string {
// Chrome version 120-145 (modern range)
chromeVersion := rand.Intn(26) + 120 chromeVersion := rand.Intn(26) + 120
chromeBuild := rand.Intn(1500) + 6000 chromeBuild := rand.Intn(1500) + 6000
chromePatch := rand.Intn(200) + 100 chromePatch := rand.Intn(200) + 100
@@ -38,10 +34,9 @@ const (
SongLinkTimeout = 30 * time.Second SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3 DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second DefaultRetryDelay = 1 * time.Second
Second = time.Second // Exported for use in other files Second = time.Second
) )
// Shared transport with connection pooling to prevent TCP exhaustion
var sharedTransport = &http.Transport{ var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
@@ -60,6 +55,27 @@ var sharedTransport = &http.Transport{
DisableCompression: true, DisableCompression: true,
} }
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
// Isolated from download traffic so that download failures cannot poison
// the connection pool used by metadata enrichment.
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 30,
MaxIdleConnsPerHost: 5,
MaxConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 32 * 1024,
ReadBufferSize: 32 * 1024,
DisableCompression: true,
}
var sharedClient = &http.Client{ var sharedClient = &http.Client{
Transport: sharedTransport, Transport: sharedTransport,
Timeout: DefaultTimeout, Timeout: DefaultTimeout,
@@ -77,6 +93,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
} }
} }
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
// Use this for API calls that should not be affected by download traffic.
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: metadataTransport,
Timeout: timeout,
}
}
func GetSharedClient() *http.Client { func GetSharedClient() *http.Client {
return sharedClient return sharedClient
} }
@@ -85,9 +110,9 @@ func GetDownloadClient() *http.Client {
return downloadClient return downloadClient
} }
// CloseIdleConnections closes idle connections in the shared transport
func CloseIdleConnections() { func CloseIdleConnections() {
sharedTransport.CloseIdleConnections() sharedTransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
} }
// Also checks for ISP blocking on errors // Also checks for ISP blocking on errors
@@ -117,16 +142,12 @@ func DefaultRetryConfig() RetryConfig {
} }
} }
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
// Handles 429 (Too Many Requests) responses with Retry-After header
// Also detects and logs ISP blocking
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) { func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
var lastErr error var lastErr error
delay := config.InitialDelay delay := config.InitialDelay
requestURL := req.URL.String() requestURL := req.URL.String()
for attempt := 0; attempt <= config.MaxRetries; attempt++ { for attempt := 0; attempt <= config.MaxRetries; attempt++ {
// Clone request for retry (body needs to be re-readable)
reqCopy := req.Clone(req.Context()) reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent()) reqCopy.Header.Set("User-Agent", getRandomUserAgent())
@@ -134,9 +155,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
if err != nil { if err != nil {
lastErr = err lastErr = err
// Check for ISP blocking on network errors
if CheckAndLogISPBlocking(err, requestURL, "HTTP") { if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
// Don't retry if ISP blocking is detected - it won't help
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP") return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
} }
@@ -149,12 +168,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue continue
} }
// Success
if resp.StatusCode >= 200 && resp.StatusCode < 300 { if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp, nil return resp, nil
} }
// Handle rate limiting (429)
if resp.StatusCode == 429 { if resp.StatusCode == 429 {
resp.Body.Close() resp.Body.Close()
retryAfter := getRetryAfterDuration(resp) retryAfter := getRetryAfterDuration(resp)
@@ -194,7 +211,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
} }
} }
// Server errors (5xx) - retry
if resp.StatusCode >= 500 { if resp.StatusCode >= 500 {
resp.Body.Close() resp.Body.Close()
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode) lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
@@ -206,7 +222,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue continue
} }
// Client errors (4xx except 429) - don't retry
return resp, nil return resp, nil
} }
@@ -225,12 +240,10 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
return 60 * time.Second // Default wait time return 60 * time.Second // Default wait time
} }
// Try parsing as seconds
if seconds, err := strconv.Atoi(retryAfter); err == nil { if seconds, err := strconv.Atoi(retryAfter); err == nil {
return time.Duration(seconds) * time.Second return time.Duration(seconds) * time.Second
} }
// Try parsing as HTTP date
if t, err := http.ParseTime(retryAfter); err == nil { if t, err := http.ParseTime(retryAfter); err == nil {
duration := time.Until(t) duration := time.Until(t)
if duration > 0 { if duration > 0 {
@@ -241,8 +254,6 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
return 60 * time.Second // Default return 60 * time.Second // Default
} }
// ReadResponseBody reads and returns the response body
// Returns error if body is empty
func ReadResponseBody(resp *http.Response) ([]byte, error) { func ReadResponseBody(resp *http.Response) ([]byte, error) {
if resp == nil { if resp == nil {
return nil, fmt.Errorf("response is nil") return nil, fmt.Errorf("response is nil")
@@ -272,14 +283,12 @@ func ValidateResponse(resp *http.Response) error {
return nil return nil
} }
// BuildErrorMessage creates a detailed error message for API failures
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string { func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
msg := fmt.Sprintf("API %s failed", apiURL) msg := fmt.Sprintf("API %s failed", apiURL)
if statusCode > 0 { if statusCode > 0 {
msg += fmt.Sprintf(" (HTTP %d)", statusCode) msg += fmt.Sprintf(" (HTTP %d)", statusCode)
} }
if responsePreview != "" { if responsePreview != "" {
// Truncate preview if too long
if len(responsePreview) > 100 { if len(responsePreview) > 100 {
responsePreview = responsePreview[:100] + "..." responsePreview = responsePreview[:100] + "..."
} }
@@ -298,18 +307,14 @@ func (e *ISPBlockingError) Error() string {
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason) return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
} }
// IsISPBlocking checks if an error is likely caused by ISP blocking
// Returns the ISPBlockingError if detected, nil otherwise
func IsISPBlocking(err error, requestURL string) *ISPBlockingError { func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil { if err == nil {
return nil return nil
} }
// Extract domain from URL
domain := extractDomain(requestURL) domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error()) errStr := strings.ToLower(err.Error())
// Check for DNS resolution failure (common ISP blocking method)
var dnsErr *net.DNSError var dnsErr *net.DNSError
if errors.As(err, &dnsErr) { if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTemporary { if dnsErr.IsNotFound || dnsErr.IsTemporary {
@@ -321,11 +326,9 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
} }
} }
// Check for connection refused (ISP firewall blocking)
var opErr *net.OpError var opErr *net.OpError
if errors.As(err, &opErr) { if errors.As(err, &opErr) {
if opErr.Op == "dial" { if opErr.Op == "dial" {
// Check for specific syscall errors
var syscallErr syscall.Errno var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) { if errors.As(opErr.Err, &syscallErr) {
switch syscallErr { switch syscallErr {
@@ -364,7 +367,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
} }
} }
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
var tlsErr *tls.RecordHeaderError var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) { if errors.As(err, &tlsErr) {
return &ISPBlockingError{ return &ISPBlockingError{
@@ -425,7 +427,6 @@ func extractDomain(rawURL string) string {
parsed, err := url.Parse(rawURL) parsed, err := url.Parse(rawURL)
if err != nil { if err != nil {
// Try to extract domain manually
rawURL = strings.TrimPrefix(rawURL, "https://") rawURL = strings.TrimPrefix(rawURL, "https://")
rawURL = strings.TrimPrefix(rawURL, "http://") rawURL = strings.TrimPrefix(rawURL, "http://")
if idx := strings.Index(rawURL, "/"); idx > 0 { if idx := strings.Index(rawURL, "/"); idx > 0 {
+1 -8
View File
@@ -35,7 +35,6 @@ func newUTLSTransport() *utlsTransport {
} }
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) { func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// For non-HTTPS, use standard transport
if req.URL.Scheme != "https" { if req.URL.Scheme != "https" {
return sharedTransport.RoundTrip(req) return sharedTransport.RoundTrip(req)
} }
@@ -44,29 +43,24 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
port := t.getPort(req.URL) port := t.getPort(req.URL)
addr := net.JoinHostPort(host, port) addr := net.JoinHostPort(host, port)
// Dial TCP connection
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr) conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Create uTLS connection with Chrome fingerprint (supports HTTP/2 ALPN)
tlsConn := utls.UClient(conn, &utls.Config{ tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host, ServerName: host,
NextProtos: []string{"h2", "http/1.1"}, // Prefer HTTP/2 NextProtos: []string{"h2", "http/1.1"},
}, utls.HelloChrome_Auto) }, utls.HelloChrome_Auto)
// Perform TLS handshake
if err := tlsConn.Handshake(); err != nil { if err := tlsConn.Handshake(); err != nil {
conn.Close() conn.Close()
return nil, err return nil, err
} }
// Check if server supports HTTP/2
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
if negotiatedProto == "h2" { if negotiatedProto == "h2" {
// Use HTTP/2 transport
h2Transport := &http2.Transport{ h2Transport := &http2.Transport{
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) { DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
return tlsConn, nil return tlsConn, nil
@@ -77,7 +71,6 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return h2Transport.RoundTrip(req) return h2Transport.RoundTrip(req)
} }
// Fallback to HTTP/1.1
transport := &http.Transport{ transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return tlsConn, nil return tlsConn, nil
+616
View File
@@ -0,0 +1,616 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// LibraryScanResult represents metadata from a scanned audio file
type LibraryScanResult struct {
ID string `json:"id"`
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
AlbumArtist string `json:"albumArtist,omitempty"`
FilePath string `json:"filePath"`
CoverPath string `json:"coverPath,omitempty"`
ScannedAt string `json:"scannedAt"`
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"trackNumber,omitempty"`
DiscNumber int `json:"discNumber,omitempty"`
Duration int `json:"duration,omitempty"`
ReleaseDate string `json:"releaseDate,omitempty"`
BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"`
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
Genre string `json:"genre,omitempty"`
Format string `json:"format,omitempty"`
}
type LibraryScanProgress struct {
TotalFiles int `json:"total_files"`
ScannedFiles int `json:"scanned_files"`
CurrentFile string `json:"current_file"`
ErrorCount int `json:"error_count"`
ProgressPct float64 `json:"progress_pct"`
IsComplete bool `json:"is_complete"`
}
// IncrementalScanResult contains results of an incremental library scan
type IncrementalScanResult struct {
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
SkippedCount int `json:"skippedCount"` // Files that were unchanged
TotalFiles int `json:"totalFiles"` // Total files in folder
}
var (
libraryScanProgress LibraryScanProgress
libraryScanProgressMu sync.RWMutex
libraryScanCancel chan struct{}
libraryScanCancelMu sync.Mutex
libraryCoverCacheDir string
libraryCoverCacheMu sync.RWMutex
)
var supportedAudioFormats = map[string]bool{
".flac": true,
".m4a": true,
".mp3": true,
".opus": true,
".ogg": true,
}
func SetLibraryCoverCacheDir(cacheDir string) {
libraryCoverCacheMu.Lock()
libraryCoverCacheDir = cacheDir
libraryCoverCacheMu.Unlock()
}
func ScanLibraryFolder(folderPath string) (string, error) {
if folderPath == "" {
return "[]", fmt.Errorf("folder path is empty")
}
info, err := os.Stat(folderPath)
if err != nil {
return "[]", fmt.Errorf("folder not found: %w", err)
}
if !info.IsDir() {
return "[]", fmt.Errorf("path is not a folder: %s", folderPath)
}
libraryScanProgressMu.Lock()
libraryScanProgress = LibraryScanProgress{}
libraryScanProgressMu.Unlock()
libraryScanCancelMu.Lock()
if libraryScanCancel != nil {
close(libraryScanCancel)
}
libraryScanCancel = make(chan struct{})
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
var audioFiles []string
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
select {
case <-cancelCh:
return fmt.Errorf("scan cancelled")
default:
}
if !info.IsDir() {
ext := strings.ToLower(filepath.Ext(path))
if supportedAudioFormats[ext] {
audioFiles = append(audioFiles, path)
}
}
return nil
})
if err != nil {
return "[]", err
}
totalFiles := len(audioFiles)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
if totalFiles == 0 {
libraryScanProgressMu.Lock()
libraryScanProgress.IsComplete = true
libraryScanProgressMu.Unlock()
return "[]", nil
}
GoLog("[LibraryScan] Found %d audio files to scan\n", totalFiles)
results := make([]LibraryScanResult, 0, totalFiles)
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
for i, filePath := range audioFiles {
select {
case <-cancelCh:
return "[]", fmt.Errorf("scan cancelled")
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = i + 1
libraryScanProgress.CurrentFile = filepath.Base(filePath)
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
result, err := scanAudioFile(filePath, scanTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
continue
}
results = append(results, *result)
}
libraryScanProgressMu.Lock()
libraryScanProgress.ErrorCount = errorCount
libraryScanProgress.IsComplete = true
libraryScanProgressMu.Unlock()
GoLog("[LibraryScan] Scan complete: %d tracks found, %d errors\n", len(results), errorCount)
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal results: %w", err)
}
return string(jsonBytes), nil
}
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
ext := strings.ToLower(filepath.Ext(filePath))
result := &LibraryScanResult{
ID: generateLibraryID(filePath),
FilePath: filePath,
ScannedAt: scanTime,
Format: strings.TrimPrefix(ext, "."),
}
// Get file modification time
if info, err := os.Stat(filePath); err == nil {
result.FileModTime = info.ModTime().UnixMilli()
}
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" && ext != ".m4a" {
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
}
}
switch ext {
case ".flac":
return scanFLACFile(filePath, result)
case ".m4a":
return scanM4AFile(filePath, result)
case ".mp3":
return scanMP3File(filePath, result)
case ".opus", ".ogg":
return scanOggFile(filePath, result)
default:
return scanFromFilename(filePath, result)
}
}
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
return scanFromFilename(filePath, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.ReleaseDate = metadata.Date
result.Genre = metadata.Genre
quality, err := GetAudioQuality(filePath)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
quality, err := GetM4AQuality(filePath)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
return scanFromFilename(filePath, result)
}
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.Genre = metadata.Genre
if metadata.Date != "" {
result.ReleaseDate = metadata.Date
} else {
result.ReleaseDate = metadata.Year
}
result.ISRC = metadata.ISRC
quality, err := GetMP3Quality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadOggVorbisComments(filePath)
if err != nil {
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.Genre = metadata.Genre
result.ReleaseDate = metadata.Date
quality, err := GetOggQuality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
parts := strings.SplitN(filename, " - ", 2)
if len(parts) == 2 {
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
result.TrackName = parts[1]
result.ArtistName = "Unknown Artist"
} else {
result.ArtistName = parts[0]
result.TrackName = parts[1]
}
} else {
if len(filename) > 3 && isNumeric(filename[:2]) {
title := strings.TrimLeft(filename[2:], " .-")
result.TrackName = title
} else {
result.TrackName = filename
}
result.ArtistName = "Unknown Artist"
}
dir := filepath.Dir(filePath)
result.AlbumName = filepath.Base(dir)
if result.AlbumName == "." || result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
func generateLibraryID(filePath string) string {
return fmt.Sprintf("lib_%x", hashString(filePath))
}
func hashString(s string) uint32 {
var hash uint32 = 5381
for _, c := range s {
hash = ((hash << 5) + hash) + uint32(c)
}
return hash
}
func GetLibraryScanProgress() string {
libraryScanProgressMu.RLock()
defer libraryScanProgressMu.RUnlock()
jsonBytes, _ := json.Marshal(libraryScanProgress)
return string(jsonBytes)
}
func CancelLibraryScan() {
libraryScanCancelMu.Lock()
defer libraryScanCancelMu.Unlock()
if libraryScanCancel != nil {
close(libraryScanCancel)
libraryScanCancel = nil
}
}
func ReadAudioMetadata(filePath string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
result, err := scanAudioFile(filePath, scanTime)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
return string(jsonBytes), nil
}
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
if folderPath == "" {
return "{}", fmt.Errorf("folder path is empty")
}
info, err := os.Stat(folderPath)
if err != nil {
return "{}", fmt.Errorf("folder not found: %w", err)
}
if !info.IsDir() {
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
}
// Parse existing files map
existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" {
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
}
}
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
// Reset progress
libraryScanProgressMu.Lock()
libraryScanProgress = LibraryScanProgress{}
libraryScanProgressMu.Unlock()
// Setup cancellation
libraryScanCancelMu.Lock()
if libraryScanCancel != nil {
close(libraryScanCancel)
}
libraryScanCancel = make(chan struct{})
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
// Collect all audio files with their mod times
type fileInfo struct {
path string
modTime int64
}
var currentFiles []fileInfo
currentPathSet := make(map[string]bool)
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
select {
case <-cancelCh:
return fmt.Errorf("scan cancelled")
default:
}
if !info.IsDir() {
ext := strings.ToLower(filepath.Ext(path))
if supportedAudioFormats[ext] {
currentFiles = append(currentFiles, fileInfo{
path: path,
modTime: info.ModTime().UnixMilli(),
})
currentPathSet[path] = true
}
}
return nil
})
if err != nil {
return "{}", err
}
totalFiles := len(currentFiles)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
// Find files to scan (new or modified)
var filesToScan []fileInfo
skippedCount := 0
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
// New file
filesToScan = append(filesToScan, f)
} else if f.modTime != existingModTime {
// Modified file
filesToScan = append(filesToScan, f)
} else {
// Unchanged file - skip
skippedCount++
}
}
// Find deleted files
var deletedPaths []string
for existingPath := range existingFiles {
if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
}
}
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
len(filesToScan), skippedCount, len(deletedPaths))
if len(filesToScan) == 0 {
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.IsComplete = true
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
result := IncrementalScanResult{
Scanned: []LibraryScanResult{},
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
// Scan the files that need scanning
results := make([]LibraryScanResult, 0, len(filesToScan))
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
for i, f := range filesToScan {
select {
case <-cancelCh:
return "{}", fmt.Errorf("scan cancelled")
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = skippedCount + i + 1
libraryScanProgress.CurrentFile = filepath.Base(f.path)
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
result, err := scanAudioFile(f.path, scanTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
continue
}
results = append(results, *result)
}
libraryScanProgressMu.Lock()
libraryScanProgress.ErrorCount = errorCount
libraryScanProgress.IsComplete = true
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
len(results), skippedCount, len(deletedPaths), errorCount)
scanResult := IncrementalScanResult{
Scanned: results,
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, err := json.Marshal(scanResult)
if err != nil {
return "{}", fmt.Errorf("failed to marshal results: %w", err)
}
return string(jsonBytes), nil
}
+33 -16
View File
@@ -3,6 +3,7 @@ package gobackend
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -22,30 +23,55 @@ type LogBuffer struct {
loggingEnabled bool loggingEnabled bool
} }
const (
defaultLogBufferSize = 500
maxLogMessageLength = 500
)
var ( var (
globalLogBuffer *LogBuffer globalLogBuffer *LogBuffer
logBufferOnce sync.Once 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._~+/\-]+=*`)
) )
// GetLogBuffer returns the singleton log buffer instance 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 { func GetLogBuffer() *LogBuffer {
logBufferOnce.Do(func() { logBufferOnce.Do(func() {
globalLogBuffer = &LogBuffer{ globalLogBuffer = &LogBuffer{
entries: make([]LogEntry, 0, 1000), entries: make([]LogEntry, 0, defaultLogBufferSize),
maxSize: 1000, maxSize: defaultLogBufferSize,
loggingEnabled: false, // Default: disabled for performance (user can enable in settings) loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
} }
}) })
return globalLogBuffer return globalLogBuffer
} }
func truncateLogMessage(message string) string {
runes := []rune(message)
if len(runes) <= maxLogMessageLength {
return message
}
return string(runes[:maxLogMessageLength]) + "...[truncated]"
}
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
lb.loggingEnabled = enabled lb.loggingEnabled = enabled
} }
// IsLoggingEnabled returns whether logging is enabled
func (lb *LogBuffer) IsLoggingEnabled() bool { func (lb *LogBuffer) IsLoggingEnabled() bool {
lb.mu.RLock() lb.mu.RLock()
defer lb.mu.RUnlock() defer lb.mu.RUnlock()
@@ -60,6 +86,9 @@ func (lb *LogBuffer) Add(level, tag, message string) {
return return
} }
message = sanitizeSensitiveLogText(message)
message = truncateLogMessage(message)
entry := LogEntry{ entry := LogEntry{
Timestamp: time.Now().Format("15:04:05.000"), Timestamp: time.Now().Format("15:04:05.000"),
Level: level, Level: level,
@@ -75,7 +104,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
fmt.Printf("[%s] %s\n", tag, message) fmt.Printf("[%s] %s\n", tag, message)
} }
// GetAll returns all log entries as JSON
func (lb *LogBuffer) GetAll() string { func (lb *LogBuffer) GetAll() string {
lb.mu.RLock() lb.mu.RLock()
defer lb.mu.RUnlock() defer lb.mu.RUnlock()
@@ -99,21 +127,18 @@ func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
return entries, len(lb.entries) return entries, len(lb.entries)
} }
// Clear clears all log entries
func (lb *LogBuffer) Clear() { func (lb *LogBuffer) Clear() {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
lb.entries = lb.entries[:0] lb.entries = lb.entries[:0]
} }
// Count returns the number of log entries
func (lb *LogBuffer) Count() int { func (lb *LogBuffer) Count() int {
lb.mu.RLock() lb.mu.RLock()
defer lb.mu.RUnlock() defer lb.mu.RUnlock()
return len(lb.entries) return len(lb.entries)
} }
// Helper functions for logging with different levels
func LogDebug(tag, format string, args ...interface{}) { func LogDebug(tag, format string, args ...interface{}) {
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...)) GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
} }
@@ -163,15 +188,10 @@ func GoLog(format string, args ...interface{}) {
GetLogBuffer().Add(level, tag, message) GetLogBuffer().Add(level, tag, message)
} }
// Exported functions for Flutter
// GetLogs returns all logs as JSON array
func GetLogs() string { func GetLogs() string {
return GetLogBuffer().GetAll() return GetLogBuffer().GetAll()
} }
// GetLogsSince returns logs since the given index
// Returns JSON: {"logs": [...], "next_index": N}
func GetLogsSince(index int) string { func GetLogsSince(index int) string {
entries, nextIndex := GetLogBuffer().getSince(index) entries, nextIndex := GetLogBuffer().getSince(index)
logsJson, _ := json.Marshal(entries) logsJson, _ := json.Marshal(entries)
@@ -179,17 +199,14 @@ func GetLogsSince(index int) string {
return result return result
} }
// ClearLogs clears all logs
func ClearLogs() { func ClearLogs() {
GetLogBuffer().Clear() GetLogBuffer().Clear()
} }
// GetLogCount returns the number of log entries
func GetLogCount() int { func GetLogCount() int {
return GetLogBuffer().Count() return GetLogBuffer().Count()
} }
// SetLoggingEnabled enables or disables logging from Flutter
func SetLoggingEnabled(enabled bool) { func SetLoggingEnabled(enabled bool) {
GetLogBuffer().SetLoggingEnabled(enabled) GetLogBuffer().SetLoggingEnabled(enabled)
} }
+397 -87
View File
@@ -20,6 +20,140 @@ const (
durationToleranceSec = 10.0 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 { type lyricsCacheEntry struct {
response *LyricsResponse response *LyricsResponse
expiresAt time.Time expiresAt time.Time
@@ -90,6 +224,15 @@ func (c *lyricsCache) Size() int {
return len(c.cache) 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 { type LRCLibResponse struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -139,7 +282,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) 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) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -174,7 +317,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) 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) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -238,79 +381,205 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
return diff <= durationToleranceSec return diff <= durationToleranceSec
} }
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
// Normalize artist name - take first artist before comma/semicolon for better matching
primaryArtist := normalizeArtistName(artistName) primaryArtist := normalizeArtistName(artistName)
fetchOptions := GetLyricsFetchOptions()
// Check cache first (use original artist name for cache key) extManager := GetExtensionManager()
var extensionProviders []*ExtensionProviderWrapper
if extManager != nil {
extensionProviders = extManager.GetLyricsProviders()
}
var cachedNonExtension *LyricsResponse
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found { if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName) isExtensionCache := strings.HasPrefix(cached.Source, "Extension:")
cachedCopy := *cached if len(extensionProviders) == 0 || isExtensionCache {
cachedCopy.Source = cached.Source + " (cached)" 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 return &cachedCopy, nil
} }
var lyrics *LyricsResponse // Get configured provider order
var err error providerOrder := GetLyricsProviderOrder()
// Helper to check if lyrics result is valid (has lines OR is instrumental)
isValidResult := func(l *LyricsResponse) bool {
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
}
// Try exact match first with primary artist
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Try with full artist name if different from primary
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
}
}
// Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName) 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) { if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB (simplified)" GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
} }
}
// Search with duration matching (use primary artist for search) if err != nil {
query := primaryArtist + " " + trackName GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Search with simplified name and duration matching
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
} }
} }
return nil, fmt.Errorf("lyrics not found from any source") 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 { func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse {
result := &LyricsResponse{ result := &LyricsResponse{
Instrumental: resp.Instrumental, Instrumental: resp.Instrumental,
@@ -348,10 +617,20 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
continue 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) matches := lrcPattern.FindStringSubmatch(line)
if len(matches) == 5 { if len(matches) == 5 {
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3]) startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
words := strings.TrimSpace(matches[4]) words := strings.TrimSpace(matches[4])
if words == "" {
continue
}
lines = append(lines, LyricsLine{ lines = append(lines, LyricsLine{
StartTimeMs: startMs, StartTimeMs: startMs,
@@ -372,6 +651,63 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
return lines 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 { func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
min, _ := strconv.ParseInt(minutes, 10, 64) min, _ := strconv.ParseInt(minutes, 10, 64)
sec, _ := strconv.ParseInt(seconds, 10, 64) sec, _ := strconv.ParseInt(seconds, 10, 64)
@@ -385,40 +721,18 @@ func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
} }
func msToLRCTimestamp(ms int64) string { func msToLRCTimestamp(ms int64) string {
return fmt.Sprintf("[%s]", msToLRCTimestampInline(ms))
}
func msToLRCTimestampInline(ms int64) string {
totalSeconds := ms / 1000 totalSeconds := ms / 1000
minutes := totalSeconds / 60 minutes := totalSeconds / 60
seconds := totalSeconds % 60 seconds := totalSeconds % 60
centiseconds := (ms % 1000) / 10 centiseconds := (ms % 1000) / 10
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
} }
// Use convertToLRCWithMetadata for full LRC with headers
// Kept for potential future use
// func convertToLRC(lyrics *LyricsResponse) string {
// if lyrics == nil || len(lyrics.Lines) == 0 {
// return ""
// }
//
// var builder strings.Builder
//
// if lyrics.SyncType == "LINE_SYNCED" {
// for _, line := range lyrics.Lines {
// timestamp := msToLRCTimestamp(line.StartTimeMs)
// builder.WriteString(timestamp)
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// } else {
// for _, line := range lyrics.Lines {
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// }
//
// return builder.String()
// }
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string { func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 { if lyrics == nil || len(lyrics.Lines) == 0 {
return "" return ""
@@ -480,11 +794,7 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result) return strings.TrimSpace(result)
} }
// normalizeArtistName extracts the primary artist from multi-artist strings
// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX"
// e.g., "Artist1; Artist2" -> "Artist1"
func normalizeArtistName(name string) string { func normalizeArtistName(name string) string {
// Split by common separators: ", " or "; " or " & " or " feat. " or " ft. "
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "} separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
result := name result := name
+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")
}
+220 -436
View File
@@ -4,16 +4,97 @@ import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
stdimage "image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io" "io"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"github.com/go-flac/flacpicture" "github.com/go-flac/flacpicture/v2"
"github.com/go-flac/flacvorbis" "github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac" "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 { type Metadata struct {
Title string Title string
Artist string Artist string
@@ -29,6 +110,8 @@ type Metadata struct {
Genre string Genre string
Label string Label string
Copyright string Copyright string
Composer string
Comment string
} }
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
@@ -98,6 +181,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "COPYRIGHT", metadata.Copyright) setComment(cmt, "COPYRIGHT", metadata.Copyright)
} }
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -117,19 +208,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
} }
} }
picture, err := flacpicture.NewFromImageData( picBlock, err := buildPictureBlock(coverPath, coverData)
flacpicture.PictureTypeFrontCover,
"Front Cover",
coverData,
"image/jpeg",
)
if err != nil { if err != nil {
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err) return fmt.Errorf("failed to create picture block: %w", err)
} else {
picBlock := picture.Marshal()
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
} }
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
} }
} else { } else {
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath) fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
@@ -206,6 +290,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "COPYRIGHT", metadata.Copyright) setComment(cmt, "COPYRIGHT", metadata.Copyright)
} }
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -220,25 +312,17 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
} }
} }
picture, err := flacpicture.NewFromImageData( picBlock, err := buildPictureBlock("", coverData)
flacpicture.PictureTypeFrontCover,
"Front Cover",
coverData,
"image/jpeg",
)
if err != nil { if err != nil {
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err) return fmt.Errorf("failed to create picture block: %w", err)
} else {
picBlock := picture.Marshal()
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
} }
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
} }
return f.Save(filePath) return f.Save(filePath)
} }
// ReadMetadata reads metadata from a FLAC file
func ReadMetadata(filePath string) (*Metadata, error) { func ReadMetadata(filePath string) (*Metadata, error) {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -293,6 +377,12 @@ func ReadMetadata(filePath string) (*Metadata, error) {
metadata.Date = getComment(cmt, "YEAR") metadata.Date = getComment(cmt, "YEAR")
} }
metadata.Genre = getComment(cmt, "GENRE")
metadata.Label = getComment(cmt, "ORGANIZATION")
metadata.Copyright = getComment(cmt, "COPYRIGHT")
metadata.Composer = getComment(cmt, "COMPOSER")
metadata.Comment = getComment(cmt, "COMMENT")
break break
} }
} }
@@ -336,6 +426,39 @@ func fileExists(path string) bool {
return err == nil return err == nil
} }
func ExtractCoverArt(filePath string) ([]byte, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, meta := range f.Meta {
if meta.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
if pic.PictureType == flacpicture.PictureTypeFrontCover && len(pic.ImageData) > 0 {
return pic.ImageData, nil
}
}
}
for _, meta := range f.Meta {
if meta.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
if len(pic.ImageData) > 0 {
return pic.ImageData, nil
}
}
}
return nil, fmt.Errorf("no cover art found in file")
}
func EmbedLyrics(filePath string, lyrics string) error { func EmbedLyrics(filePath string, lyrics string) error {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -418,35 +541,92 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
return f.Save(filePath) return f.Save(filePath)
} }
// ExtractLyrics extracts embedded lyrics from a FLAC file
func ExtractLyrics(filePath string) (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) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err) return "", fmt.Errorf("failed to parse FLAC file: %w", err)
} }
for _, meta := range f.Meta { for _, meta := range f.Meta {
if meta.Type == flac.VorbisComment { if meta.Type != flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta) continue
if err != nil { }
continue
}
lyrics, err := cmt.Get("LYRICS") cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
if err == nil && len(lyrics) > 0 && lyrics[0] != "" { if err != nil {
return lyrics[0], nil continue
} }
lyrics, err = cmt.Get("UNSYNCEDLYRICS") lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" { if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
return lyrics[0], nil 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") 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 { type AudioQuality struct {
BitDepth int `json:"bit_depth"` BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"` SampleRate int `json:"sample_rate"`
@@ -512,356 +692,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)") return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
} }
// ========================================
// M4A (MP4/AAC) Metadata Embedding
// ========================================
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
input, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open M4A file: %w", err)
}
defer input.Close()
info, err := input.Stat()
if err != nil {
return fmt.Errorf("failed to stat M4A file: %w", err)
}
fileSize := info.Size()
moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize)
if err != nil {
return fmt.Errorf("failed to find moov atom: %w", err)
}
if !moovFound {
return fmt.Errorf("moov atom not found in M4A file")
}
moovContentStart := moovHeader.offset + moovHeader.headerSize
moovContentSize := moovHeader.size - moovHeader.headerSize
udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize)
if err != nil {
return fmt.Errorf("failed to locate udta atom: %w", err)
}
var metaHeader atomHeader
metaFound := false
if udtaFound {
udtaContentStart := udtaHeader.offset + udtaHeader.headerSize
udtaContentSize := udtaHeader.size - udtaHeader.headerSize
metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize)
if err != nil {
return fmt.Errorf("failed to locate meta atom: %w", err)
}
}
metaAtom := buildMetaAtom(metadata, coverData)
metaSize := int64(len(metaAtom))
var delta int64
var newUdtaSize int64
switch {
case udtaFound && metaFound:
delta = metaSize - metaHeader.size
newUdtaSize = udtaHeader.size + delta
case udtaFound && !metaFound:
delta = metaSize
newUdtaSize = udtaHeader.size + delta
case !udtaFound:
newUdtaSize = int64(8 + len(metaAtom))
delta = newUdtaSize
}
newMoovSize := moovHeader.size + delta
if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) {
return fmt.Errorf("moov atom exceeds 32-bit size after update")
}
if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) {
return fmt.Errorf("udta atom exceeds 32-bit size after update")
}
if !udtaFound && newUdtaSize > int64(^uint32(0)) {
return fmt.Errorf("udta atom exceeds 32-bit size after update")
}
tempPath := filePath + ".tmp"
output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
cleanupTemp := true
defer func() {
_ = output.Close()
if cleanupTemp {
_ = os.Remove(tempPath)
}
}()
switch {
case udtaFound && metaFound:
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(metaAtom); err != nil {
return fmt.Errorf("failed to write meta atom: %w", err)
}
metaEnd := metaHeader.offset + metaHeader.size
if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil {
return err
}
case udtaFound && !metaFound:
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
return err
}
insertPos := udtaHeader.offset + udtaHeader.size
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(metaAtom); err != nil {
return fmt.Errorf("failed to write meta atom: %w", err)
}
if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil {
return err
}
case !udtaFound:
newUdtaAtom := buildUdtaAtom(metaAtom)
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
moovEnd := moovHeader.offset + moovHeader.size
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(newUdtaAtom); err != nil {
return fmt.Errorf("failed to write udta atom: %w", err)
}
if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil {
return err
}
}
if err := output.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
_ = input.Close()
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
if err := os.Rename(tempPath, filePath); err != nil {
return fmt.Errorf("failed to move temp file: %w", err)
}
cleanupTemp = false
fmt.Printf("[M4A] Metadata embedded successfully\n")
return nil
}
// buildMetaAtom builds a complete meta atom with ilst containing metadata
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
var ilst []byte
if metadata.Title != "" {
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
}
if metadata.Artist != "" {
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
}
if metadata.Album != "" {
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
}
if metadata.AlbumArtist != "" {
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
}
if metadata.Date != "" {
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
}
if metadata.TrackNumber > 0 {
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
}
if metadata.DiscNumber > 0 {
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
}
if metadata.Lyrics != "" {
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
}
if len(coverData) > 0 {
ilst = append(ilst, buildCoverAtom(coverData)...)
}
ilstSize := 8 + len(ilst)
ilstAtom := make([]byte, 4)
ilstAtom[0] = byte(ilstSize >> 24)
ilstAtom[1] = byte(ilstSize >> 16)
ilstAtom[2] = byte(ilstSize >> 8)
ilstAtom[3] = byte(ilstSize)
ilstAtom = append(ilstAtom, []byte("ilst")...)
ilstAtom = append(ilstAtom, ilst...)
hdlr := []byte{
0, 0, 0, 33, // size = 33
'h', 'd', 'l', 'r',
0, 0, 0, 0, // version + flags
0, 0, 0, 0, // predefined
'm', 'd', 'i', 'r', // handler type
'a', 'p', 'p', 'l', // manufacturer
0, 0, 0, 0, // component flags
0, 0, 0, 0, // component flags mask
0, // null terminator
}
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
metaContent = append(metaContent, ilstAtom...)
metaSize := 8 + len(metaContent)
metaAtom := make([]byte, 4)
metaAtom[0] = byte(metaSize >> 24)
metaAtom[1] = byte(metaSize >> 16)
metaAtom[2] = byte(metaSize >> 8)
metaAtom[3] = byte(metaSize)
metaAtom = append(metaAtom, []byte("meta")...)
metaAtom = append(metaAtom, metaContent...)
return metaAtom
}
func buildTextAtom(name, value string) []byte {
valueBytes := []byte(value)
dataSize := 16 + len(valueBytes)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
dataAtom[1] = byte(dataSize >> 16)
dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, valueBytes...)
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte(name)...)
atom = append(atom, dataAtom...)
return atom
}
// buildTrackNumberAtom builds trkn atom
func buildTrackNumberAtom(track, total int) []byte {
dataAtom := []byte{
0, 0, 0, 24, // size
'd', 'a', 't', 'a',
0, 0, 0, 0, // type = implicit
0, 0, 0, 0, // locale
0, 0, // padding
byte(track >> 8), byte(track), // track number
byte(total >> 8), byte(total), // total tracks
0, 0, // padding
}
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("trkn")...)
atom = append(atom, dataAtom...)
return atom
}
func buildDiscNumberAtom(disc, total int) []byte {
dataAtom := []byte{
0, 0, 0, 22, // size
'd', 'a', 't', 'a',
0, 0, 0, 0, // type = implicit
0, 0, 0, 0, // locale
0, 0, // padding
byte(disc >> 8), byte(disc), // disc number
byte(total >> 8), byte(total), // total discs
}
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("disk")...)
atom = append(atom, dataAtom...)
return atom
}
// buildCoverAtom builds covr atom with image data
func buildCoverAtom(coverData []byte) []byte {
imageType := byte(13)
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
imageType = 14
}
dataSize := 16 + len(coverData)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
dataAtom[1] = byte(dataSize >> 16)
dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, imageType)
dataAtom = append(dataAtom, 0, 0, 0, 0)
dataAtom = append(dataAtom, coverData...)
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("covr")...)
atom = append(atom, dataAtom...)
return atom
}
func GetM4AQuality(filePath string) (AudioQuality, error) { func GetM4AQuality(filePath string) (AudioQuality, error) {
f, err := os.Open(filePath) f, err := os.Open(filePath)
if err != nil { if err != nil {
@@ -974,52 +804,6 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6
return atomHeader{}, false, nil return atomHeader{}, false, nil
} }
func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error {
if len(typ) != 4 {
return fmt.Errorf("invalid atom type: %s", typ)
}
if headerSize == 16 {
header := make([]byte, 16)
binary.BigEndian.PutUint32(header[0:4], 1)
copy(header[4:8], []byte(typ))
binary.BigEndian.PutUint64(header[8:16], uint64(size))
_, err := w.Write(header)
return err
}
if size > int64(^uint32(0)) {
return fmt.Errorf("atom size exceeds 32-bit for %s", typ)
}
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], uint32(size))
copy(header[4:8], []byte(typ))
_, err := w.Write(header)
return err
}
func copyRange(dst io.Writer, src *os.File, offset, length int64) error {
if length <= 0 {
return nil
}
if _, err := src.Seek(offset, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek source: %w", err)
}
if _, err := io.CopyN(dst, src, length); err != nil {
return fmt.Errorf("failed to copy data: %w", err)
}
return nil
}
func buildUdtaAtom(metaAtom []byte) []byte {
size := 8 + len(metaAtom)
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], uint32(size))
copy(header[4:8], []byte("udta"))
return append(header, metaAtom...)
}
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) { func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
const chunkSize = 64 * 1024 const chunkSize = 64 * 1024
patternMP4A := []byte("mp4a") patternMP4A := []byte("mp4a")
+31
View File
@@ -0,0 +1,31 @@
package gobackend
import (
"fmt"
"os"
"strings"
)
func isFDOutput(outputFD int) bool {
return outputFD > 0
}
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
if isFDOutput(outputFD) {
return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil
}
return os.Create(outputPath)
}
func cleanupOutputOnError(outputPath string, outputFD int) {
if isFDOutput(outputFD) {
return
}
path := strings.TrimSpace(outputPath)
if path == "" || strings.HasPrefix(path, "/proc/self/fd/") {
return
}
_ = os.Remove(path)
}
+63 -29
View File
@@ -1,6 +1,7 @@
package gobackend package gobackend
import ( import (
"encoding/json"
"fmt" "fmt"
"sync" "sync"
"time" "time"
@@ -9,15 +10,14 @@ import (
type TrackIDCacheEntry struct { type TrackIDCacheEntry struct {
TidalTrackID int64 TidalTrackID int64
QobuzTrackID int64 QobuzTrackID int64
AmazonTrackID string AmazonURL string
ExpiresAt time.Time ExpiresAt time.Time
} }
type TrackIDCache struct { type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry cache map[string]*TrackIDCacheEntry
mu sync.RWMutex mu sync.RWMutex
ttl time.Duration ttl time.Duration
// Cleanup is triggered on writes at a fixed interval to avoid unbounded growth.
lastCleanup time.Time lastCleanup time.Time
cleanupInterval time.Duration cleanupInterval time.Duration
} }
@@ -52,7 +52,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
return entry return entry
} }
// Lazily delete expired entry.
c.mu.Lock() c.mu.Lock()
entry, exists = c.cache[isrc] entry, exists = c.cache[isrc]
if exists && time.Now().After(entry.ExpiresAt) { if exists && time.Now().After(entry.ExpiresAt) {
@@ -108,7 +107,7 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
} }
} }
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -117,7 +116,7 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
entry = &TrackIDCacheEntry{} entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry c.cache[isrc] = entry
} }
entry.AmazonTrackID = trackID entry.AmazonURL = amazonURL
now := time.Now() now := time.Now()
entry.ExpiresAt = now.Add(c.ttl) entry.ExpiresAt = now.Add(c.ttl)
@@ -139,7 +138,6 @@ func (c *TrackIDCache) Size() int {
return len(c.cache) return len(c.cache)
} }
// ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct { type ParallelDownloadResult struct {
CoverData []byte CoverData []byte
LyricsData *LyricsResponse LyricsData *LyricsResponse
@@ -159,20 +157,20 @@ func FetchCoverAndLyricsParallel(
) *ParallelDownloadResult { ) *ParallelDownloadResult {
result := &ParallelDownloadResult{} result := &ParallelDownloadResult{}
var wg sync.WaitGroup var wg sync.WaitGroup
var resultMu sync.Mutex
if coverURL != "" { if coverURL != "" {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
fmt.Println("[Parallel] Starting cover download...")
data, err := downloadCoverToMemory(coverURL, maxQualityCover) data, err := downloadCoverToMemory(coverURL, maxQualityCover)
resultMu.Lock()
if err != nil { if err != nil {
result.CoverErr = err result.CoverErr = err
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
} else { } else {
result.CoverData = data result.CoverData = data
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
} }
resultMu.Unlock()
}() }()
} }
@@ -180,21 +178,19 @@ func FetchCoverAndLyricsParallel(
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient() client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0 durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
resultMu.Lock()
if err != nil { if err != nil {
result.LyricsErr = err result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 { } else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics result.LyricsData = lyrics
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName) result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else { } else {
result.LyricsErr = fmt.Errorf("no lyrics found") result.LyricsErr = fmt.Errorf("no lyrics found")
fmt.Println("[Parallel] No lyrics found")
} }
resultMu.Unlock()
}() }()
} }
@@ -206,8 +202,8 @@ type PreWarmCacheRequest struct {
ISRC string ISRC string
TrackName string TrackName string
ArtistName string ArtistName string
SpotifyID string // Needed for Amazon (SongLink lookup) SpotifyID string
Service string // "tidal", "qobuz", "amazon" Service string
} }
func PreWarmTrackCache(requests []PreWarmCacheRequest) { func PreWarmTrackCache(requests []PreWarmCacheRequest) {
@@ -215,13 +211,15 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
return return
} }
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache() cache := GetTrackIDCache()
semaphore := make(chan struct{}, 3) semaphore := make(chan struct{}, 3)
var wg sync.WaitGroup var wg sync.WaitGroup
for _, req := range requests { for _, req := range requests {
if req.ISRC == "" {
continue
}
if cached := cache.Get(req.ISRC); cached != nil { if cached := cache.Get(req.ISRC); cached != nil {
continue continue
} }
@@ -236,7 +234,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
case "tidal": case "tidal":
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName) preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
case "qobuz": case "qobuz":
preWarmQobuzCache(r.ISRC) preWarmQobuzCache(r.ISRC, r.SpotifyID)
case "amazon": case "amazon":
preWarmAmazonCache(r.ISRC, r.SpotifyID) preWarmAmazonCache(r.ISRC, r.SpotifyID)
} }
@@ -244,7 +242,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
} }
wg.Wait() wg.Wait()
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
} }
func preWarmTidalCache(isrc, _, _ string) { func preWarmTidalCache(isrc, _, _ string) {
@@ -252,30 +249,68 @@ func preWarmTidalCache(isrc, _, _ string) {
track, err := downloader.SearchTrackByISRC(isrc) track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil { if err == nil && track != nil {
GetTrackIDCache().SetTidal(isrc, track.ID) GetTrackIDCache().SetTidal(isrc, track.ID)
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
} }
} }
func preWarmQobuzCache(isrc string) { // preWarmQobuzCache tries to get Qobuz Track ID in the following order:
// 1. From SongLink (fast, no Qobuz API call needed)
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
func preWarmQobuzCache(isrc, spotifyID string) {
// First, try to get QobuzID from SongLink - this is faster and more reliable
if spotifyID != "" {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.QobuzID != "" {
// Parse QobuzID to int64
var trackID int64
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
GetTrackIDCache().SetQobuz(isrc, trackID)
return
}
}
}
// Fallback: Direct ISRC search on Qobuz API
downloader := NewQobuzDownloader() downloader := NewQobuzDownloader()
track, err := downloader.SearchTrackByISRC(isrc) track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil { if err == nil && track != nil {
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc)
GetTrackIDCache().SetQobuz(isrc, track.ID) GetTrackIDCache().SetQobuz(isrc, track.ID)
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
} }
} }
func preWarmAmazonCache(isrc, spotifyID string) { func preWarmAmazonCache(isrc, spotifyID string) {
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc) availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon { if err == nil && availability != nil && availability.AmazonURL != "" {
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL) GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
} }
} }
func PreWarmCache(tracksJSON string) error { func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
SpotifyID string `json:"spotify_id"`
Service string `json:"service"`
}
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
return fmt.Errorf("failed to parse tracks JSON: %w", err)
}
requests := make([]PreWarmCacheRequest, len(tracks))
for i, t := range tracks {
requests[i] = PreWarmCacheRequest{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
SpotifyID: t.SpotifyID,
Service: t.Service,
}
}
go PreWarmTrackCache(requests) go PreWarmTrackCache(requests)
return nil return nil
@@ -283,7 +318,6 @@ func PreWarmCache(tracksJSON string) error {
func ClearTrackCache() { func ClearTrackCache() {
GetTrackIDCache().Clear() GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared")
} }
func GetCacheSize() int { func GetCacheSize() int {
+5 -18
View File
@@ -78,7 +78,6 @@ func GetItemProgress(itemID string) string {
return "{}" return "{}"
} }
// StartItemProgress initializes progress tracking for an item
func StartItemProgress(itemID string) { func StartItemProgress(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -93,7 +92,6 @@ func StartItemProgress(itemID string) {
} }
} }
// SetItemBytesTotal sets total bytes for an item
func SetItemBytesTotal(itemID string, total int64) { func SetItemBytesTotal(itemID string, total int64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -103,7 +101,6 @@ func SetItemBytesTotal(itemID string, total int64) {
} }
} }
// SetItemBytesReceived sets bytes received for an item
func SetItemBytesReceived(itemID string, received int64) { func SetItemBytesReceived(itemID string, received int64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -116,7 +113,6 @@ func SetItemBytesReceived(itemID string, received int64) {
} }
} }
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) { func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -130,7 +126,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
} }
} }
// CompleteItemProgress marks an item as complete
func CompleteItemProgress(itemID string) { func CompleteItemProgress(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -142,7 +137,6 @@ func CompleteItemProgress(itemID string) {
} }
} }
// SetItemProgress sets progress for an item directly
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) { func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -158,7 +152,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
} }
} }
// SetItemFinalizing marks an item as finalizing (embedding metadata)
func SetItemFinalizing(itemID string) { func SetItemFinalizing(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -169,7 +162,6 @@ func SetItemFinalizing(itemID string) {
} }
} }
// RemoveItemProgress removes progress tracking for an item
func RemoveItemProgress(itemID string) { func RemoveItemProgress(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -177,7 +169,6 @@ func RemoveItemProgress(itemID string) {
delete(multiProgress.Items, itemID) delete(multiProgress.Items, itemID)
} }
// ClearAllItemProgress clears all item progress
func ClearAllItemProgress() { func ClearAllItemProgress() {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -185,7 +176,6 @@ func ClearAllItemProgress() {
multiProgress.Items = make(map[string]*ItemProgress) multiProgress.Items = make(map[string]*ItemProgress)
} }
// setDownloadDir sets the default download directory
func setDownloadDir(path string) error { func setDownloadDir(path string) error {
downloadDirMu.Lock() downloadDirMu.Lock()
defer downloadDirMu.Unlock() defer downloadDirMu.Unlock()
@@ -193,20 +183,18 @@ func setDownloadDir(path string) error {
return nil return nil
} }
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
type ItemProgressWriter struct { type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) } writer interface{ Write([]byte) (int, error) }
itemID string itemID string
current int64 current int64
lastReported int64 // Track last reported bytes for threshold-based updates lastReported int64
startTime time.Time // Track start time for speed calculation startTime time.Time
lastTime time.Time // Track last update time for speed calculation lastTime time.Time
lastBytes int64 // Track bytes at last speed calculation lastBytes int64
} }
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB const progressUpdateThreshold = 64 * 1024
// NewItemProgressWriter creates a new progress writer for a specific item
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter { func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
now := time.Now() now := time.Now()
return &ItemProgressWriter{ return &ItemProgressWriter{
@@ -220,7 +208,6 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
} }
} }
// Write implements io.Writer with threshold-based progress updates and speed tracking
func (pw *ItemProgressWriter) Write(p []byte) (int, error) { func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
if pw.itemID != "" && isDownloadCancelled(pw.itemID) { if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
return 0, ErrDownloadCancelled return 0, ErrDownloadCancelled
+295 -220
View File
@@ -52,12 +52,10 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true return true
} }
@@ -112,24 +110,19 @@ func qobuzSplitArtists(artists string) []string {
return result return result
} }
// qobuzSameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func qobuzSameWordsUnordered(a, b string) bool { func qobuzSameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a) wordsA := strings.Fields(a)
wordsB := strings.Fields(b) wordsB := strings.Fields(b)
// Must have same number of words
if len(wordsA) != len(wordsB) || len(wordsA) == 0 { if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false return false
} }
// Sort and compare
sortedA := make([]string, len(wordsA)) sortedA := make([]string, len(wordsA))
sortedB := make([]string, len(wordsB)) sortedB := make([]string, len(wordsB))
copy(sortedA, wordsA) copy(sortedA, wordsA)
copy(sortedB, wordsB) copy(sortedB, wordsB)
// Simple bubble sort (usually just 2-3 words)
for i := 0; i < len(sortedA)-1; i++ { for i := 0; i < len(sortedA)-1; i++ {
for j := i + 1; j < len(sortedA); j++ { for j := i + 1; j < len(sortedA); j++ {
if sortedA[i] > sortedA[j] { if sortedA[i] > sortedA[j] {
@@ -153,7 +146,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle))
// Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
@@ -182,8 +174,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := qobuzIsLatinScript(expectedTitle) expectedLatin := qobuzIsLatinScript(expectedTitle)
foundLatin := qobuzIsLatinScript(foundTitle) foundLatin := qobuzIsLatinScript(foundTitle)
if expectedLatin != foundLatin { if expectedLatin != foundLatin {
@@ -194,9 +184,7 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return false return false
} }
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
func qobuzExtractCoreTitle(title string) string { func qobuzExtractCoreTitle(title string) string {
// Find first occurrence of ( or [
parenIdx := strings.Index(title, "(") parenIdx := strings.Index(title, "(")
bracketIdx := strings.Index(title, "[") bracketIdx := strings.Index(title, "[")
dashIdx := strings.Index(title, " - ") dashIdx := strings.Index(title, " - ")
@@ -281,49 +269,28 @@ func qobuzCleanTitle(title string) string {
return strings.TrimSpace(cleaned) return strings.TrimSpace(cleaned)
} }
// qobuzIsLatinScript checks if a string is primarily Latin script
// Returns true for ASCII and Latin Extended characters (European languages)
// Returns false for CJK, Arabic, Cyrillic, etc.
func qobuzIsLatinScript(s string) bool { func qobuzIsLatinScript(s string) bool {
for _, r := range s { for _, r := range s {
// Skip common punctuation and numbers
if r < 128 { if r < 128 {
continue continue
} }
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.) if (r >= 0x0100 && r <= 0x024F) ||
// Latin Extended-B: U+0180 to U+024F (r >= 0x1E00 && r <= 0x1EFF) ||
// Latin Extended Additional: U+1E00 to U+1EFF (r >= 0x00C0 && r <= 0x00FF) {
// Latin Extended-C/D/E: various ranges
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
continue continue
} }
// CJK ranges - definitely different script if (r >= 0x4E00 && r <= 0x9FFF) ||
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs (r >= 0x3040 && r <= 0x309F) ||
(r >= 0x3040 && r <= 0x309F) || // Hiragana (r >= 0x30A0 && r <= 0x30FF) ||
(r >= 0x30A0 && r <= 0x30FF) || // Katakana (r >= 0xAC00 && r <= 0xD7AF) ||
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean) (r >= 0x0600 && r <= 0x06FF) ||
(r >= 0x0600 && r <= 0x06FF) || // Arabic (r >= 0x0400 && r <= 0x04FF) {
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
return false return false
} }
} }
return true return true
} }
// qobuzIsASCIIString checks if a string contains only ASCII characters
// Kept for potential future use
// func qobuzIsASCIIString(s string) bool {
// for _, r := range s {
// if r > 127 {
// return false
// }
// }
// return true
// }
// containsQueryQobuz checks if a query already exists in the list
func containsQueryQobuz(queries []string, query string) bool { func containsQueryQobuz(queries []string, query string) bool {
for _, q := range queries { for _, q := range queries {
if q == query { if q == query {
@@ -336,7 +303,7 @@ func containsQueryQobuz(queries []string, query string) bool {
func NewQobuzDownloader() *QobuzDownloader { func NewQobuzDownloader() *QobuzDownloader {
qobuzDownloaderOnce.Do(func() { qobuzDownloaderOnce.Do(func() {
globalQobuzDownloader = &QobuzDownloader{ globalQobuzDownloader = &QobuzDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout client: NewHTTPClientWithTimeout(DefaultTimeout),
appID: "798273057", appID: "798273057",
} }
}) })
@@ -344,7 +311,6 @@ func NewQobuzDownloader() *QobuzDownloader {
} }
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
// Qobuz API: /track/get?track_id=XXX
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID) trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
@@ -371,15 +337,11 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
return &track, nil return &track, nil
} }
// GetAvailableAPIs returns list of available Qobuz APIs
// Uses same APIs as PC version for compatibility
func (q *QobuzDownloader) GetAvailableAPIs() []string { func (q *QobuzDownloader) GetAvailableAPIs() []string {
// Same APIs as PC version (referensi/backend/qobuz.go)
// Primary: dab.yeet.su, Fallback: dabmusic.xyz, qobuz.squid.wtf
encodedAPIs := []string{ encodedAPIs := []string{
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==",
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=",
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=", // qobuz.squid.wtf/api/download-music?track_id= "cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=",
} }
var apis []string var apis []string
@@ -394,21 +356,19 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
return apis return apis
} }
// mapJumoQuality maps Qobuz quality codes to Jumo format
func mapJumoQuality(quality string) int { func mapJumoQuality(quality string) int {
switch quality { switch quality {
case "6": case "6":
return 6 // 16-bit FLAC return 6
case "7": case "7":
return 7 // 24-bit 96kHz return 7
case "27": case "27":
return 27 // 24-bit 192kHz return 27
default: default:
return 6 return 6
} }
} }
// decodeXOR decodes XOR-encoded response from Jumo API
func decodeXOR(data []byte) string { func decodeXOR(data []byte) string {
text := string(data) text := string(data)
runes := []rune(text) runes := []rune(text)
@@ -420,13 +380,46 @@ func decodeXOR(data []byte) string {
return string(result) return string(result)
} }
// downloadFromJumo gets download URL from Jumo API (fallback) func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return "", fmt.Errorf("invalid JSON: %v", err)
}
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
return "", fmt.Errorf("%s", errMsg)
}
if success, ok := raw["success"].(bool); ok && !success {
if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" {
return "", fmt.Errorf("%s", msg)
}
return "", fmt.Errorf("api returned success=false")
}
if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
return strings.TrimSpace(linkVal), nil
}
if data, ok := raw["data"].(map[string]any); ok {
if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
return strings.TrimSpace(linkVal), nil
}
}
return "", fmt.Errorf("no download URL in response")
}
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) { func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
formatID := mapJumoQuality(quality) formatID := mapJumoQuality(quality)
region := "US" region := "US"
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
// Jumo API endpoint
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
GoLog("[Qobuz] Trying Jumo API fallback...\n") GoLog("[Qobuz] Trying Jumo API fallback...\n")
@@ -435,6 +428,8 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
@@ -452,17 +447,13 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
} }
var result map[string]any var result map[string]any
// Try parsing as plain JSON first
if err := json.Unmarshal(body, &result); err != nil { if err := json.Unmarshal(body, &result); err != nil {
// Try XOR decoding
decoded := decodeXOR(body) decoded := decodeXOR(body)
if err := json.Unmarshal([]byte(decoded), &result); err != nil { if err := json.Unmarshal([]byte(decoded), &result); err != nil {
return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err) return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err)
} }
} }
// Check for URL in various response formats
if urlVal, ok := result["url"].(string); ok && urlVal != "" { if urlVal, ok := result["url"].(string); ok && urlVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully\n") GoLog("[Qobuz] Jumo API returned URL successfully\n")
return urlVal, nil return urlVal, nil
@@ -511,7 +502,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, err return nil, err
} }
// Find exact ISRC match
for i := range result.Tracks.Items { for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc { if result.Tracks.Items[i].ISRC == isrc {
return &result.Tracks.Items[i], nil return &result.Tracks.Items[i], nil
@@ -525,7 +515,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
} }
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
@@ -558,7 +547,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items)) GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
// Find ISRC matches
var isrcMatches []*QobuzTrack var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items { for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc { if result.Tracks.Items[i].ISRC == isrc {
@@ -612,35 +600,26 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
} }
// Now includes romaji conversion for Japanese text (same as Tidal)
// Also includes title verification to prevent wrong song downloads
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
// Try multiple search strategies (same as Tidal/PC version)
queries := []string{} queries := []string{}
// Strategy 1: Artist + Track name
if artistName != "" && trackName != "" { if artistName != "" && trackName != "" {
queries = append(queries, artistName+" "+trackName) queries = append(queries, artistName+" "+trackName)
} }
// Strategy 2: Track name only
if trackName != "" { if trackName != "" {
queries = append(queries, trackName) queries = append(queries, trackName)
} }
// Strategy 3: Romaji versions if Japanese detected
if ContainsJapanese(trackName) || ContainsJapanese(artistName) { if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
// Convert to romaji (hiragana/katakana only, kanji stays)
romajiTrack := JapaneseToRomaji(trackName) romajiTrack := JapaneseToRomaji(trackName)
romajiArtist := JapaneseToRomaji(artistName) romajiArtist := JapaneseToRomaji(artistName)
// Clean and remove ALL non-ASCII characters (including kanji)
cleanRomajiTrack := CleanToASCII(romajiTrack) cleanRomajiTrack := CleanToASCII(romajiTrack)
cleanRomajiArtist := CleanToASCII(romajiArtist) cleanRomajiArtist := CleanToASCII(romajiArtist)
// Artist + Track romaji (cleaned to ASCII only)
if cleanRomajiArtist != "" && cleanRomajiTrack != "" { if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
if !containsQueryQobuz(queries, romajiQuery) { if !containsQueryQobuz(queries, romajiQuery) {
@@ -649,7 +628,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
} }
// Track romaji only (cleaned)
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName { if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
if !containsQueryQobuz(queries, cleanRomajiTrack) { if !containsQueryQobuz(queries, cleanRomajiTrack) {
queries = append(queries, cleanRomajiTrack) queries = append(queries, cleanRomajiTrack)
@@ -657,7 +635,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
} }
// Strategy 4: Artist only as last resort
if artistName != "" { if artistName != "" {
artistOnly := CleanToASCII(JapaneseToRomaji(artistName)) artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) { if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
@@ -716,7 +693,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
} }
// Filter by title match first (NEW - like Tidal)
var titleMatches []*QobuzTrack var titleMatches []*QobuzTrack
for i := range allTracks { for i := range allTracks {
track := &allTracks[i] track := &allTracks[i]
@@ -727,7 +703,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks)) GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
// If no title matches, log warning but continue with all tracks
tracksToCheck := titleMatches tracksToCheck := titleMatches
if len(titleMatches) == 0 { if len(titleMatches) == 0 {
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks)) GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
@@ -736,7 +711,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
} }
// If duration verification is requested
if expectedDurationSec > 0 { if expectedDurationSec > 0 {
var durationMatches []*QobuzTrack var durationMatches []*QobuzTrack
for _, track := range tracksToCheck { for _, track := range tracksToCheck {
@@ -765,7 +739,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec) return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
} }
// No duration verification, return best quality from title matches
for _, track := range tracksToCheck { for _, track := range tracksToCheck {
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n",
@@ -783,7 +756,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
} }
// qobuzAPIResult holds the result from a parallel API request
type qobuzAPIResult struct { type qobuzAPIResult struct {
apiURL string apiURL string
downloadURL string downloadURL string
@@ -791,82 +763,154 @@ type qobuzAPIResult struct {
duration time.Duration duration time.Duration
} }
// Qobuz API timeout configuration
// Mobile networks are more unstable, so we use longer timeouts
const (
qobuzAPITimeoutMobile = 25 * time.Second
qobuzMaxRetries = 2 // Number of retries per API
qobuzRetryDelay = 500 * time.Millisecond
)
// getQobuzAPITimeout returns appropriate timeout based on platform
// For mobile (gomobile builds), we use longer timeouts
func getQobuzAPITimeout() time.Duration {
// Since this runs in gomobile context, we always use mobile timeout
// The Go backend is only used on mobile (Android/iOS)
return qobuzAPITimeoutMobile
}
// qobuzSquidCountries defines the region fallback order for squid.wtf API
var qobuzSquidCountries = []string{"US", "FR"}
// fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic
// For squid.wtf APIs, it tries US region first, then falls back to FR
func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) {
isSquid := strings.Contains(api, "squid.wtf")
if isSquid {
for _, country := range qobuzSquidCountries {
GoLog("[Qobuz] Trying squid.wtf with country=%s\n", country)
result, err := fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, country)
if err == nil {
return result, nil
}
GoLog("[Qobuz] squid.wtf country=%s failed: %v\n", country, err)
}
return "", fmt.Errorf("squid.wtf failed for all regions (US, FR)")
}
return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "")
}
// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination
func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeout time.Duration, country string) (string, error) {
var lastErr error
retryDelay := qobuzRetryDelay
for attempt := 0; attempt <= qobuzMaxRetries; attempt++ {
if attempt > 0 {
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay)
time.Sleep(retryDelay)
retryDelay *= 2 // Exponential backoff
}
client := NewHTTPClientWithTimeout(timeout)
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
if country != "" {
reqURL += "&country=" + country
}
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
lastErr = err
continue
}
resp, err := client.Do(req)
if err != nil {
lastErr = err
// Check for retryable errors (timeout, connection reset)
errStr := strings.ToLower(err.Error())
if strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "eof") {
continue // Retry
}
break // Non-retryable error
}
// Server errors are retryable
if resp.StatusCode >= 500 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
continue
}
// 429 rate limit - wait and retry
if resp.StatusCode == 429 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("rate limited")
retryDelay = 2 * time.Second // Wait longer for rate limit
continue
}
if resp.StatusCode != 200 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
if len(body) > 0 && body[0] == '<' {
return "", fmt.Errorf("received HTML instead of JSON")
}
urlVal, parseErr := extractQobuzDownloadURLFromBody(body)
if parseErr == nil {
return urlVal, nil
}
lastErr = parseErr
continue
}
if lastErr != nil {
return "", lastErr
}
return "", fmt.Errorf("all retries failed")
}
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 { if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available") return "", "", fmt.Errorf("no APIs available")
} }
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis)) GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
resultChan := make(chan qobuzAPIResult, len(apis)) resultChan := make(chan qobuzAPIResult, len(apis))
startTime := time.Now() startTime := time.Now()
timeout := getQobuzAPITimeout()
// Start all requests in parallel
for _, apiURL := range apis { for _, apiURL := range apis {
go func(api string) { go func(api string) {
reqStart := time.Now() reqStart := time.Now()
downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout)
client := NewHTTPClientWithTimeout(15 * time.Second) resultChan <- qobuzAPIResult{
apiURL: api,
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) downloadURL: downloadURL,
err: err,
req, err := http.NewRequest("GET", reqURL, nil) duration: time.Since(reqStart),
if err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
} }
resp, err := client.Do(req)
if err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
// Check if response is HTML (error page)
if len(body) > 0 && body[0] == '<' {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
return
}
// Check for error in JSON response
var errorResp struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)}
return
}
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("invalid JSON: %v", err), duration: time.Since(reqStart)}
return
}
if result.URL != "" {
resultChan <- qobuzAPIResult{apiURL: api, downloadURL: result.URL, err: nil, duration: time.Since(reqStart)}
return
}
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("no download URL in response"), duration: time.Since(reqStart)}
}(apiURL) }(apiURL)
} }
// Collect results - return first success
var errors []string var errors []string
for i := 0; i < len(apis); i++ { for i := 0; i < len(apis); i++ {
@@ -874,7 +918,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
if result.err == nil { if result.err == nil {
GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration) GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration)
// Drain remaining results to avoid goroutine leaks
go func(remaining int) { go func(remaining int) {
for j := 0; j < remaining; j++ { for j := 0; j < remaining; j++ {
<-resultChan <-resultChan
@@ -906,14 +949,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return downloadURL, nil return downloadURL, nil
} }
// All standard APIs failed, try Jumo as fallback
GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n") GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality) jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
if jumoErr == nil { if jumoErr == nil {
return jumoURL, nil return jumoURL, nil
} }
// If quality is 27 (hi-res), try fallback to lower quality
if quality == "27" { if quality == "27" {
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n") GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
jumoURL, jumoErr = q.downloadFromJumo(trackID, "7") jumoURL, jumoErr = q.downloadFromJumo(trackID, "7")
@@ -933,11 +974,9 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err) return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
} }
// DownloadFile downloads a file from URL with User-Agent and progress tracking func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background() ctx := context.Background()
// Initialize item progress (required for all downloads)
if itemID != "" { if itemID != "" {
StartItemProgress(itemID) StartItemProgress(itemID)
defer CompleteItemProgress(itemID) defer CompleteItemProgress(itemID)
@@ -972,7 +1011,7 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
SetItemBytesTotal(itemID, expectedSize) SetItemBytesTotal(itemID, expectedSize)
} }
out, err := os.Create(outputPath) out, err := openOutputForWrite(outputPath, outputFD)
if err != nil { if err != nil {
return err return err
} }
@@ -987,29 +1026,27 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
written, err = io.Copy(bufWriter, resp.Body) written, err = io.Copy(bufWriter, resp.Body)
} }
// Flush buffer before checking for errors
flushErr := bufWriter.Flush() flushErr := bufWriter.Flush()
closeErr := out.Close() closeErr := out.Close()
if err != nil { if err != nil {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
return ErrDownloadCancelled return ErrDownloadCancelled
} }
return fmt.Errorf("download interrupted: %w", err) return fmt.Errorf("download interrupted: %w", err)
} }
if flushErr != nil { if flushErr != nil {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr) return fmt.Errorf("failed to flush buffer: %w", flushErr)
} }
if closeErr != nil { if closeErr != nil {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr) return fmt.Errorf("failed to close file: %w", closeErr)
} }
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize { if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
} }
@@ -1027,13 +1064,17 @@ type QobuzDownloadResult struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
LyricsLRC string
} }
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader() downloader := NewQobuzDownloader()
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
} }
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
@@ -1041,6 +1082,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
var track *QobuzTrack var track *QobuzTrack
var err error var err error
// Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate)
if req.QobuzID != "" { if req.QobuzID != "" {
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID) GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
var trackID int64 var trackID int64
@@ -1055,24 +1097,46 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
} }
// OPTIMIZATION: Check cache first for track ID // Strategy 2: Use cached Qobuz Track ID (fast, no search needed)
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
// For Qobuz we need to search again to get full track info, but we can use the ID track, err = downloader.GetTrackByID(cached.QobuzTrackID)
track, err = downloader.SearchTrackByISRC(req.ISRC)
if err != nil { if err != nil {
GoLog("[Qobuz] Cache hit but search failed: %v\n", err) GoLog("[Qobuz] Cache hit but GetTrackByID failed: %v\n", err)
track = nil track = nil
} }
} }
} }
// Strategy 1: Search by ISRC with duration verification // Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
GoLog("[Qobuz] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", req.SpotifyID)
songLinkClient := NewSongLinkClient()
availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC)
if slErr == nil && availability != nil && availability.QobuzID != "" {
var trackID int64
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Qobuz] Got Qobuz ID %d from SongLink\n", trackID)
track, err = downloader.GetTrackByID(trackID)
if err != nil {
GoLog("[Qobuz] Failed to get track by SongLink ID %d: %v\n", trackID, err)
track = nil
} else if track != nil {
GoLog("[Qobuz] Successfully found track via SongLink ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
// Cache for future use
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}
}
}
}
}
// Strategy 4: ISRC search with duration verification
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC) GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
// Verify artist AND title
if track != nil { if track != nil {
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
@@ -1086,10 +1150,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
} }
// Strategy 2: Search by metadata with duration verification (includes title verification) // Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
if track == nil { if track == nil {
GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName)
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name) req.ArtistName, track.Performer.Name)
@@ -1105,7 +1169,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
} }
// Log match found and cache the track ID
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration) GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
if req.ISRC != "" { if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID) GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
@@ -1117,31 +1180,36 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
"album": req.AlbumName, "album": req.AlbumName,
"track": req.TrackNumber, "track": req.TrackNumber,
"year": extractYear(req.ReleaseDate), "year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber, "disc": req.DiscNumber,
}) })
filename = sanitizeFilename(filename) + ".flac" var outputPath string
outputPath := filepath.Join(req.OutputDir, filename) if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { if outputPath == "" && isFDOutput(req.OutputFD) {
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
} }
// Map quality from Tidal format to Qobuz format qobuzQuality := "27"
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
qobuzQuality := "27" // Default to highest quality
switch req.Quality { switch req.Quality {
case "LOSSLESS": case "LOSSLESS":
qobuzQuality = "6" // 16-bit FLAC qobuzQuality = "6"
case "HI_RES": case "HI_RES":
qobuzQuality = "7" // 24-bit 96kHz qobuzQuality = "7"
case "HI_RES_LOSSLESS": case "HI_RES_LOSSLESS":
qobuzQuality = "27" // 24-bit 192kHz qobuzQuality = "27"
} }
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
actualBitDepth := track.MaximumBitDepth actualBitDepth := track.MaximumBitDepth
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz actualSampleRate := int(track.MaximumSamplingRate * 1000)
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality) downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
@@ -1149,7 +1217,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
} }
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{}) parallelDone := make(chan struct{})
go func() { go func() {
@@ -1165,15 +1232,13 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
) )
}() }()
// Download audio file with item ID for progress tracking if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) { if errors.Is(err, ErrDownloadCancelled) {
return QobuzDownloadResult{}, ErrDownloadCancelled return QobuzDownloadResult{}, ErrDownloadCancelled
} }
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err) return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
} }
// Wait for parallel operations to complete
<-parallelDone <-parallelDone
if req.ItemID != "" { if req.ItemID != "" {
@@ -1186,7 +1251,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
albumName = req.AlbumName albumName = req.AlbumName
} }
// Use track number from request if available, otherwise from Qobuz API
actualTrackNumber := req.TrackNumber actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 { if actualTrackNumber == 0 {
actualTrackNumber = track.TrackNumber actualTrackNumber = track.TrackNumber
@@ -1196,15 +1260,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Title: track.Title, Title: track.Title,
Artist: track.Performer.Name, Artist: track.Performer.Name,
Album: albumName, Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct AlbumArtist: req.AlbumArtist,
Date: track.Album.ReleaseDate, Date: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber, TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result DiscNumber: req.DiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
Genre: req.Genre, // From Deezer album metadata Genre: req.Genre,
Label: req.Label, // From Deezer album metadata Label: req.Label,
Copyright: req.Copyright, // From Deezer album metadata Copyright: req.Copyright,
} }
var coverData []byte var coverData []byte
@@ -1213,40 +1277,50 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData)) GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} }
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { if isSafOutput {
fmt.Printf("Warning: failed to embed metadata: %v\n", err) GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Qobuz] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
}
} else if req.EmbedLyrics {
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
}
} }
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
}
lyricsLRC := ""
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode lyricsLRC = parallelResult.LyricsLRC
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Qobuz] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
}
} else if req.EmbedLyrics {
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
} }
// Add to ISRC index for fast duplicate checking
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
return QobuzDownloadResult{ return QobuzDownloadResult{
FilePath: outputPath, FilePath: outputPath,
BitDepth: actualBitDepth, BitDepth: actualBitDepth,
@@ -1256,7 +1330,8 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Album: track.Album.Title, Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate, ReleaseDate: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber, TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber, // Qobuz track struct limitations DiscNumber: req.DiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil }, nil
} }
+47
View File
@@ -0,0 +1,47 @@
package gobackend
import "testing"
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
t.Run("reads nested data.url", func(t *testing.T) {
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
got, err := extractQobuzDownloadURLFromBody(body)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != "https://example.test/audio.flac" {
t.Fatalf("unexpected URL: %q", got)
}
})
t.Run("reads top-level url", func(t *testing.T) {
body := []byte(`{"url":"https://example.test/top.flac"}`)
got, err := extractQobuzDownloadURLFromBody(body)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != "https://example.test/top.flac" {
t.Fatalf("unexpected URL: %q", got)
}
})
t.Run("returns API error", func(t *testing.T) {
body := []byte(`{"error":"track not found"}`)
_, err := extractQobuzDownloadURLFromBody(body)
if err == nil || err.Error() != "track not found" {
t.Fatalf("expected track-not-found error, got %v", err)
}
})
t.Run("returns message when success false", func(t *testing.T) {
body := []byte(`{"success":false,"message":"blocked"}`)
_, err := extractQobuzDownloadURLFromBody(body)
if err == nil || err.Error() != "blocked" {
t.Fatalf("expected blocked error, got %v", err)
}
})
}
+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")
}
}
+333 -82
View File
@@ -8,7 +8,6 @@ import (
"net/url" "net/url"
"strings" "strings"
"sync" "sync"
"time"
) )
type SongLinkClient struct { type SongLinkClient struct {
@@ -16,16 +15,21 @@ type SongLinkClient struct {
} }
type TrackAvailability struct { type TrackAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"` Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"` Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"` Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"` Deezer bool `json:"deezer"`
TidalURL string `json:"tidal_url,omitempty"` YouTube bool `json:"youtube"`
AmazonURL string `json:"amazon_url,omitempty"` TidalURL string `json:"tidal_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"` AmazonURL string `json:"amazon_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"` QobuzURL string `json:"qobuz_url,omitempty"`
DeezerID string `json:"deezer_id,omitempty"` DeezerURL string `json:"deezer_url,omitempty"`
YouTubeURL string `json:"youtube_url,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
YouTubeID string `json:"youtube_id,omitempty"`
} }
var ( var (
@@ -36,40 +40,13 @@ var (
func NewSongLinkClient() *SongLinkClient { func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() { songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{ globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), client: NewMetadataHTTPClient(SongLinkTimeout),
} }
}) })
return globalSongLinkClient return globalSongLinkClient
} }
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty")
}
// Try SongLink first
availability, err := s.checkTrackAvailabilitySongLink(spotifyTrackID)
if err != nil {
// Fallback to IDHS if SongLink fails
LogWarn("SongLink", "SongLink failed, trying IDHS fallback: %v", err)
idhsClient := NewIDHSClient()
availability, err = idhsClient.GetAvailabilityFromSpotify(spotifyTrackID)
if err != nil {
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
}
LogInfo("SongLink", "IDHS fallback successful for %s", spotifyTrackID)
}
// Check Qobuz availability separately via ISRC
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
return availability, nil
}
// checkTrackAvailabilitySongLink is the original SongLink implementation
func (s *SongLinkClient) checkTrackAvailabilitySongLink(spotifyTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
@@ -125,6 +102,7 @@ func (s *SongLinkClient) checkTrackAvailabilitySongLink(spotifyTrackID string) (
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true availability.Tidal = true
availability.TidalURL = tidalLink.URL availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
} }
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
@@ -138,6 +116,28 @@ func (s *SongLinkClient) checkTrackAvailabilitySongLink(spotifyTrackID string) (
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
} }
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
// Fallback to regular youtube if youtubeMusic not available
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -158,40 +158,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str
return urls, nil return urls, nil
} }
func checkQobuzAvailability(isrc string) bool {
client := NewHTTPClientWithTimeout(10 * time.Second)
appID := "798273057"
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return false
}
resp, err := DoRequestWithUserAgent(client, req)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
var searchResp struct {
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return false
}
return searchResp.Tracks.Total > 0
}
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL // extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
func extractDeezerIDFromURL(deezerURL string) string { func extractDeezerIDFromURL(deezerURL string) string {
parts := strings.Split(deezerURL, "/") parts := strings.Split(deezerURL, "/")
@@ -205,6 +171,148 @@ func extractDeezerIDFromURL(deezerURL string) string {
return "" return ""
} }
// extractQobuzIDFromURL extracts Qobuz track ID from URL
// URL formats:
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
// - https://open.qobuz.com/track/12345678
// - https://www.qobuz.com/track/12345678
// - https://play.qobuz.com/track/12345678
func extractQobuzIDFromURL(qobuzURL string) string {
if qobuzURL == "" {
return ""
}
// Try to find /track/ID pattern first
if strings.Contains(qobuzURL, "/track/") {
parts := strings.Split(qobuzURL, "/track/")
if len(parts) > 1 {
idPart := parts[1]
// Remove query parameters
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
}
// Remove trailing slash or path
if idx := strings.Index(idPart, "/"); idx > 0 {
idPart = idPart[:idx]
}
idPart = strings.TrimSpace(idPart)
// Validate it's a number
if idPart != "" && isNumeric(idPart) {
return idPart
}
}
}
// Try to extract from album URL with track highlight
// Format: /album/albumname/trackid or ?trackId=12345678
if strings.Contains(qobuzURL, "trackId=") {
parts := strings.Split(qobuzURL, "trackId=")
if len(parts) > 1 {
idPart := parts[1]
if idx := strings.Index(idPart, "&"); idx > 0 {
idPart = idPart[:idx]
}
idPart = strings.TrimSpace(idPart)
if idPart != "" && isNumeric(idPart) {
return idPart
}
}
}
// Last resort: get last numeric segment from URL
parts := strings.Split(qobuzURL, "/")
for i := len(parts) - 1; i >= 0; i-- {
part := parts[i]
// Remove query parameters
if idx := strings.Index(part, "?"); idx > 0 {
part = part[:idx]
}
part = strings.TrimSpace(part)
if part != "" && isNumeric(part) {
return part
}
}
return ""
}
// extractTidalIDFromURL extracts Tidal track ID from URL
// URL formats:
// - https://tidal.com/browse/track/12345678
// - https://listen.tidal.com/track/12345678
func extractTidalIDFromURL(tidalURL string) string {
if tidalURL == "" {
return ""
}
if strings.Contains(tidalURL, "/track/") {
parts := strings.Split(tidalURL, "/track/")
if len(parts) > 1 {
idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
}
if idx := strings.Index(idPart, "/"); idx > 0 {
idPart = idPart[:idx]
}
idPart = strings.TrimSpace(idPart)
if idPart != "" && isNumeric(idPart) {
return idPart
}
}
}
return ""
}
// extractYouTubeIDFromURL extracts YouTube video ID from URL
// URL formats:
// - https://www.youtube.com/watch?v=VIDEO_ID
// - https://youtu.be/VIDEO_ID
// - https://music.youtube.com/watch?v=VIDEO_ID
func extractYouTubeIDFromURL(youtubeURL string) string {
if youtubeURL == "" {
return ""
}
// Handle youtu.be short URLs
if strings.Contains(youtubeURL, "youtu.be/") {
parts := strings.Split(youtubeURL, "youtu.be/")
if len(parts) >= 2 {
idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
}
if idx := strings.Index(idPart, "&"); idx > 0 {
idPart = idPart[:idx]
}
return strings.TrimSpace(idPart)
}
}
// Handle youtube.com URLs with ?v= parameter
parsed, err := url.Parse(youtubeURL)
if err != nil {
return ""
}
if v := parsed.Query().Get("v"); v != "" {
return v
}
// Handle /embed/ format
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0]
}
}
return ""
}
// isNumeric is defined in library_scan.go
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "") availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil { if err != nil {
@@ -218,6 +326,20 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
return availability.DeezerID, nil return availability.DeezerID, nil
} }
// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
return "", err
}
if !availability.YouTube || availability.YouTubeURL == "" {
return "", fmt.Errorf("track not found on YouTube")
}
return availability.YouTubeURL, nil
}
// AlbumAvailability represents album availability on different platforms // AlbumAvailability represents album availability on different platforms
type AlbumAvailability struct { type AlbumAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
@@ -227,10 +349,8 @@ type AlbumAvailability struct {
} }
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
// Use global rate limiter
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
// Build API URL for album
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID) spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
@@ -301,10 +421,8 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return nil, fmt.Errorf("deezer track ID is empty") return nil, fmt.Errorf("deezer track ID is empty")
} }
// Try SongLink first
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID) availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
if err != nil { if err != nil {
// Fallback to IDHS if SongLink fails
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err) LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
idhsClient := NewIDHSClient() idhsClient := NewIDHSClient()
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID) availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
@@ -338,7 +456,6 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
} }
defer resp.Body.Close() defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 { if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)") return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
} }
@@ -385,6 +502,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true availability.Tidal = true
availability.TidalURL = tidalLink.URL availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
} }
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
@@ -392,10 +510,29 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
availability.AmazonURL = amazonLink.URL availability.AmazonURL = amazonLink.URL
} }
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
} }
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -407,11 +544,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
return nil, fmt.Errorf("%s ID is empty", platform) return nil, fmt.Errorf("%s ID is empty", platform)
} }
// Use global rate limiter
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
// Build API URL using platform, type, and id parameters (as per API docs)
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US", apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
url.QueryEscape(platform), url.QueryEscape(platform),
url.QueryEscape(entityType), url.QueryEscape(entityType),
@@ -429,7 +563,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
} }
defer resp.Body.Close() defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 { if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform) return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
} }
@@ -467,6 +600,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true availability.Tidal = true
availability.TidalURL = tidalLink.URL availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
} }
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
@@ -474,12 +608,31 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
availability.AmazonURL = amazonLink.URL availability.AmazonURL = amazonLink.URL
} }
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true availability.Deezer = true
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
} }
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -535,3 +688,101 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
return availability.AmazonURL, nil return availability.AmazonURL, nil
} }
// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
return "", err
}
if !availability.YouTube || availability.YouTubeURL == "" {
return "", fmt.Errorf("track not found on YouTube")
}
return availability.YouTubeURL, nil
}
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 400 || resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on SongLink")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
EntityID string `json:"entityUniqueId"`
} `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
availability := &TrackAvailability{}
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil
}
+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)
}
}
+11 -16
View File
@@ -63,10 +63,8 @@ var (
credentialsMu sync.RWMutex credentialsMu sync.RWMutex
) )
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)") var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
// SetSpotifyCredentials sets custom Spotify API credentials
func SetSpotifyCredentials(clientID, clientSecret string) { func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock() credentialsMu.Lock()
defer credentialsMu.Unlock() defer credentialsMu.Unlock()
@@ -89,7 +87,6 @@ func HasSpotifyCredentials() bool {
return false return false
} }
// getCredentials returns the current credentials or error if not configured
func getCredentials() (string, string, error) { func getCredentials() (string, string, error) {
credentialsMu.RLock() credentialsMu.RLock()
defer credentialsMu.RUnlock() defer credentialsMu.RUnlock()
@@ -117,7 +114,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
src := rand.NewSource(time.Now().UnixNano()) src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{ c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), httpClient: NewMetadataHTTPClient(15 * time.Second),
clientID: clientID, clientID: clientID,
clientSecret: clientSecret, clientSecret: clientSecret,
rng: rand.New(src), rng: rand.New(src),
@@ -143,7 +140,7 @@ type TrackMetadata struct {
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
ExternalURL string `json:"external_urls"` ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation AlbumType string `json:"album_type,omitempty"`
} }
type AlbumTrackMetadata struct { type AlbumTrackMetadata struct {
@@ -212,7 +209,7 @@ type ArtistAlbumMetadata struct {
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
Images string `json:"images"` Images string `json:"images"`
AlbumType string `json:"album_type"` // album, single, compilation AlbumType string `json:"album_type"`
Artists string `json:"artists"` Artists string `json:"artists"`
} }
@@ -534,7 +531,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
albumImage := firstImageURL(data.Images) albumImage := firstImageURL(data.Images)
// Get first artist ID
var firstArtistId string var firstArtistId string
if len(data.Artists) > 0 { if len(data.Artists) > 0 {
firstArtistId = data.Artists[0].ID firstArtistId = data.Artists[0].ID
@@ -567,7 +563,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks) fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
// Collect track IDs for parallel ISRC fetching
trackIDs := make([]string, len(allTrackItems)) trackIDs := make([]string, len(allTrackItems))
for i, item := range allTrackItems { for i, item := range allTrackItems {
trackIDs[i] = item.ID trackIDs[i] = item.ID
@@ -939,14 +934,14 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
defer c.rngMu.Unlock() defer c.rngMu.Unlock()
macMajor := c.rng.Intn(4) + 11 macMajor := c.rng.Intn(4) + 11
macMinor := c.rng.Intn(5) + 4 // 4-8 macMinor := c.rng.Intn(5) + 4
webkitMajor := c.rng.Intn(7) + 530 // 530-536 webkitMajor := c.rng.Intn(7) + 530
webkitMinor := c.rng.Intn(7) + 30 // 30-36 webkitMinor := c.rng.Intn(7) + 30
chromeMajor := c.rng.Intn(25) + 80 // 80-104 chromeMajor := c.rng.Intn(25) + 80
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499 chromeBuild := c.rng.Intn(1500) + 3000
chromePatch := c.rng.Intn(65) + 60 // 60-124 chromePatch := c.rng.Intn(65) + 60
safariMajor := c.rng.Intn(7) + 530 // 530-536 safariMajor := c.rng.Intn(7) + 530
safariMinor := c.rng.Intn(6) + 30 // 30-35 safariMinor := c.rng.Intn(6) + 30
return fmt.Sprintf( return fmt.Sprintf(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
+375 -165
View File
@@ -119,19 +119,18 @@ func NewTidalDownloader() *TidalDownloader {
return globalTidalDownloader return globalTidalDownloader
} }
// GetAvailableAPIs returns list of available Tidal APIs
func (t *TidalDownloader) GetAvailableAPIs() []string { func (t *TidalDownloader) GetAvailableAPIs() []string {
encodedAPIs := []string{ encodedAPIs := []string{
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority)
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online "dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf "dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site "dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site "bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site "aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site "a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site "d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
} }
var apis []string var apis []string
@@ -251,7 +250,6 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
return trackID, nil return trackID, nil
} }
// GetTrackInfoByID gets track info by Tidal track ID
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
token, err := t.GetAccessToken() token, err := t.GetAccessToken()
if err != nil { if err != nil {
@@ -319,7 +317,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, err return nil, err
} }
// Find exact ISRC match
for i := range result.Items { for i := range result.Items {
if result.Items[i].ISRC == isrc { if result.Items[i].ISRC == isrc {
return &result.Items[i], nil return &result.Items[i], nil
@@ -343,7 +340,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
// Build search queries - multiple strategies (same as PC version) // Build search queries - multiple strategies (same as PC version)
queries := []string{} queries := []string{}
// Strategy 1: Artist + Track name (original)
if artistName != "" && trackName != "" { if artistName != "" && trackName != "" {
queries = append(queries, artistName+" "+trackName) queries = append(queries, artistName+" "+trackName)
} }
@@ -586,13 +582,123 @@ type tidalAPIResult struct {
duration time.Duration duration time.Duration
} }
// Returns the first successful result (supports both v1 and v2 API formats) // Tidal API timeout configuration
// Mobile networks are more unstable, so we use longer timeouts
const (
tidalAPITimeoutMobile = 25 * time.Second
tidalMaxRetries = 2 // Number of retries per API
tidalRetryDelay = 500 * time.Millisecond
)
// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic
func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) {
var lastErr error
retryDelay := tidalRetryDelay
for attempt := 0; attempt <= tidalMaxRetries; attempt++ {
if attempt > 0 {
GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay)
time.Sleep(retryDelay)
retryDelay *= 2 // Exponential backoff
}
client := NewHTTPClientWithTimeout(timeout)
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
lastErr = err
continue
}
resp, err := client.Do(req)
if err != nil {
lastErr = err
// Check for retryable errors (timeout, connection reset)
errStr := strings.ToLower(err.Error())
if strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "eof") {
continue // Retry
}
break // Non-retryable error
}
// Server errors are retryable
if resp.StatusCode >= 500 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
continue
}
// 429 rate limit - wait and retry
if resp.StatusCode == 429 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("rate limited")
retryDelay = 2 * time.Second // Wait longer for rate limit
continue
}
if resp.StatusCode != 200 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return TidalDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
// Try V2 response format (with manifest)
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
if v2Response.Data.AssetPresentation == "PREVIEW" {
return TidalDownloadInfo{}, fmt.Errorf("returned PREVIEW instead of FULL")
}
return TidalDownloadInfo{
URL: "MANIFEST:" + v2Response.Data.Manifest,
BitDepth: v2Response.Data.BitDepth,
SampleRate: v2Response.Data.SampleRate,
}, nil
}
// Try V1 response format
var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
return TidalDownloadInfo{
URL: item.OriginalTrackURL,
BitDepth: 16,
SampleRate: 44100,
}, nil
}
}
}
return TidalDownloadInfo{}, fmt.Errorf("no download URL or manifest in response")
}
if lastErr != nil {
return TidalDownloadInfo{}, lastErr
}
return TidalDownloadInfo{}, fmt.Errorf("all retries failed")
}
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
if len(apis) == 0 { if len(apis) == 0 {
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available") return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
} }
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis)) GoLog("[Tidal] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
resultChan := make(chan tidalAPIResult, len(apis)) resultChan := make(chan tidalAPIResult, len(apis))
startTime := time.Now() startTime := time.Now()
@@ -600,69 +706,13 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
for _, apiURL := range apis { for _, apiURL := range apis {
go func(api string) { go func(api string) {
reqStart := time.Now() reqStart := time.Now()
info, err := fetchTidalURLWithRetry(api, trackID, quality, tidalAPITimeoutMobile)
client := NewHTTPClientWithTimeout(15 * time.Second) resultChan <- tidalAPIResult{
apiURL: api,
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) info: info,
err: err,
req, err := http.NewRequest("GET", reqURL, nil) duration: time.Since(reqStart),
if err != nil {
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
} }
resp, err := client.Do(req)
if err != nil {
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
if v2Response.Data.AssetPresentation == "PREVIEW" {
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
return
}
info := TidalDownloadInfo{
URL: "MANIFEST:" + v2Response.Data.Manifest,
BitDepth: v2Response.Data.BitDepth,
SampleRate: v2Response.Data.SampleRate,
}
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
return
}
var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
info := TidalDownloadInfo{
URL: item.OriginalTrackURL,
BitDepth: 16,
SampleRate: 44100,
}
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
return
}
}
}
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)}
}(apiURL) }(apiURL)
} }
@@ -789,6 +839,10 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount) GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
} }
if segmentCount == 0 {
return "", "", nil, fmt.Errorf("no segments found in manifest")
}
for i := 1; i <= segmentCount; i++ { for i := 1; i <= segmentCount; i++ {
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i)) mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL) mediaURLs = append(mediaURLs, mediaURL)
@@ -797,8 +851,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return "", initURL, mediaURLs, nil return "", initURL, mediaURLs, nil
} }
// DownloadFile downloads a file from URL with progress tracking func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background() ctx := context.Background()
if strings.HasPrefix(downloadURL, "MANIFEST:") { if strings.HasPrefix(downloadURL, "MANIFEST:") {
@@ -811,7 +864,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
return ErrDownloadCancelled return ErrDownloadCancelled
} }
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID) return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, outputFD, itemID)
} }
if itemID != "" { if itemID != "" {
@@ -848,7 +901,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
SetItemBytesTotal(itemID, expectedSize) SetItemBytesTotal(itemID, expectedSize)
} }
out, err := os.Create(outputPath) out, err := openOutputForWrite(outputPath, outputFD)
if err != nil { if err != nil {
return err return err
} }
@@ -867,30 +920,30 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
closeErr := out.Close() closeErr := out.Close()
if err != nil { if err != nil {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
return ErrDownloadCancelled return ErrDownloadCancelled
} }
return fmt.Errorf("download interrupted: %w", err) return fmt.Errorf("download interrupted: %w", err)
} }
if flushErr != nil { if flushErr != nil {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr) return fmt.Errorf("failed to flush buffer: %w", flushErr)
} }
if closeErr != nil { if closeErr != nil {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr) return fmt.Errorf("failed to close file: %w", closeErr)
} }
if expectedSize > 0 && written != expectedSize { if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
} }
return nil return nil
} }
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error { func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath string, outputFD int, itemID string) error {
fmt.Println("[Tidal] Parsing manifest...") fmt.Println("[Tidal] Parsing manifest...")
directURL, initURL, mediaURLs, err := parseManifest(manifestB64) directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
if err != nil { if err != nil {
@@ -935,7 +988,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
SetItemBytesTotal(itemID, expectedSize) SetItemBytesTotal(itemID, expectedSize)
} }
out, err := os.Create(outputPath) out, err := openOutputForWrite(outputPath, outputFD)
if err != nil { if err != nil {
return fmt.Errorf("failed to create file: %w", err) return fmt.Errorf("failed to create file: %w", err)
} }
@@ -951,29 +1004,40 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
closeErr := out.Close() closeErr := out.Close()
if err != nil { if err != nil {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
return ErrDownloadCancelled return ErrDownloadCancelled
} }
return fmt.Errorf("download interrupted: %w", err) return fmt.Errorf("download interrupted: %w", err)
} }
if closeErr != nil { if closeErr != nil {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr) return fmt.Errorf("failed to close file: %w", closeErr)
} }
if expectedSize > 0 && written != expectedSize { if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath) cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
} }
return nil return nil
} }
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a" // For DASH format, determine correct M4A path
// If outputPath already ends with .m4a, use it directly.
// If outputPath ends with .flac, convert .flac to .m4a.
// Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is.
var m4aPath string
if strings.HasSuffix(outputPath, ".m4a") {
m4aPath = outputPath
} else if strings.HasSuffix(outputPath, ".flac") {
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
} else {
m4aPath = outputPath
}
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath) GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
out, err := os.Create(m4aPath) out, err := openOutputForWrite(m4aPath, outputFD)
if err != nil { if err != nil {
GoLog("[Tidal] Failed to create M4A file: %v\n", err) GoLog("[Tidal] Failed to create M4A file: %v\n", err)
return fmt.Errorf("failed to create M4A file: %w", err) return fmt.Errorf("failed to create M4A file: %w", err)
@@ -982,20 +1046,20 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
GoLog("[Tidal] Downloading init segment...\n") GoLog("[Tidal] Downloading init segment...\n")
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
out.Close() out.Close()
os.Remove(m4aPath) cleanupOutputOnError(m4aPath, outputFD)
return ErrDownloadCancelled return ErrDownloadCancelled
} }
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil) req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
if err != nil { if err != nil {
out.Close() out.Close()
os.Remove(m4aPath) cleanupOutputOnError(m4aPath, outputFD)
GoLog("[Tidal] Init segment request failed: %v\n", err) GoLog("[Tidal] Init segment request failed: %v\n", err)
return fmt.Errorf("failed to create init segment request: %w", err) return fmt.Errorf("failed to create init segment request: %w", err)
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
out.Close() out.Close()
os.Remove(m4aPath) cleanupOutputOnError(m4aPath, outputFD)
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
return ErrDownloadCancelled return ErrDownloadCancelled
} }
@@ -1005,7 +1069,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
resp.Body.Close() resp.Body.Close()
out.Close() out.Close()
os.Remove(m4aPath) cleanupOutputOnError(m4aPath, outputFD)
GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode) GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode) return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
} }
@@ -1013,7 +1077,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
resp.Body.Close() resp.Body.Close()
if err != nil { if err != nil {
out.Close() out.Close()
os.Remove(m4aPath) cleanupOutputOnError(m4aPath, outputFD)
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
return ErrDownloadCancelled return ErrDownloadCancelled
} }
@@ -1025,7 +1089,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
for i, mediaURL := range mediaURLs { for i, mediaURL := range mediaURLs {
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
out.Close() out.Close()
os.Remove(m4aPath) cleanupOutputOnError(m4aPath, outputFD)
return ErrDownloadCancelled return ErrDownloadCancelled
} }
@@ -1041,14 +1105,14 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil) req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
if err != nil { if err != nil {
out.Close() out.Close()
os.Remove(m4aPath) cleanupOutputOnError(m4aPath, outputFD)
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err) GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
return fmt.Errorf("failed to create segment %d request: %w", i+1, err) return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
out.Close() out.Close()
os.Remove(m4aPath) cleanupOutputOnError(m4aPath, outputFD)
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
return ErrDownloadCancelled return ErrDownloadCancelled
} }
@@ -1058,7 +1122,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
resp.Body.Close() resp.Body.Close()
out.Close() out.Close()
os.Remove(m4aPath) cleanupOutputOnError(m4aPath, outputFD)
GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode) GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode) return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
} }
@@ -1066,7 +1130,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
resp.Body.Close() resp.Body.Close()
if err != nil { if err != nil {
out.Close() out.Close()
os.Remove(m4aPath) cleanupOutputOnError(m4aPath, outputFD)
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
return ErrDownloadCancelled return ErrDownloadCancelled
} }
@@ -1076,7 +1140,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
} }
if err := out.Close(); err != nil { if err := out.Close(); err != nil {
os.Remove(m4aPath) cleanupOutputOnError(m4aPath, outputFD)
GoLog("[Tidal] Failed to close M4A file: %v\n", err) GoLog("[Tidal] Failed to close M4A file: %v\n", err)
return fmt.Errorf("failed to close M4A file: %w", err) return fmt.Errorf("failed to close M4A file: %w", err)
} }
@@ -1096,6 +1160,7 @@ type TidalDownloadResult struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
LyricsLRC string // LRC content for embedding in converted files
} }
func artistsMatch(spotifyArtist, tidalArtist string) bool { func artistsMatch(spotifyArtist, tidalArtist string) bool {
@@ -1106,7 +1171,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return true return true
} }
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) { if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
return true return true
} }
@@ -1165,7 +1229,6 @@ func sameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a) wordsA := strings.Fields(a)
wordsB := strings.Fields(b) wordsB := strings.Fields(b)
// Must have same number of words
if len(wordsA) != len(wordsB) || len(wordsA) == 0 { if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false return false
} }
@@ -1198,7 +1261,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle))
// Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
@@ -1207,7 +1269,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// Clean both titles and compare
cleanExpected := cleanTitle(normExpected) cleanExpected := cleanTitle(normExpected)
cleanFound := cleanTitle(normFound) cleanFound := cleanTitle(normFound)
@@ -1221,7 +1282,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
} }
} }
// Extract core title (before any parentheses/brackets)
coreExpected := extractCoreTitle(normExpected) coreExpected := extractCoreTitle(normExpected)
coreFound := extractCoreTitle(normFound) coreFound := extractCoreTitle(normFound)
@@ -1229,7 +1289,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := isLatinScript(expectedTitle) expectedLatin := isLatinScript(expectedTitle)
foundLatin := isLatinScript(foundTitle) foundLatin := isLatinScript(foundTitle)
if expectedLatin != foundLatin { if expectedLatin != foundLatin {
@@ -1350,8 +1409,11 @@ func isLatinScript(s string) bool {
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader() downloader := NewTidalDownloader()
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
} }
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
@@ -1407,49 +1469,83 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if track == nil && req.SpotifyID != "" { if track == nil && req.SpotifyID != "" {
GoLog("[Tidal] ISRC search failed, trying SongLink...\n") GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
var tidalURL string
var slErr error var trackID int64
var gotTidalID bool
if strings.HasPrefix(req.SpotifyID, "deezer:") { if strings.HasPrefix(req.SpotifyID, "deezer:") {
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID) GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
songlink := NewSongLinkClient() songlink := NewSongLinkClient()
tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID) availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID)
if slErr == nil && availability != nil && availability.TidalID != "" {
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
gotTidalID = true
}
}
// Fallback to URL parsing if TidalID not in struct
if !gotTidalID && availability != nil && availability.TidalURL != "" {
var idErr error
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
if idErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
gotTidalID = true
}
}
} else { } else {
tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID) songlink := NewSongLinkClient()
availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
if slErr == nil && availability != nil && availability.TidalID != "" {
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
gotTidalID = true
}
}
// Fallback to URL parsing if TidalID not in struct
if !gotTidalID && availability != nil && availability.TidalURL != "" {
var idErr error
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
if idErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
gotTidalID = true
}
}
} }
if slErr == nil && tidalURL != "" { if gotTidalID && trackID > 0 {
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL) track, err = downloader.GetTrackInfoByID(trackID)
if idErr == nil { if track != nil {
track, err = downloader.GetTrackInfoByID(trackID) tidalArtist := track.Artist.Name
if track != nil { if len(track.Artists) > 0 {
tidalArtist := track.Artist.Name var artistNames []string
if len(track.Artists) > 0 { for _, a := range track.Artists {
var artistNames []string artistNames = append(artistNames, a.Name)
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
} }
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) { if !artistsMatch(req.ArtistName, tidalArtist) {
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n", GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist) req.ArtistName, tidalArtist)
track = nil track = nil
} }
if track != nil && expectedDurationSec > 0 { if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 { if durationDiff < 0 {
durationDiff = -durationDiff durationDiff = -durationDiff
}
if durationDiff > 3 {
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration)
track = nil // Reject this match
}
} }
if durationDiff > 3 {
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration)
track = nil // Reject this match
}
}
// Cache for future use
if track != nil && req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
} }
} }
} }
@@ -1502,35 +1598,69 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GetTrackIDCache().SetTidal(req.ISRC, track.ID) GetTrackIDCache().SetTidal(req.ISRC, track.ID)
} }
quality := req.Quality
if quality == "" {
quality = "LOSSLESS"
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
"artist": req.ArtistName, "artist": req.ArtistName,
"album": req.AlbumName, "album": req.AlbumName,
"track": req.TrackNumber, "track": req.TrackNumber,
"year": extractYear(req.ReleaseDate), "year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber, "disc": req.DiscNumber,
}) })
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { outputExt := strings.TrimSpace(req.OutputExt)
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil if outputExt == "" {
} if quality == "HIGH" {
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a" outputExt = ".m4a"
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { } else {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil outputExt = ".flac"
}
} else if !strings.HasPrefix(outputExt, ".") {
outputExt = "." + outputExt
} }
tmpPath := outputPath + ".m4a.tmp" var outputPath string
if _, err := os.Stat(tmpPath); err == nil { var m4aPath string
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath) if isSafOutput {
os.Remove(tmpPath) outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
m4aPath = outputPath
} else {
if outputExt == ".m4a" || quality == "HIGH" {
filename = sanitizeFilename(filename) + ".m4a"
outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = outputPath
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
}
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
if quality != "HIGH" {
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
}
} }
quality := req.Quality if !isSafOutput {
if quality == "" { tmpPath := outputPath + ".m4a.tmp"
quality = "LOSSLESS" if _, err := os.Stat(tmpPath); err == nil {
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
os.Remove(tmpPath)
}
} }
GoLog("[Tidal] Using quality: %s\n", quality) GoLog("[Tidal] Using quality: %s\n", quality)
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality) downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
@@ -1563,7 +1693,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return "Direct URL" return "Direct URL"
}()) }())
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil { if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) { if errors.Is(err, ErrDownloadCancelled) {
return TidalDownloadResult{}, ErrDownloadCancelled return TidalDownloadResult{}, ErrDownloadCancelled
} }
@@ -1580,11 +1710,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
actualOutputPath := outputPath actualOutputPath := outputPath
if _, err := os.Stat(m4aPath); err == nil { if !isSafOutput {
actualOutputPath = m4aPath if _, err := os.Stat(m4aPath); err == nil {
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) actualOutputPath = m4aPath
} else if _, err := os.Stat(outputPath); err != nil { GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) } else if _, err := os.Stat(outputPath); err != nil {
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
}
} }
releaseDate := req.ReleaseDate releaseDate := req.ReleaseDate
@@ -1593,7 +1725,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate) GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
} }
// Use track number from request if available, otherwise from Tidal API
actualTrackNumber := req.TrackNumber actualTrackNumber := req.TrackNumber
actualDiscNumber := req.DiscNumber actualDiscNumber := req.DiscNumber
if actualTrackNumber == 0 { if actualTrackNumber == 0 {
@@ -1624,7 +1755,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData)) GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} }
if strings.HasSuffix(actualOutputPath, ".flac") { actualExt := outputExt
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
actualExt = ".m4a"
}
if actualExt == "" && !isSafOutput {
actualExt = strings.ToLower(filepath.Ext(actualOutputPath))
}
if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) {
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err) fmt.Printf("Warning: failed to embed metadata: %v\n", err)
} }
@@ -1635,7 +1774,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
lyricsMode = "embed" lyricsMode = "embed"
} }
if lyricsMode == "external" || lyricsMode == "both" { if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
GoLog("[Tidal] Saving external LRC file...\n") GoLog("[Tidal] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr) GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
@@ -1655,16 +1794,49 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} else if req.EmbedLyrics { } else if req.EmbedLyrics {
fmt.Println("[Tidal] No lyrics available from parallel fetch") fmt.Println("[Tidal] No lyrics available from parallel fetch")
} }
} else if strings.HasSuffix(actualOutputPath, ".m4a") { } else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") if quality == "HIGH" {
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
}
}
}
} else {
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
}
} }
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
}
bitDepth := downloadInfo.BitDepth
sampleRate := downloadInfo.SampleRate
lyricsLRC := ""
if quality == "HIGH" {
bitDepth = 0
sampleRate = 44100
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return TidalDownloadResult{ return TidalDownloadResult{
FilePath: actualOutputPath, FilePath: actualOutputPath,
BitDepth: downloadInfo.BitDepth, BitDepth: bitDepth,
SampleRate: downloadInfo.SampleRate, SampleRate: sampleRate,
Title: track.Title, Title: track.Title,
Artist: track.Artist.Name, Artist: track.Artist.Name,
Album: track.Album.Title, Album: track.Album.Title,
@@ -1672,5 +1844,43 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TrackNumber: actualTrackNumber, TrackNumber: actualTrackNumber,
DiscNumber: actualDiscNumber, DiscNumber: actualDiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil }, nil
} }
func parseTidalURL(input string) (string, string, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return "", "", fmt.Errorf("empty URL")
}
parsed, err := url.Parse(trimmed)
if err != nil {
return "", "", err
}
if parsed.Host != "tidal.com" && parsed.Host != "listen.tidal.com" && parsed.Host != "www.tidal.com" {
return "", "", fmt.Errorf("not a Tidal URL")
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
// Handle /browse/track/123 format
if len(parts) > 0 && parts[0] == "browse" {
parts = parts[1:]
}
if len(parts) < 2 {
return "", "", fmt.Errorf("invalid Tidal URL format")
}
resourceType := parts[0]
resourceID := parts[1]
switch resourceType {
case "track", "album", "artist", "playlist":
return resourceType, resourceID, nil
default:
return "", "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
}
}
+567
View File
@@ -0,0 +1,567 @@
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
package gobackend
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type YouTubeDownloader struct {
client *http.Client
apiURL string
mu sync.Mutex
}
var (
globalYouTubeDownloader *YouTubeDownloader
youtubeDownloaderOnce sync.Once
)
type YouTubeQuality string
const (
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
)
type CobaltRequest struct {
URL string `json:"url"`
AudioBitrate string `json:"audioBitrate,omitempty"`
AudioFormat string `json:"audioFormat,omitempty"`
DownloadMode string `json:"downloadMode,omitempty"`
FilenameStyle string `json:"filenameStyle,omitempty"`
DisableMetadata bool `json:"disableMetadata,omitempty"`
}
type CobaltResponse struct {
Status string `json:"status"`
URL string `json:"url,omitempty"`
Filename string `json:"filename,omitempty"`
Error *struct {
Code string `json:"code"`
Context *struct {
Service string `json:"service,omitempty"`
Limit int `json:"limit,omitempty"`
} `json:"context,omitempty"`
} `json:"error,omitempty"`
}
type YouTubeDownloadResult struct {
FilePath string
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
Format string // "opus" or "mp3"
Bitrate int
LyricsLRC string
CoverData []byte
}
func NewYouTubeDownloader() *YouTubeDownloader {
youtubeDownloaderOnce.Do(func() {
globalYouTubeDownloader = &YouTubeDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second),
apiURL: "https://api.qwkuns.me",
}
})
return globalYouTubeDownloader
}
// 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)
searchQuery := url.QueryEscape(query)
GoLog("[YouTube] Search query: %s\n", query)
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
return youtubeMusicURL, nil
}
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
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"
}
// Try SpotubeDL first (primary)
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
if extractErr == nil {
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
videoID, audioFormat, audioBitrate)
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
if err == nil {
return resp, nil
}
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
} else {
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
}
// Fallback: direct Cobalt API (api.qwkuns.me)
cobaltURL := toYouTubeMusicURL(youtubeURL)
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
cobaltURL, audioFormat, audioBitrate)
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
if err != nil {
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
}
return resp, nil
}
// requestCobaltDirect sends a download request to the primary Cobalt API.
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
reqBody := CobaltRequest{
URL: videoURL,
AudioFormat: audioFormat,
AudioBitrate: audioBitrate,
DownloadMode: "audio",
FilenameStyle: "basic",
DisableMetadata: true,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("cobalt API request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
}
var cobaltResp CobaltResponse
if err := json.Unmarshal(body, &cobaltResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
}
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
}
if cobaltResp.URL == "" {
return nil, fmt.Errorf("no download URL in response")
}
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
return &cobaltResp, nil
}
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
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)
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("spotubedl request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
}
var result struct {
URL string `json:"url"`
}
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")
}
GoLog("[YouTube] Got download URL from SpotubeDL\n")
return &CobaltResponse{
Status: "tunnel",
URL: result.URL,
}, nil
}
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[YouTube] Download completed: %d bytes written\n", written)
return nil
}
func BuildYouTubeSearchURL(trackName, artistName string) string {
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
}
func BuildYouTubeWatchURL(videoID string) string {
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
func isYouTubeVideoID(s string) bool {
if len(s) != 11 {
return false
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}
func IsYouTubeURL(urlStr string) bool {
lower := strings.ToLower(urlStr)
return strings.Contains(lower, "youtube.com") ||
strings.Contains(lower, "youtu.be") ||
strings.Contains(lower, "music.youtube.com")
}
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
func toYouTubeMusicURL(rawURL string) string {
videoID, err := ExtractYouTubeVideoID(rawURL)
if err != nil {
return rawURL
}
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
func ExtractYouTubeVideoID(urlStr string) (string, error) {
if strings.Contains(urlStr, "youtu.be/") {
parts := strings.Split(urlStr, "youtu.be/")
if len(parts) >= 2 {
videoID := strings.Split(parts[1], "?")[0]
videoID = strings.Split(videoID, "&")[0]
return strings.TrimSpace(videoID), nil
}
}
parsed, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
// /watch?v=
if v := parsed.Query().Get("v"); v != "" {
return v, nil
}
// /embed/
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
// /v/
if strings.Contains(parsed.Path, "/v/") {
parts := strings.Split(parsed.Path, "/v/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
return "", fmt.Errorf("could not extract video ID from URL")
}
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
}
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
var youtubeURL string
var lookupErr error
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
}
// Try Spotify ID via SongLink
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
}
}
// Try Deezer ID via SongLink
if youtubeURL == "" && req.DeezerID != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
}
}
// Try ISRC via SongLink
if youtubeURL == "" && req.ISRC != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
songlink := NewSongLinkClient()
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
youtubeURL = availability.YouTubeURL
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
} else if isrcErr != nil {
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
}
}
// Cobalt requires direct video URLs, not search URLs
if youtubeURL == "" {
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
}
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
if err != nil {
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 = ".opus"
format = "opus"
bitrate = 256
case YouTubeQualityMP3320:
ext = ".mp3"
format = "mp3"
bitrate = 320
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ext
var outputPath string
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
outputPath = req.OutputDir + "/" + filename
}
GoLog("[YouTube] Downloading to: %s\n", outputPath)
// Parallel fetch cover art + lyrics
var parallelResult *ParallelDownloadResult
if req.EmbedLyrics || req.CoverURL != "" {
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
lyricsLRC := ""
var coverData []byte
if parallelResult != nil {
if parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
}
if parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
}
}
return YouTubeDownloadResult{
FilePath: outputPath,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Format: format,
Bitrate: bitrate,
LyricsLRC: lyricsLRC,
CoverData: coverData,
}, nil
}
+5
View File
@@ -46,6 +46,11 @@ post_install do |installer|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config| target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' 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 end
end end
+152 -15
View File
@@ -83,18 +83,12 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "downloadTrack": case "downloadByStrategy":
let requestJson = call.arguments as! String let requestJson = call.arguments as! String
let response = GobackendDownloadTrack(requestJson, &error) let response = GobackendDownloadByStrategy(requestJson, &error)
if let error = error { throw error } if let error = error { throw error }
return response 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": case "getDownloadProgress":
let response = GobackendGetDownloadProgress() let response = GobackendGetDownloadProgress()
return response return response
@@ -197,6 +191,17 @@ import Gobackend // Import Go framework
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error) let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
if let error = error { throw error } if let error = error { throw error }
return response 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": case "embedLyricsToFile":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
@@ -209,6 +214,41 @@ import Gobackend // Import Go framework
case "cleanupConnections": case "cleanupConnections":
GobackendCleanupConnections() GobackendCleanupConnections()
return nil 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": case "readFileMetadata":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
@@ -217,6 +257,14 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "editFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let metadataJson = args["metadata_json"] as? String ?? "{}"
let response = GobackendEditFileMetadata(filePath, metadataJson, &error)
if let error = error { throw error }
return response
case "searchDeezerAll": case "searchDeezerAll":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let query = args["query"] as! String let query = args["query"] as! String
@@ -242,6 +290,20 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "parseTidalUrl":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendParseTidalURLExport(url, &error)
if let error = error { throw error }
return response
case "convertTidalToSpotifyDeezer":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendConvertTidalToSpotifyDeezer(url, &error)
if let error = error { throw error }
return response
case "searchDeezerByISRC": case "searchDeezerByISRC":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let isrc = args["isrc"] as! String let isrc = args["isrc"] as! String
@@ -457,12 +519,6 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response 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": case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String let extensionId = args["extension_id"] as! String
@@ -470,6 +526,12 @@ import Gobackend // Import Go framework
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error) let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
if let error = error { throw error } if let error = error { throw error }
return response 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": case "removeExtension":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
@@ -625,6 +687,14 @@ import Gobackend // Import Go framework
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error) let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
if let error = error { throw error } if let error = error { throw error }
return response return response
case "runPostProcessingV2":
let args = call.arguments as! [String: Any]
let inputJson = args["input"] as? String ?? ""
let metadataJson = args["metadata"] as? String ?? ""
let response = GobackendRunPostProcessingV2JSON(inputJson, metadataJson, &error)
if let error = error { throw error }
return response
case "getPostProcessingProviders": case "getPostProcessingProviders":
let response = GobackendGetPostProcessingProvidersJSON(&error) let response = GobackendGetPostProcessingProvidersJSON(&error)
@@ -687,6 +757,73 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
// Local Library Scanning
case "setLibraryCoverCacheDir":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
GobackendSetLibraryCoverCacheDirJSON(cacheDir)
return nil
case "scanLibraryFolder":
let args = call.arguments as! [String: Any]
let folderPath = args["folder_path"] as! String
let response = GobackendScanLibraryFolderJSON(folderPath, &error)
if let error = error { throw error }
return response
case "scanLibraryFolderIncremental":
let args = call.arguments as! [String: Any]
let folderPath = args["folder_path"] as! String
let existingFiles = args["existing_files"] as? String ?? "{}"
let response = GobackendScanLibraryFolderIncrementalJSON(folderPath, existingFiles, &error)
if let error = error { throw error }
return response
case "getLibraryScanProgress":
let response = GobackendGetLibraryScanProgressJSON()
return response
case "cancelLibraryScan":
GobackendCancelLibraryScanJSON()
return nil
case "readAudioMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendReadAudioMetadataJSON(filePath, &error)
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: default:
throw NSError( throw NSError(
domain: "SpotiFLAC", domain: "SpotiFLAC",
+25 -1
View File
@@ -67,7 +67,7 @@
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <false/>
</dict> </dict>
<!-- File Sharing - Allow access via Files app --> <!-- File Sharing - Allow access via Files app -->
@@ -81,5 +81,29 @@
<!-- Photo Library (for cover art if needed) --> <!-- Photo Library (for cover art if needed) -->
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>SpotiFLAC needs access to save album artwork</string> <string>SpotiFLAC needs access to save album artwork</string>
<!-- URL Schemes for deep linking -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>com.zarz.spotiflac</string>
<key>CFBundleURLSchemes</key>
<array>
<string>spotiflac</string>
</array>
</dict>
</array>
<!-- Associated Domains for Universal Links -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>spotify</string>
<string>deezer</string>
<string>tidal</string>
<string>youtube-music</string>
</array>
</dict> </dict>
</plist> </plist>
+32 -12
View File
@@ -4,36 +4,55 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/screens/main_shell.dart'; import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart'; import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/screens/tutorial_screen.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) { final _routerProvider = Provider<GoRouter>((ref) {
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch)); 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) {
initialLocation = '/setup';
} else if (!hasCompletedTutorial) {
initialLocation = '/tutorial';
} else {
initialLocation = '/';
}
return GoRouter( return GoRouter(
initialLocation: isFirstLaunch ? '/setup' : '/', initialLocation: initialLocation,
routes: [ routes: [
GoRoute(path: '/', builder: (context, state) => const MainShell()),
GoRoute(path: '/setup', builder: (context, state) => const SetupScreen()),
GoRoute( GoRoute(
path: '/', path: '/tutorial',
builder: (context, state) => const MainShell(), builder: (context, state) => const TutorialScreen(),
),
GoRoute(
path: '/setup',
builder: (context, state) => const SetupScreen(),
), ),
], ],
); );
}); });
class SpotiFLACApp extends ConsumerWidget { class SpotiFLACApp extends ConsumerWidget {
const SpotiFLACApp({super.key}); final bool disableOverscrollEffects;
const SpotiFLACApp({super.key, this.disableOverscrollEffects = false});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(_routerProvider); final router = ref.watch(_routerProvider);
final localeString = ref.watch(settingsProvider.select((s) => s.locale)); final localeString = ref.watch(settingsProvider.select((s) => s.locale));
final scrollBehavior = disableOverscrollEffects
? const MaterialScrollBehavior().copyWith(overscroll: false)
: null;
Locale? locale; Locale? locale;
if (localeString != 'system') { if (localeString != 'system') {
if (localeString.contains('_')) { if (localeString.contains('_')) {
@@ -43,7 +62,7 @@ class SpotiFLACApp extends ConsumerWidget {
locale = Locale(localeString); locale = Locale(localeString);
} }
} }
return DynamicColorWrapper( return DynamicColorWrapper(
builder: (lightTheme, darkTheme, themeMode) { builder: (lightTheme, darkTheme, themeMode) {
return MaterialApp.router( return MaterialApp.router(
@@ -52,6 +71,7 @@ class SpotiFLACApp extends ConsumerWidget {
theme: lightTheme, theme: lightTheme,
darkTheme: darkTheme, darkTheme: darkTheme,
themeMode: themeMode, themeMode: themeMode,
scrollBehavior: scrollBehavior,
themeAnimationDuration: const Duration(milliseconds: 300), themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut, themeAnimationCurve: Curves.easeInOut,
routerConfig: router, routerConfig: router,
+3 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.3.1'; static const String version = '3.6.7';
static const String buildNumber = '68'; static const String buildNumber = '81';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
@@ -17,4 +17,5 @@ static const String version = '3.3.1';
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC'; static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
static const String kofiUrl = 'https://ko-fi.com/zarzet'; static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+740 -6
View File
@@ -18,6 +18,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get navHome => 'Home'; String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override @override
String get navHistory => 'History'; String get navHistory => 'History';
@@ -340,6 +343,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -450,12 +457,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override @override
String get aboutApp => 'App'; String get aboutApp => 'App';
@@ -488,6 +489,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -684,6 +692,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +951,11 @@ class AppLocalizationsEn extends AppLocalizations {
return '\"$trackName\" already downloaded'; return '\"$trackName\" already downloaded';
} }
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override @override
String get snackbarHistoryCleared => 'History cleared'; String get snackbarHistoryCleared => 'History cleared';
@@ -1165,6 +1182,13 @@ class AppLocalizationsEn extends AppLocalizations {
return '$artist - $title'; return '$artist - $title';
} }
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override @override
String get folderOrganization => 'Folder Organization'; String get folderOrganization => 'Folder Organization';
@@ -1895,6 +1919,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1907,6 +1935,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -1986,6 +2036,39 @@ class AppLocalizationsEn extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2109,6 +2192,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2208,4 +2297,649 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get allFilesAccessDisabledMessage => String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.'; 'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
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
+740 -6
View File
@@ -18,6 +18,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get navHome => 'होम'; String get navHome => 'होम';
@override
String get navLibrary => 'Library';
@override @override
String get navHistory => 'इतिहास'; String get navHistory => 'इतिहास';
@@ -340,6 +343,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -450,12 +457,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override @override
String get aboutApp => 'App'; String get aboutApp => 'App';
@@ -488,6 +489,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -684,6 +692,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +951,11 @@ class AppLocalizationsHi extends AppLocalizations {
return '\"$trackName\" already downloaded'; return '\"$trackName\" already downloaded';
} }
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override @override
String get snackbarHistoryCleared => 'History cleared'; String get snackbarHistoryCleared => 'History cleared';
@@ -1165,6 +1182,13 @@ class AppLocalizationsHi extends AppLocalizations {
return '$artist - $title'; return '$artist - $title';
} }
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override @override
String get folderOrganization => 'Folder Organization'; String get folderOrganization => 'Folder Organization';
@@ -1895,6 +1919,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1907,6 +1935,28 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -1986,6 +2036,39 @@ class AppLocalizationsHi extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2109,6 +2192,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2208,4 +2297,649 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get allFilesAccessDisabledMessage => String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.'; 'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
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';
} }
+740 -6
View File
@@ -18,6 +18,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get navHome => 'Beranda'; String get navHome => 'Beranda';
@override
String get navLibrary => 'Library';
@override @override
String get navHistory => 'Riwayat'; String get navHistory => 'Riwayat';
@@ -344,6 +347,10 @@ class AppLocalizationsId extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com'; 'Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Ekstensi'; String get extensionsTitle => 'Ekstensi';
@@ -455,12 +462,6 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get aboutSupport => 'Dukungan'; String get aboutSupport => 'Dukungan';
@override
String get aboutBuyMeCoffee => 'Belikan saya kopi';
@override
String get aboutBuyMeCoffeeSubtitle => 'Dukung pengembangan di Ko-fi';
@override @override
String get aboutApp => 'Aplikasi'; String get aboutApp => 'Aplikasi';
@@ -493,6 +494,13 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!'; 'API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.'; 'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
@@ -689,6 +697,10 @@ class AppLocalizationsId extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.'; 'Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC'; String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC';
@@ -945,6 +957,11 @@ class AppLocalizationsId extends AppLocalizations {
return '\"$trackName\" sudah diunduh'; return '\"$trackName\" sudah diunduh';
} }
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override @override
String get snackbarHistoryCleared => 'Riwayat dihapus'; String get snackbarHistoryCleared => 'Riwayat dihapus';
@@ -1171,6 +1188,13 @@ class AppLocalizationsId extends AppLocalizations {
return '$artist - $title'; 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 @override
String get folderOrganization => 'Organisasi Folder'; String get folderOrganization => 'Organisasi Folder';
@@ -1907,6 +1931,10 @@ class AppLocalizationsId extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1919,6 +1947,28 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Struktur Folder Album'; String get downloadAlbumFolderStructure => 'Struktur Folder Album';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
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 @override
String get downloadSaveFormat => 'Simpan Format'; String get downloadSaveFormat => 'Simpan Format';
@@ -1999,6 +2049,39 @@ class AppLocalizationsId extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Apakah Anda yakin ingin menghapus semua unduhan?'; 'Apakah Anda yakin ingin menghapus semua unduhan?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override @override
String get queueEmpty => 'Tidak ada unduhan dalam antrian'; String get queueEmpty => 'Tidak ada unduhan dalam antrian';
@@ -2122,6 +2205,12 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2221,4 +2310,649 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get allFilesAccessDisabledMessage => String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.'; 'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
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';
} }
+740 -6
View File
@@ -18,6 +18,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get navHome => 'ホーム'; String get navHome => 'ホーム';
@override
String get navLibrary => 'Library';
@override @override
String get navHistory => '履歴'; String get navHistory => '履歴';
@@ -337,6 +340,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。'; 'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => '拡張'; String get extensionsTitle => '拡張';
@@ -446,12 +453,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get aboutSupport => 'サポート'; String get aboutSupport => 'サポート';
@override
String get aboutBuyMeCoffee => 'コーヒーを買ってください';
@override
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします';
@override @override
String get aboutApp => 'アプリ'; String get aboutApp => 'アプリ';
@@ -484,6 +485,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。'; 'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
@@ -679,6 +687,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Spotify のトラックを FLAC でダウンロード'; String get setupDownloadInFlac => 'Spotify のトラックを FLAC でダウンロード';
@@ -934,6 +946,11 @@ class AppLocalizationsJa extends AppLocalizations {
return '$trackName」は既にダウンロードされています'; return '$trackName」は既にダウンロードされています';
} }
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override @override
String get snackbarHistoryCleared => '履歴を消去しました'; String get snackbarHistoryCleared => '履歴を消去しました';
@@ -1159,6 +1176,13 @@ class AppLocalizationsJa extends AppLocalizations {
return '$artist - $title'; return '$artist - $title';
} }
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override @override
String get folderOrganization => 'フォルダ構成'; String get folderOrganization => 'フォルダ構成';
@@ -1883,6 +1907,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1895,6 +1923,28 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造'; String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
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 @override
String get downloadSaveFormat => '形式を保存'; String get downloadSaveFormat => '形式を保存';
@@ -1973,6 +2023,39 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?'; String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override @override
String get queueEmpty => 'キューにダウンロードがありません'; String get queueEmpty => 'キューにダウンロードがありません';
@@ -2095,6 +2178,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'プレイリスト'; String get recentTypePlaylist => 'プレイリスト';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'プレイリスト: $name'; return 'プレイリスト: $name';
@@ -2194,4 +2283,649 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get allFilesAccessDisabledMessage => String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.'; 'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
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';
} }
+752 -19
View File
@@ -13,11 +13,14 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get appDescription => String get appDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Spotify 트랙을 Tidal, Qobuz, Amazon Music에서 무손실 음질로 다운로드하세요.';
@override @override
String get navHome => 'Home'; String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override @override
String get navHistory => 'History'; String get navHistory => 'History';
@@ -31,32 +34,32 @@ class AppLocalizationsKo extends AppLocalizations {
String get homeTitle => 'Home'; String get homeTitle => 'Home';
@override @override
String get homeSearchHint => 'Paste Spotify URL or search...'; String get homeSearchHint => 'Spotify URL을 붙여 넣거나 검색';
@override @override
String homeSearchHintExtension(String extensionName) { String homeSearchHintExtension(String extensionName) {
return 'Search with $extensionName...'; return '$extensionName에서 검색';
} }
@override @override
String get homeSubtitle => 'Paste a Spotify link or search by name'; String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
@override @override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
@override @override
String get homeRecent => 'Recent'; String get homeRecent => '최근 기록';
@override @override
String get historyTitle => 'History'; String get historyTitle => '기록';
@override @override
String historyDownloading(int count) { String historyDownloading(int count) {
return 'Downloading ($count)'; return '다운로드 중... $count';
} }
@override @override
String get historyDownloaded => 'Downloaded'; String get historyDownloaded => '다운로드 목록';
@override @override
String get historyFilterAll => 'All'; String get historyFilterAll => 'All';
@@ -72,7 +75,7 @@ class AppLocalizationsKo extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
count, count,
locale: localeName, locale: localeName,
other: '$count tracks', other: '${count}tracks',
one: '1 track', one: '1 track',
); );
return '$_temp0'; return '$_temp0';
@@ -242,14 +245,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsAutoFallback => 'Auto Fallback'; String get optionsAutoFallback => 'Auto Fallback';
@override @override
String get optionsAutoFallbackSubtitle => String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도';
'Try other services if download fails';
@override @override
String get optionsUseExtensionProviders => 'Use Extension Providers'; String get optionsUseExtensionProviders => 'Use Extension Providers';
@override @override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
@override @override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
@@ -340,6 +342,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -450,12 +456,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override @override
String get aboutApp => 'App'; String get aboutApp => 'App';
@@ -488,6 +488,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -684,6 +691,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +950,11 @@ class AppLocalizationsKo extends AppLocalizations {
return '\"$trackName\" already downloaded'; return '\"$trackName\" already downloaded';
} }
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override @override
String get snackbarHistoryCleared => 'History cleared'; String get snackbarHistoryCleared => 'History cleared';
@@ -1165,6 +1181,13 @@ class AppLocalizationsKo extends AppLocalizations {
return '$artist - $title'; return '$artist - $title';
} }
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override @override
String get folderOrganization => 'Folder Organization'; String get folderOrganization => 'Folder Organization';
@@ -1895,6 +1918,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1907,6 +1934,28 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -1986,6 +2035,39 @@ class AppLocalizationsKo extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2109,6 +2191,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2208,4 +2296,649 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get allFilesAccessDisabledMessage => String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.'; 'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
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';
} }
+740 -6
View File
@@ -18,6 +18,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get navHome => 'Home'; String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override @override
String get navHistory => 'History'; String get navHistory => 'History';
@@ -340,6 +343,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -450,12 +457,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override @override
String get aboutApp => 'App'; String get aboutApp => 'App';
@@ -488,6 +489,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -684,6 +692,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +951,11 @@ class AppLocalizationsNl extends AppLocalizations {
return '\"$trackName\" already downloaded'; return '\"$trackName\" already downloaded';
} }
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override @override
String get snackbarHistoryCleared => 'History cleared'; String get snackbarHistoryCleared => 'History cleared';
@@ -1165,6 +1182,13 @@ class AppLocalizationsNl extends AppLocalizations {
return '$artist - $title'; return '$artist - $title';
} }
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override @override
String get folderOrganization => 'Folder Organization'; String get folderOrganization => 'Folder Organization';
@@ -1895,6 +1919,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1907,6 +1935,28 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -1986,6 +2036,39 @@ class AppLocalizationsNl extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2109,6 +2192,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2208,4 +2297,649 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get allFilesAccessDisabledMessage => String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.'; 'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
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
+740 -6
View File
@@ -18,6 +18,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get navHome => 'Ara'; String get navHome => 'Ara';
@override
String get navLibrary => 'Library';
@override @override
String get navHistory => 'Geçmiş'; String get navHistory => 'Geçmiş';
@@ -345,6 +348,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin'; 'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Eklentiler'; String get extensionsTitle => 'Eklentiler';
@@ -457,12 +464,6 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get aboutSupport => 'Destek'; String get aboutSupport => 'Destek';
@override
String get aboutBuyMeCoffee => 'Bana bir kahve ısmarla';
@override
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi üzerinden uygulamayı destekle';
@override @override
String get aboutApp => 'Uygulama'; String get aboutApp => 'Uygulama';
@@ -495,6 +496,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'En iyi Qobuz streaming API\'ı. Yüksek kalite indirmeler bunun sayesinde!'; 'En iyi Qobuz streaming API\'ı. Yüksek kalite indirmeler bunun sayesinde!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.'; 'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
@@ -691,6 +699,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS\'un sınırlaması: Boş klasörler seçilemiyor. İçinde en az bir dosya bulunan bir klasör seçin.'; 'iOS\'un sınırlaması: Boş klasörler seçilemiyor. İçinde en az bir dosya bulunan bir klasör seçin.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Spotify şarkılarını FLAC olarak indirin'; String get setupDownloadInFlac => 'Spotify şarkılarını FLAC olarak indirin';
@@ -946,6 +958,11 @@ class AppLocalizationsTr extends AppLocalizations {
return '\"$trackName\" zaten indirilmiş'; return '\"$trackName\" zaten indirilmiş';
} }
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override @override
String get snackbarHistoryCleared => 'Geçmiş temizlendi'; String get snackbarHistoryCleared => 'Geçmiş temizlendi';
@@ -1172,6 +1189,13 @@ class AppLocalizationsTr extends AppLocalizations {
return '$artist - $title'; return '$artist - $title';
} }
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override @override
String get folderOrganization => 'Klasör Organizasyonu'; String get folderOrganization => 'Klasör Organizasyonu';
@@ -1910,6 +1934,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1922,6 +1950,28 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2001,6 +2051,39 @@ class AppLocalizationsTr extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2124,6 +2207,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2223,4 +2312,649 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get allFilesAccessDisabledMessage => String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.'; 'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
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
+574 -8
View File
@@ -9,8 +9,10 @@
"navHome": "Home", "navHome": "Home",
"@navHome": {"description": "Bottom navigation - Home tab"}, "@navHome": {"description": "Bottom navigation - Home tab"},
"navLibrary": "Library",
"@navLibrary": {"description": "Bottom navigation - Library tab"},
"navHistory": "History", "navHistory": "History",
"@navHistory": {"description": "Bottom navigation - History tab"}, "@navHistory": {"description": "Bottom navigation - History tab (legacy)"},
"navSettings": "Settings", "navSettings": "Settings",
"@navSettings": {"description": "Bottom navigation - Settings tab"}, "@navSettings": {"description": "Bottom navigation - Settings tab"},
"navStore": "Store", "navStore": "Store",
@@ -239,6 +241,8 @@
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"}, "@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", "optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"}, "@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"},
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
"@optionsSpotifyDeprecationWarning": {"description": "Warning about Spotify API deprecation"},
"extensionsTitle": "Extensions", "extensionsTitle": "Extensions",
"@extensionsTitle": {"description": "Extensions page title"}, "@extensionsTitle": {"description": "Extensions page title"},
@@ -322,10 +326,6 @@
"@aboutSocial": {"description": "Section for social links"}, "@aboutSocial": {"description": "Section for social links"},
"aboutSupport": "Support", "aboutSupport": "Support",
"@aboutSupport": {"description": "Section for support/donation links"}, "@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": "App",
"@aboutApp": {"description": "Section for app info"}, "@aboutApp": {"description": "Section for app info"},
"aboutVersion": "Version", "aboutVersion": "Version",
@@ -344,6 +344,10 @@
"@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"}, "@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"},
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
"@aboutDabMusicDesc": {"description": "Credit for DAB Music API"}, "@aboutDabMusicDesc": {"description": "Credit for DAB Music API"},
"aboutSpotiSaver": "SpotiSaver",
"@aboutSpotiSaver": {"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"},
"aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!",
"@aboutSpotiSaverDesc": {"description": "Credit for SpotiSaver API"},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"@aboutAppDescription": {"description": "App description in header card"}, "@aboutAppDescription": {"description": "App description in header card"},
@@ -481,8 +485,10 @@
"@setupChooseFromFiles": {"description": "iOS file picker option"}, "@setupChooseFromFiles": {"description": "iOS file picker option"},
"setupChooseFromFilesSubtitle": "Select iCloud or other location", "setupChooseFromFilesSubtitle": "Select iCloud or other location",
"@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"}, "@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"},
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", "setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
"@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"}, "@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"},
"setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.",
"@setupIcloudNotSupported": {"description": "Error when user selects iCloud Drive on iOS"},
"setupDownloadInFlac": "Download Spotify tracks in FLAC", "setupDownloadInFlac": "Download Spotify tracks in FLAC",
"@setupDownloadInFlac": {"description": "App tagline in setup"}, "@setupDownloadInFlac": {"description": "App tagline in setup"},
"setupStepStorage": "Storage", "setupStepStorage": "Storage",
@@ -668,6 +674,13 @@
"trackName": {"type": "String"} "trackName": {"type": "String"}
} }
}, },
"snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library",
"@snackbarAlreadyInLibrary": {
"description": "Snackbar - track already exists in local library",
"placeholders": {
"trackName": {"type": "String"}
}
},
"snackbarHistoryCleared": "History cleared", "snackbarHistoryCleared": "History cleared",
"@snackbarHistoryCleared": {"description": "Snackbar - history deleted"}, "@snackbarHistoryCleared": {"description": "Snackbar - history deleted"},
"snackbarCredentialsSaved": "Credentials saved", "snackbarCredentialsSaved": "Credentials saved",
@@ -861,6 +874,14 @@
"@filenameAvailablePlaceholders": {"description": "Label for placeholder list"}, "@filenameAvailablePlaceholders": {"description": "Label for placeholder list"},
"filenameHint": "{artist} - {title}", "filenameHint": "{artist} - {title}",
"@filenameHint": {"description": "Default filename format hint"}, "@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": "Folder Organization",
"@folderOrganization": {"description": "Setting title - folder structure"}, "@folderOrganization": {"description": "Setting title - folder structure"},
@@ -1397,6 +1418,8 @@
"@lossyFormatOpusSubtitle": {"description": "Opus format description"}, "@lossyFormatOpusSubtitle": {"description": "Opus format description"},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {"description": "Note about quality availability"}, "@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"},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"}, "@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
@@ -1406,6 +1429,18 @@
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"}, "@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
"downloadAlbumFolderStructure": "Album Folder Structure", "downloadAlbumFolderStructure": "Album Folder Structure",
"@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"}, "@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {"description": "Setting - choose whether artist folders use Album Artist or Track Artist"},
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available",
"@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": "Save Format",
"@downloadSaveFormat": {"description": "Setting - output file format"}, "@downloadSaveFormat": {"description": "Setting - output file format"},
"downloadSelectService": "Select Service", "downloadSelectService": "Select Service",
@@ -1458,10 +1493,32 @@
"queueTitle": "Download Queue", "queueTitle": "Download Queue",
"@queueTitle": {"description": "Queue screen title"}, "@queueTitle": {"description": "Queue screen title"},
"queueClearAll": "Clear All", "queueClearAll": "Clear All",
"@queueClearAll": {"description": "Button - clear all queue items"}, "@queueClearAll": {"description": "Button - clear all queue items"},
"queueClearAllMessage": "Are you sure you want to clear all downloads?", "queueClearAllMessage": "Are you sure you want to clear all downloads?",
"@queueClearAllMessage": {"description": "Clear queue confirmation"}, "@queueClearAllMessage": {"description": "Clear queue confirmation"},
"queueExportFailed": "Export",
"@queueExportFailed": {"description": "Button - export failed downloads to TXT"},
"queueExportFailedSuccess": "Failed downloads exported to TXT file",
"@queueExportFailedSuccess": {"description": "Success message after exporting failed downloads"},
"queueExportFailedClear": "Clear Failed",
"@queueExportFailedClear": {"description": "Action to clear failed downloads after export"},
"queueExportFailedError": "Failed to export downloads",
"@queueExportFailedError": {"description": "Error message when export fails"},
"settingsAutoExportFailed": "Auto-export failed downloads",
"@settingsAutoExportFailed": {"description": "Setting toggle for auto-export"},
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
"@settingsAutoExportFailedSubtitle": {"description": "Subtitle for auto-export setting"},
"settingsDownloadNetwork": "Download Network",
"@settingsDownloadNetwork": {"description": "Setting for network type preference"},
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
"@settingsDownloadNetworkAny": {"description": "Network option - use any connection"},
"settingsDownloadNetworkWifiOnly": "WiFi Only",
"@settingsDownloadNetworkWifiOnly": {"description": "Network option - only use WiFi"},
"settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.",
"@settingsDownloadNetworkSubtitle": {"description": "Subtitle explaining network preference"},
"queueEmpty": "No downloads in queue", "queueEmpty": "No downloads in queue",
"@queueEmpty": {"description": "Empty queue state title"}, "@queueEmpty": {"description": "Empty queue state title"},
"queueEmptySubtitle": "Add tracks from the home screen", "queueEmptySubtitle": "Add tracks from the home screen",
@@ -1557,6 +1614,12 @@
"@recentTypeSong": {"description": "Recent access item type - song/track"}, "@recentTypeSong": {"description": "Recent access item type - song/track"},
"recentTypePlaylist": "Playlist", "recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {"description": "Recent access item type - playlist"}, "@recentTypePlaylist": {"description": "Recent access item type - playlist"},
"recentEmpty": "No recent items yet",
"@recentEmpty": {"description": "Empty state text for recent access list"},
"recentShowAllDownloads": "Show All Downloads",
"@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access"
},
"recentPlaylistInfo": "Playlist: {name}", "recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": { "@recentPlaylistInfo": {
@@ -1661,5 +1724,508 @@
"allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.", "allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.",
"@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"}, "@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"},
"allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.", "allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.",
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"} "@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"},
"settingsLocalLibrary": "Local Library",
"@settingsLocalLibrary": {"description": "Settings menu item - local library"},
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
"settingsCache": "Storage & Cache",
"@settingsCache": {"description": "Settings menu item - cache management"},
"settingsCacheSubtitle": "View size and clear cached data",
"@settingsCacheSubtitle": {"description": "Subtitle for cache management menu"},
"libraryTitle": "Local Library",
"@libraryTitle": {"description": "Library settings page title"},
"libraryStatus": "Library Status",
"@libraryStatus": {"description": "Section header for library status"},
"libraryScanSettings": "Scan Settings",
"@libraryScanSettings": {"description": "Section header for scan settings"},
"libraryEnableLocalLibrary": "Enable Local Library",
"@libraryEnableLocalLibrary": {"description": "Toggle to enable library scanning"},
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
"@libraryEnableLocalLibrarySubtitle": {"description": "Subtitle for enable toggle"},
"libraryFolder": "Library Folder",
"@libraryFolder": {"description": "Folder selection setting"},
"libraryFolderHint": "Tap to select folder",
"@libraryFolderHint": {"description": "Placeholder when no folder selected"},
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
"@libraryShowDuplicateIndicator": {"description": "Toggle for duplicate indicator in search"},
"libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks",
"@libraryShowDuplicateIndicatorSubtitle": {"description": "Subtitle for duplicate indicator toggle"},
"libraryActions": "Actions",
"@libraryActions": {"description": "Section header for library actions"},
"libraryScan": "Scan Library",
"@libraryScan": {"description": "Button to start library scan"},
"libraryScanSubtitle": "Scan for audio files",
"@libraryScanSubtitle": {"description": "Subtitle for scan button"},
"libraryScanSelectFolderFirst": "Select a folder first",
"@libraryScanSelectFolderFirst": {"description": "Message when trying to scan without folder"},
"libraryCleanupMissingFiles": "Cleanup Missing Files",
"@libraryCleanupMissingFiles": {"description": "Button to remove entries for missing files"},
"libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist",
"@libraryCleanupMissingFilesSubtitle": {"description": "Subtitle for cleanup button"},
"libraryClear": "Clear Library",
"@libraryClear": {"description": "Button to clear all library entries"},
"libraryClearSubtitle": "Remove all scanned tracks",
"@libraryClearSubtitle": {"description": "Subtitle for clear button"},
"libraryClearConfirmTitle": "Clear Library",
"@libraryClearConfirmTitle": {"description": "Dialog title for clear confirmation"},
"libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.",
"@libraryClearConfirmMessage": {"description": "Dialog message for clear confirmation"},
"libraryAbout": "About Local Library",
"@libraryAbout": {"description": "Section header for about info"},
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.",
"@libraryAboutDescription": {"description": "Description of local library feature"},
"libraryTracksCount": "{count} tracks",
"@libraryTracksCount": {
"description": "Track count in library",
"placeholders": {
"count": {"type": "int"}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
"placeholders": {
"time": {"type": "String"}
}
},
"libraryLastScannedNever": "Never",
"@libraryLastScannedNever": {"description": "Shown when library has never been scanned"},
"libraryScanning": "Scanning...",
"@libraryScanning": {"description": "Status during scan"},
"libraryScanProgress": "{progress}% of {total} files",
"@libraryScanProgress": {
"description": "Scan progress display",
"placeholders": {
"progress": {"type": "String"},
"total": {"type": "int"}
}
},
"libraryInLibrary": "In Library",
"@libraryInLibrary": {"description": "Badge shown on tracks that exist in local library"},
"libraryRemovedMissingFiles": "Removed {count} missing files from library",
"@libraryRemovedMissingFiles": {
"description": "Snackbar after cleanup",
"placeholders": {
"count": {"type": "int"}
}
},
"libraryCleared": "Library cleared",
"@libraryCleared": {"description": "Snackbar after clearing library"},
"libraryStorageAccessRequired": "Storage Access Required",
"@libraryStorageAccessRequired": {"description": "Dialog title for storage permission"},
"libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.",
"@libraryStorageAccessMessage": {"description": "Dialog message for storage permission"},
"libraryFolderNotExist": "Selected folder does not exist",
"@libraryFolderNotExist": {"description": "Error when folder doesn't exist"},
"librarySourceDownloaded": "Downloaded",
"@librarySourceDownloaded": {"description": "Badge for tracks downloaded via SpotiFLAC"},
"librarySourceLocal": "Local",
"@librarySourceLocal": {"description": "Badge for tracks from local library scan"},
"libraryFilterAll": "All",
"@libraryFilterAll": {"description": "Filter chip - show all library items"},
"libraryFilterDownloaded": "Downloaded",
"@libraryFilterDownloaded": {"description": "Filter chip - show only downloaded items"},
"libraryFilterLocal": "Local",
"@libraryFilterLocal": {"description": "Filter chip - show only local library items"},
"libraryFilterTitle": "Filters",
"@libraryFilterTitle": {"description": "Filter bottom sheet title"},
"libraryFilterReset": "Reset",
"@libraryFilterReset": {"description": "Reset all filters button"},
"libraryFilterApply": "Apply",
"@libraryFilterApply": {"description": "Apply filters button"},
"libraryFilterSource": "Source",
"@libraryFilterSource": {"description": "Filter section - source type"},
"libraryFilterQuality": "Quality",
"@libraryFilterQuality": {"description": "Filter section - audio quality"},
"libraryFilterQualityHiRes": "Hi-Res (24bit)",
"@libraryFilterQualityHiRes": {"description": "Filter option - high resolution audio"},
"libraryFilterQualityCD": "CD (16bit)",
"@libraryFilterQualityCD": {"description": "Filter option - CD quality audio"},
"libraryFilterQualityLossy": "Lossy",
"@libraryFilterQualityLossy": {"description": "Filter option - lossy compressed audio"},
"libraryFilterFormat": "Format",
"@libraryFilterFormat": {"description": "Filter section - file format"},
"libraryFilterDate": "Date Added",
"@libraryFilterDate": {"description": "Filter section - date range"},
"libraryFilterDateToday": "Today",
"@libraryFilterDateToday": {"description": "Filter option - today only"},
"libraryFilterDateWeek": "This Week",
"@libraryFilterDateWeek": {"description": "Filter option - this week"},
"libraryFilterDateMonth": "This Month",
"@libraryFilterDateMonth": {"description": "Filter option - this month"},
"libraryFilterDateYear": "This Year",
"@libraryFilterDateYear": {"description": "Filter option - this year"},
"libraryFilterSort": "Sort",
"@libraryFilterSort": {"description": "Filter section - sort order"},
"libraryFilterSortLatest": "Latest",
"@libraryFilterSortLatest": {"description": "Sort option - newest first"},
"libraryFilterSortOldest": "Oldest",
"@libraryFilterSortOldest": {"description": "Sort option - oldest first"},
"libraryFilterActive": "{count} filter(s) active",
"@libraryFilterActive": {
"description": "Badge showing number of active filters",
"placeholders": {
"count": {"type": "int"}
}
},
"timeJustNow": "Just now",
"@timeJustNow": {"description": "Relative time - less than a minute ago"},
"timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
"@timeMinutesAgo": {
"description": "Relative time - minutes ago",
"placeholders": {
"count": {"type": "int"}
}
},
"timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
"@timeHoursAgo": {
"description": "Relative time - hours ago",
"placeholders": {
"count": {"type": "int"}
}
},
"storageSwitchTitle": "Switch Storage Mode",
"@storageSwitchTitle": {"description": "Dialog title when switching storage mode"},
"storageSwitchToSafTitle": "Switch to SAF Storage?",
"@storageSwitchToSafTitle": {"description": "Dialog title when switching to SAF"},
"storageSwitchToAppTitle": "Switch to App Storage?",
"@storageSwitchToAppTitle": {"description": "Dialog title when switching to app storage"},
"storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.",
"@storageSwitchToSafMessage": {"description": "Explanation when switching to SAF"},
"storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.",
"@storageSwitchToAppMessage": {"description": "Explanation when switching to app storage"},
"storageSwitchExistingDownloads": "Existing Downloads",
"@storageSwitchExistingDownloads": {"description": "Section header for existing downloads info"},
"storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage",
"@storageSwitchExistingDownloadsInfo": {
"description": "Info about existing downloads count",
"placeholders": {
"count": {"type": "int"},
"mode": {"type": "String"}
}
},
"storageSwitchNewDownloads": "New Downloads",
"@storageSwitchNewDownloads": {"description": "Section header for new downloads info"},
"storageSwitchNewDownloadsLocation": "Will be saved to: {location}",
"@storageSwitchNewDownloadsLocation": {
"description": "Shows where new downloads will go",
"placeholders": {
"location": {"type": "String"}
}
},
"storageSwitchContinue": "Continue",
"@storageSwitchContinue": {"description": "Button to proceed with storage switch"},
"storageSwitchSelectFolder": "Select SAF Folder",
"@storageSwitchSelectFolder": {"description": "Button to select SAF folder"},
"storageAppStorage": "App Storage",
"@storageAppStorage": {"description": "Label for app storage mode"},
"storageSafStorage": "SAF Storage",
"@storageSafStorage": {"description": "Label for SAF storage mode"},
"storageModeBadge": "Storage: {mode}",
"@storageModeBadge": {
"description": "Badge showing storage mode for a track",
"placeholders": {
"mode": {"type": "String"}
}
},
"storageStatsTitle": "Storage Statistics",
"@storageStatsTitle": {"description": "Section title for storage stats"},
"storageStatsAppCount": "{count} tracks in App Storage",
"@storageStatsAppCount": {
"description": "Count of tracks in app storage",
"placeholders": {
"count": {"type": "int"}
}
},
"storageStatsSafCount": "{count} tracks in SAF Storage",
"@storageStatsSafCount": {
"description": "Count of tracks in SAF storage",
"placeholders": {
"count": {"type": "int"}
}
},
"storageModeInfo": "Your files are stored in multiple locations",
"@storageModeInfo": {"description": "Info when user has files in both storage modes"},
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
"@tutorialWelcomeTitle": {"description": "Tutorial welcome page title"},
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.",
"@tutorialWelcomeDesc": {"description": "Tutorial welcome page description"},
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL",
"@tutorialWelcomeTip1": {"description": "Tutorial welcome tip 1"},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"@tutorialWelcomeTip2": {"description": "Tutorial welcome tip 2"},
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
"@tutorialWelcomeTip3": {"description": "Tutorial welcome tip 3"},
"tutorialSearchTitle": "Finding Music",
"@tutorialSearchTitle": {"description": "Tutorial search page title"},
"tutorialSearchDesc": "There are two easy ways to find music you want to download.",
"@tutorialSearchDesc": {"description": "Tutorial search page description"},
"tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box",
"@tutorialSearchTip1": {"description": "Tutorial search tip 1"},
"tutorialSearchTip2": "Or type the song name, artist, or album to search",
"@tutorialSearchTip2": {"description": "Tutorial search tip 2"},
"tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages",
"@tutorialSearchTip3": {"description": "Tutorial search tip 3"},
"tutorialDownloadTitle": "Downloading Music",
"@tutorialDownloadTitle": {"description": "Tutorial download page title"},
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.",
"@tutorialDownloadDesc": {"description": "Tutorial download page description"},
"tutorialDownloadTip1": "Tap the download button next to any track to start downloading",
"@tutorialDownloadTip1": {"description": "Tutorial download tip 1"},
"tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)",
"@tutorialDownloadTip2": {"description": "Tutorial download tip 2"},
"tutorialDownloadTip3": "Download entire albums or playlists with one tap",
"@tutorialDownloadTip3": {"description": "Tutorial download tip 3"},
"tutorialLibraryTitle": "Your Library",
"@tutorialLibraryTitle": {"description": "Tutorial library page title"},
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.",
"@tutorialLibraryDesc": {"description": "Tutorial library page description"},
"tutorialLibraryTip1": "View download progress and queue in the Library tab",
"@tutorialLibraryTip1": {"description": "Tutorial library tip 1"},
"tutorialLibraryTip2": "Tap any track to play it with your music player",
"@tutorialLibraryTip2": {"description": "Tutorial library tip 2"},
"tutorialLibraryTip3": "Switch between list and grid view for better browsing",
"@tutorialLibraryTip3": {"description": "Tutorial library tip 3"},
"tutorialExtensionsTitle": "Extensions",
"@tutorialExtensionsTitle": {"description": "Tutorial extensions page title"},
"tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.",
"@tutorialExtensionsDesc": {"description": "Tutorial extensions page description"},
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
"@tutorialExtensionsTip1": {"description": "Tutorial extensions tip 1"},
"tutorialExtensionsTip2": "Add new download providers or search sources",
"@tutorialExtensionsTip2": {"description": "Tutorial extensions tip 2"},
"tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features",
"@tutorialExtensionsTip3": {"description": "Tutorial extensions tip 3"},
"tutorialSettingsTitle": "Customize Your Experience",
"@tutorialSettingsTitle": {"description": "Tutorial settings page title"},
"tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.",
"@tutorialSettingsDesc": {"description": "Tutorial settings page description"},
"tutorialSettingsTip1": "Change download location and folder organization",
"@tutorialSettingsTip1": {"description": "Tutorial settings tip 1"},
"tutorialSettingsTip2": "Set default audio quality and format preferences",
"@tutorialSettingsTip2": {"description": "Tutorial settings tip 2"},
"tutorialSettingsTip3": "Customize app theme and appearance",
"@tutorialSettingsTip3": {"description": "Tutorial settings tip 3"},
"tutorialReadyMessage": "You're all set! Start downloading your favorite music now.",
"@tutorialReadyMessage": {"description": "Tutorial completion message"},
"tutorialExample": "EXAMPLE",
"@tutorialExample": {"description": "Example label in tutorial"},
"libraryForceFullScan": "Force Full Scan",
"@libraryForceFullScan": {"description": "Button to force a complete rescan of library"},
"libraryForceFullScanSubtitle": "Rescan all files, ignoring cache",
"@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"},
"cleanupOrphanedDownloads": "Cleanup Orphaned Downloads",
"@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"},
"cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist",
"@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"},
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
"@cleanupOrphanedDownloadsResult": {
"description": "Snackbar after orphan cleanup",
"placeholders": {
"count": {"type": "int"}
}
},
"cleanupOrphanedDownloadsNone": "No orphaned entries found",
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"},
"cacheTitle": "Storage & Cache",
"@cacheTitle": {"description": "Cache management page title"},
"cacheSummaryTitle": "Cache overview",
"@cacheSummaryTitle": {"description": "Heading for cache summary card"},
"cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.",
"@cacheSummarySubtitle": {"description": "Helper text for cache summary card"},
"cacheEstimatedTotal": "Estimated cache usage: {size}",
"@cacheEstimatedTotal": {
"description": "Total cache size shown in summary",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheSectionStorage": "Cached Data",
"@cacheSectionStorage": {"description": "Section header for cache entries"},
"cacheSectionMaintenance": "Maintenance",
"@cacheSectionMaintenance": {"description": "Section header for cleanup actions"},
"cacheAppDirectory": "App cache directory",
"@cacheAppDirectory": {"description": "Cache item title for app cache directory"},
"cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.",
"@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"},
"cacheTempDirectory": "Temporary directory",
"@cacheTempDirectory": {"description": "Cache item title for temporary files directory"},
"cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.",
"@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"},
"cacheCoverImage": "Cover image cache",
"@cacheCoverImage": {"description": "Cache item title for persistent cover images"},
"cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.",
"@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"},
"cacheLibraryCover": "Library cover cache",
"@cacheLibraryCover": {"description": "Cache item title for local library cover art images"},
"cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.",
"@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"},
"cacheExploreFeed": "Explore feed cache",
"@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"},
"cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.",
"@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"},
"cacheTrackLookup": "Track lookup cache",
"@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"},
"cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.",
"@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"},
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
"@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"},
"cacheNoData": "No cached data",
"@cacheNoData": {"description": "Label when cache category has no data"},
"cacheSizeWithFiles": "{size} in {count} files",
"@cacheSizeWithFiles": {
"description": "Cache size and file count",
"placeholders": {
"size": {"type": "String"},
"count": {"type": "int"}
}
},
"cacheSizeOnly": "{size}",
"@cacheSizeOnly": {
"description": "Cache size only",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheEntries": "{count} entries",
"@cacheEntries": {
"description": "Track cache entry count",
"placeholders": {
"count": {"type": "int"}
}
},
"cacheClearSuccess": "Cleared: {target}",
"@cacheClearSuccess": {
"description": "Snackbar after clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearConfirmTitle": "Clear cache?",
"@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"},
"cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.",
"@cacheClearConfirmMessage": {
"description": "Dialog message before clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearAllConfirmTitle": "Clear all cache?",
"@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"},
"cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.",
"@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"},
"cacheClearAll": "Clear all cache",
"@cacheClearAll": {"description": "Button label to clear all caches"},
"cacheCleanupUnused": "Cleanup unused data",
"@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"},
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
"@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"},
"cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries",
"@cacheCleanupResult": {
"description": "Snackbar after unused data cleanup",
"placeholders": {
"downloadCount": {"type": "int"},
"libraryCount": {"type": "int"}
}
},
"cacheRefreshStats": "Refresh stats",
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"},
"trackSaveCoverArt": "Save Cover Art",
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
"trackSaveCoverArtSubtitle": "Save album art as .jpg file",
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
"trackSaveLyrics": "Save Lyrics (.lrc)",
"@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",
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
"trackReEnrichOnlineSubtitle": "Search metadata online and embed into file",
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
"trackEditMetadata": "Edit Metadata",
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
"trackCoverSaved": "Cover art saved to {fileName}",
"@trackCoverSaved": {
"description": "Snackbar after cover art saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackCoverNoSource": "No cover art source available",
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
"trackLyricsSaved": "Lyrics saved to {fileName}",
"@trackLyricsSaved": {
"description": "Snackbar after lyrics saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackReEnrichProgress": "Re-enriching metadata...",
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
"trackReEnrichSearching": "Searching metadata online...",
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
"trackReEnrichSuccess": "Metadata re-enriched successfully",
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
"trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed",
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
"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": { "@aboutSupport": {
"description": "Section for support/donation links" "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": "App",
"@aboutApp": { "@aboutApp": {
"description": "Section for app info" "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
+3871 -2859
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
+1041 -37
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
-8
View File
@@ -548,14 +548,6 @@
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "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": "App",
"@aboutApp": { "@aboutApp": {
"description": "Section for app info" "description": "Section for app info"
+1316 -59
View File
File diff suppressed because it is too large Load Diff
+1038 -34
View File
File diff suppressed because it is too large Load Diff
+1033 -29
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