Compare commits

...

355 Commits

Author SHA1 Message Date
zarzet abc599d7f9 refactor: migrate queue_tab cover resolver to shared service, add supporter 2026-02-11 12:31:47 +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
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
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
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
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
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
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
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
zarzet 8c201b5b4a feat: add Cloud Save settings page (UI only)
- Add cloud upload settings to settings model (provider, URL, credentials, path)
- Create CloudSettingsPage with WebDAV and SFTP provider options
- Add Cloud Save menu item in main settings
- Add localization strings for cloud settings
- Actual upload implementation will come in v3.4.0

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

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

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

- Also fixes playlists with >25 tracks

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

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

- Add _embedMetadataToOpus() in download queue provider

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

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

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

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

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

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

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

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

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

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

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

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

- Also fixes playlists with >25 tracks

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

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

- Add _embedMetadataToOpus() in download queue provider

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

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

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

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

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

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

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

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

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

Uses FlutterShellArgs --enable-impeller=false which is the only
reliable method since AndroidManifest meta-data is broken in
Flutter 3.27+ (flutter/flutter#160595)
2026-01-24 09:02:32 +07:00
zarzet 1aa12c5857 feat: add search filter bar for extension custom search
- Add SearchFilter struct in Go backend and Dart
- Add filters array to SearchBehaviorConfig manifest
- Add selectedSearchFilter state to TrackProvider
- Add filter bar UI with FilterChips below search bar
- Filter bar only shows when search results exist or loading
- Preserve selectedSearchFilter during customSearch loading
- Pass filter option to extension customSearch
2026-01-24 08:50:41 +07:00
Amonoman 80707fc438 Update about_page.dart
i changed it becouse "Max" is not my username
2026-01-23 20:34:43 +01:00
Zarz Eleutherius ff121dfeb8 New translations app_en.arb (Indonesian) 2026-01-23 11:53:28 +07:00
zarzet c3aa6a441b fix: update Telegram community link in About page 2026-01-22 07:58:55 +07:00
Zarz Eleutherius 496d32e35b New translations app_en.arb (Turkish) 2026-01-22 07:34:38 +07:00
Zarz Eleutherius 291fa58757 New translations app_en.arb (Hindi) 2026-01-22 07:34:37 +07:00
Zarz Eleutherius eddbc2f986 New translations app_en.arb (Indonesian) 2026-01-22 07:34:36 +07:00
Zarz Eleutherius 81b8281d2c New translations app_en.arb (Chinese Traditional) 2026-01-22 07:34:35 +07:00
Zarz Eleutherius 57f87d9a4c New translations app_en.arb (Chinese Simplified) 2026-01-22 07:34:33 +07:00
Zarz Eleutherius c9d0c57d86 New translations app_en.arb (Russian) 2026-01-22 07:34:32 +07:00
Zarz Eleutherius 54ab5a9243 New translations app_en.arb (Portuguese) 2026-01-22 07:34:31 +07:00
Zarz Eleutherius 17b6b27cd7 New translations app_en.arb (Dutch) 2026-01-22 07:34:30 +07:00
Zarz Eleutherius ed131ca1fd New translations app_en.arb (Korean) 2026-01-22 07:34:29 +07:00
Zarz Eleutherius 190d65cdee New translations app_en.arb (Japanese) 2026-01-22 07:34:28 +07:00
Zarz Eleutherius dbf2e337f0 New translations app_en.arb (German) 2026-01-22 07:34:27 +07:00
Zarz Eleutherius 12e76bed4f New translations app_en.arb (Spanish) 2026-01-22 07:34:26 +07:00
Zarz Eleutherius e00db80dae New translations app_en.arb (French) 2026-01-22 07:34:24 +07:00
Zarz Eleutherius 5de0aa8145 Update source file app_en.arb 2026-01-22 07:34:20 +07:00
zarzet 91ffb25027 chore: bump version to 26.2.1+65 (new year.month.day format) 2026-01-22 07:06:15 +07:00
zarzet 6bcbdfedf0 Merge branch 'main' into dev 2026-01-22 07:04:03 +07:00
zarzet 3f42128cb9 fix: update Telegram community link and VirusTotal hash for v3.2.1 2026-01-22 04:50:46 +07:00
zarzet ccb8f98df5 fix: use --data-urlencode for Telegram message to handle special chars (+, &) 2026-01-22 04:26:02 +07:00
zarzet 591a597333 Merge branch 'dev'
# Conflicts:
#	.github/workflows/release.yml
#	README.md
2026-01-22 04:01:24 +07:00
zarzet 6388f3a5b8 perf: optimize providers, caching, and reduce rebuilds
- Cache SharedPreferences.getInstance() in providers (settings, theme, recent_access)
- Pre-compute download counts in queue provider to avoid repeated filtering
- Add identical() caching for RecentAccessView in HomeTab
- Use selective watching for exploreProvider (sections, greeting, isLoading only)
- Move isYTMusicQuickPicks computation to ExploreSection.fromJson()
- Hoist static RegExp patterns to avoid repeated compilation
- Use batch operations for iOS path migration in history_database
- Replace containsKey+lookup with single lookup in palette_service
- Pre-compute lowercase strings outside filter loops in logger
- Fix _isLoaded race condition in DownloadHistoryNotifier
2026-01-22 03:56:47 +07:00
Zarz Eleutherius 22f52f4af2 New translations app_en.arb (Turkish) 2026-01-22 02:27:30 +07:00
Zarz Eleutherius ceaaff8c9b New translations app_en.arb (Hindi) 2026-01-22 02:27:29 +07:00
Zarz Eleutherius a318495046 New translations app_en.arb (Indonesian) 2026-01-22 02:27:27 +07:00
Zarz Eleutherius 8ffc6d3821 New translations app_en.arb (Chinese Traditional) 2026-01-22 02:27:26 +07:00
Zarz Eleutherius 2036e46da0 New translations app_en.arb (Chinese Simplified) 2026-01-22 02:27:25 +07:00
Zarz Eleutherius b82000e87c New translations app_en.arb (Russian) 2026-01-22 02:27:24 +07:00
Zarz Eleutherius 144906fd8f New translations app_en.arb (Portuguese) 2026-01-22 02:27:23 +07:00
Zarz Eleutherius 8a109e9013 New translations app_en.arb (Dutch) 2026-01-22 02:27:21 +07:00
Zarz Eleutherius ba05f6b470 New translations app_en.arb (Korean) 2026-01-22 02:27:20 +07:00
Zarz Eleutherius 2f80ae7e84 New translations app_en.arb (Japanese) 2026-01-22 02:27:19 +07:00
Zarz Eleutherius e248fef130 New translations app_en.arb (German) 2026-01-22 02:27:18 +07:00
Zarz Eleutherius 174724ddd3 New translations app_en.arb (Spanish) 2026-01-22 02:27:17 +07:00
Zarz Eleutherius 730945d892 New translations app_en.arb (French) 2026-01-22 02:27:15 +07:00
Zarz Eleutherius 4abdce8c58 Update source file app_en.arb 2026-01-22 02:27:13 +07:00
zarzet 55b75dc48d chore: bump version to 3.2.1+64 2026-01-22 02:17:47 +07:00
zarzet f6cea1a683 feat: v3.2.1 - lyrics improvements, pause/resume, folder options
- Add instrumental track detection (shows 'Instrumental track' instead of 'not available')
- Add embed lyrics button in Track Info (preserves synced timestamps)
- Add pause/resume button next to 'Downloading' header in History
- Add Artist/Album + Singles folder structure option
- Fix multi-artist lyrics search (try primary artist first)
- Fix lyrics display stripping metadata tags ([ti:], [ar:], [by:])
- Skip lyrics embedding for instrumental tracks during download
2026-01-22 02:15:43 +07:00
zarzet 8d205600b8 fix: iOS path migration, local greeting timezone, ICU plural warnings
- iOS: Auto-migrate file paths when container UUID changes after app update
- Greeting: Use device local time instead of extension response
- i18n: Fix 16 ICU plural syntax warnings in Spanish and Portuguese
2026-01-22 00:48:45 +07:00
zarzet aa35f60fad fix: fallback to index+1 for Deezer track position when API returns 0 2026-01-21 16:33:30 +07:00
zarzet b627ae1874 fix: handle CRLF in changelog extraction for Telegram 2026-01-21 16:23:19 +07:00
Zarz Eleutherius 0d98ada479 New translations app_en.arb (Turkish) 2026-01-21 02:22:48 +07:00
Zarz Eleutherius 5d4fc10ab7 New translations app_en.arb (Hindi) 2026-01-21 02:22:46 +07:00
Zarz Eleutherius e37dfeb080 New translations app_en.arb (Indonesian) 2026-01-21 02:22:45 +07:00
Zarz Eleutherius eddae2a9dd New translations app_en.arb (Chinese Traditional) 2026-01-21 02:22:44 +07:00
Zarz Eleutherius 6bd7eec615 New translations app_en.arb (Chinese Simplified) 2026-01-21 02:22:43 +07:00
Zarz Eleutherius b240e91290 New translations app_en.arb (Russian) 2026-01-21 02:22:42 +07:00
Zarz Eleutherius 4e0149df29 New translations app_en.arb (Portuguese) 2026-01-21 02:22:41 +07:00
Zarz Eleutherius 065872e686 New translations app_en.arb (Dutch) 2026-01-21 02:22:39 +07:00
Zarz Eleutherius 7ab0f5b7c8 New translations app_en.arb (Korean) 2026-01-21 02:22:38 +07:00
Zarz Eleutherius fd31682242 New translations app_en.arb (Japanese) 2026-01-21 02:22:37 +07:00
Zarz Eleutherius 56c8b62fcf New translations app_en.arb (German) 2026-01-21 02:22:36 +07:00
Zarz Eleutherius c3f879346a New translations app_en.arb (Spanish) 2026-01-21 02:22:35 +07:00
Zarz Eleutherius 6da65ed033 New translations app_en.arb (French) 2026-01-21 02:22:34 +07:00
Zarz Eleutherius 553c6b6c4a Update source file app_en.arb 2026-01-21 02:22:31 +07:00
zarzet ac5f74a48f feat: convert GitHub Markdown to Telegram format in release notification 2026-01-20 10:12:18 +07:00
zarzet 2d22d85c49 feat: improve Telegram notification - upload IPA, remove redundant links, increase changelog limit 2026-01-20 10:08:53 +07:00
zarzet 3edfe8e8bb docs: update README and release workflow 2026-01-20 09:56:38 +07:00
zarzet 6f9722e05b Merge dev: update screenshots, funding, and VirusTotal hash 2026-01-20 05:58:06 +07:00
zarzet 066d35967e Merge branch 'dev' 2026-01-20 04:55:27 +07:00
zarzet 2b932cff70 Merge branch 'dev' 2026-01-20 04:16:26 +07:00
Zarz Eleutherius a32487ad88 New translations app_en.arb (Hindi) 2026-01-20 02:16:58 +07:00
Zarz Eleutherius bd4946db37 New translations app_en.arb (Indonesian) 2026-01-20 02:16:57 +07:00
Zarz Eleutherius 69f143dd9d New translations app_en.arb (Chinese Traditional) 2026-01-20 02:16:56 +07:00
Zarz Eleutherius 15408bfa1c New translations app_en.arb (Chinese Simplified) 2026-01-20 02:16:55 +07:00
Zarz Eleutherius edc715021d New translations app_en.arb (Russian) 2026-01-20 02:16:54 +07:00
Zarz Eleutherius 392472b027 New translations app_en.arb (Portuguese) 2026-01-20 02:16:53 +07:00
Zarz Eleutherius 69741fa47c New translations app_en.arb (Dutch) 2026-01-20 02:16:52 +07:00
Zarz Eleutherius 484720bcda New translations app_en.arb (Korean) 2026-01-20 02:16:51 +07:00
Zarz Eleutherius f3cc51fb06 New translations app_en.arb (Japanese) 2026-01-20 02:16:50 +07:00
Zarz Eleutherius 452ea7084a New translations app_en.arb (German) 2026-01-20 02:16:49 +07:00
Zarz Eleutherius bba059fc44 New translations app_en.arb (Spanish) 2026-01-20 02:16:48 +07:00
Zarz Eleutherius 3f75cace2b New translations app_en.arb (French) 2026-01-20 02:16:47 +07:00
zarzet 556c0e1db2 Merge dev into main 2026-01-18 03:21:02 +07:00
zarzet 9897d3102e Merge branch 'dev' into main 2026-01-17 10:06:38 +07:00
zarzet 88dfb88bcc docs: add FAQ about mobile app size (FFmpeg bundled) 2026-01-17 07:11:07 +07:00
zarzet 75bfe9b3bf docs: update VirusTotal link for v3.1.0 2026-01-17 06:09:50 +07:00
zarzet f4fe74f972 docs: add Crowdin translation badge to README 2026-01-16 07:08:49 +07:00
166 changed files with 58528 additions and 15248 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
+34 -41
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.21" go-version: "1.25"
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.21" go-version: "1.25"
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') }}
@@ -194,7 +194,7 @@ jobs:
working-directory: go_backend working-directory: go_backend
run: | run: |
mkdir -p ../ios/Frameworks mkdir -p ../ios/Frameworks
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework . gomobile bind -target=ios -tags ios -o ../ios/Frameworks/Gobackend.xcframework .
env: env:
CGO_ENABLED: 1 CGO_ENABLED: 1
@@ -249,23 +249,6 @@ jobs:
channel: "stable" channel: "stable"
cache: true cache: true
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
- name: Use iOS pubspec with FFmpeg plugin
run: |
cp pubspec.yaml pubspec_android_backup.yaml
cp pubspec_ios.yaml pubspec.yaml
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
# Swap FFmpeg service for iOS
- name: Use iOS FFmpeg service
run: |
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
# Update class name in the swapped file
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
echo "Swapped to iOS FFmpeg service"
- name: Get Flutter dependencies - name: Get Flutter dependencies
run: flutter pub get run: flutter pub get
@@ -312,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
@@ -325,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
@@ -355,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
@@ -402,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 }}
@@ -420,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
@@ -441,7 +424,11 @@ jobs:
VERSION_NUM=${VERSION#v} VERSION_NUM=${VERSION#v}
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead) # Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
FULL_CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md | sed '/^---$/d') # Use tr -d '\r' to handle CRLF line endings from Windows
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
if [ -z "$FULL_CHANGELOG" ]; then if [ -z "$FULL_CHANGELOG" ]; then
CHANGELOG="See release notes on GitHub for details." CHANGELOG="See release notes on GitHub for details."
@@ -451,7 +438,9 @@ jobs:
# - `code` → <code>code</code> # - `code` → <code>code</code>
# - ### Header → <b>Header</b> # - ### Header → <b>Header</b>
# - Escape HTML special chars first # - Escape HTML special chars first
# - Remove > blockquote prefix
CHANGELOG=$(echo "$FULL_CHANGELOG" | \ CHANGELOG=$(echo "$FULL_CHANGELOG" | \
sed 's/^> //' | \
sed 's/&/\&amp;/g' | \ sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \ sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \ sed 's/>/\&gt;/g' | \
@@ -473,6 +462,8 @@ jobs:
fi fi
echo "$CHANGELOG" > /tmp/changelog.txt echo "$CHANGELOG" > /tmp/changelog.txt
echo "DEBUG: Final changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel - name: Send to Telegram Channel
env: env:
@@ -499,11 +490,13 @@ jobs:
MESSAGE=$(cat /tmp/telegram_message.txt) MESSAGE=$(cat /tmp/telegram_message.txt)
# Send message first (using HTML parse mode) # Send message first (using HTML parse mode)
# Use --data-urlencode for proper encoding of special chars (+, &, etc.)
# Use || true to ensure file uploads continue even if message fails
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHANNEL_ID}" \ --data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
-d text="${MESSAGE}" \ --data-urlencode "text=${MESSAGE}" \
-d parse_mode="HTML" \ --data-urlencode "parse_mode=HTML" \
-d disable_web_page_preview="true" --data-urlencode "disable_web_page_preview=true" || true
# Upload arm64 APK to channel # Upload arm64 APK to channel
if [ -f "$ARM64_APK" ]; then if [ -f "$ARM64_APK" ]; then
+1
View File
@@ -72,3 +72,4 @@ flutter_*.log
# Development tools # Development tools
tool/ tool/
.claude/settings.local.json
+564 -32
View File
@@ -1,5 +1,567 @@
# Changelog # Changelog
## [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
### Added
- **Clear All Queue Button**: Cancel all queued downloads with one tap ([#96](https://github.com/zarzet/SpotiFLAC-Mobile/issues/96))
- **IDHS Fallback**: Fallback link resolver when SongLink fails (rate limited 8 req/min)
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
- **Album/Playlist Search**: Deezer search now includes albums and playlists
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re\*Index.(ot_inc))
- **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
### Changed
- **Amazon Download API**: Switched to AfkarXYZ API
- **Qobuz Download API**: Added Jumo API as fallback
- **Search Results**: Reduced artist limit from 5 to 2
### Fixed
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/zarzet/SpotiFLAC-Mobile/issues/108))
- **Opus Cover Art**: Implemented METADATA_BLOCK_PICTURE for proper cover embedding
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/zarzet/SpotiFLAC-Mobile/issues/112))
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/zarzet/SpotiFLAC-Mobile/issues/110))
---
## [3.2.1] - 2026-01-22
### Added
- **Artist/Album + Singles Folder Structure**: Singles go inside artist folder (`Artist/Album/`, `Artist/Singles/`)
- **Embed Lyrics Button**: Manually embed online lyrics into tracks from Track Info screen (preserves synced timestamps)
- **Pause/Resume Button**: Added pause and resume controls next to "Downloading" header in History screen
- **Instrumental Detection**: Tracks marked as instrumental on lrclib.net now show "Instrumental track" instead of "Lyrics not available"
### Fixed
- **Lyrics**: Multi-artist tracks now search by primary artist first, then full string
- **Lyrics**: Metadata tags (`[ti:...]`, `[ar:...]`, `[by:...]`) no longer shown in display
- **Lyrics**: Embed button now correctly appears for tracks with online lyrics
- **Lyrics**: Manual embed preserves original timestamps instead of plain text
- **iOS**: Fixed "File not found" after 3.1.x → 3.2.0 update (container UUID migration)
- **Home Feed**: Greeting now uses device local time
- **Deezer**: Track position fallback to index+1 when API returns 0
- **Localization**: Fixed 16 ICU plural syntax warnings in Spanish & Portuguese
### Performance
- **Home Feed**: Precomputed Quick Picks section flag and reduced per-page allocations; explore state now watched by field to cut rebuilds
- **Home Recent**: Cached recent-access aggregation and limited list allocations for recent downloads
- **Settings/Theme/Recent**: Cached SharedPreferences instance to avoid repeated `getInstance()` calls
- **History/DB**: Batched iOS path migration updates to reduce write overhead
- **Download Queue**: Reduced polling allocations and avoided double-load scheduling for history
- **Misc**: Precompiled regex in share intent, update dialog, extensions error parsing, log analysis, and LRC cleanup; faster palette cache hits and log filtering
---
## [3.2.0] - 2026-01-22 ## [3.2.0] - 2026-01-22
> **Note:** Starting from v3.2.0, changelogs will be concise. > **Note:** Starting from v3.2.0, changelogs will be concise.
@@ -51,31 +613,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
@@ -84,7 +641,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
@@ -93,16 +649,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`
@@ -163,7 +716,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
@@ -188,7 +740,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
@@ -198,56 +749,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
@@ -260,7 +801,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
@@ -272,12 +812,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
@@ -297,24 +835,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
@@ -325,12 +859,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
@@ -397,4 +929,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)
+27 -22
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](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/3257155286587a3596ad5d4380d4576a684aa3d37a5b19a615914a845fbe57f3) [![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.
@@ -52,19 +43,10 @@ Want to create your own extension? Check out the [Extension Development Guide](h
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC) ### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
> **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored.
## 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/spotiflacchat">
<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
@@ -86,6 +68,14 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
**Q: Why is download not working in my country?** **Q: Why is download not working in my country?**
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region. A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
### Want to support SpotiFLAC-Mobile?
_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)
## Disclaimer ## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement. This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
@@ -100,3 +90,18 @@ You are solely responsible for:
3. Any legal consequences resulting from the misuse of this tool. 3. Any legal consequences resulting from the misuse of this tool.
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use. 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)
- **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]
>
> **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")
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
+74 -2
View File
@@ -5,6 +5,7 @@
-keep class io.flutter.view.** { *; } -keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; } -keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; } -keep class io.flutter.plugins.** { *; }
-keep class io.flutter.embedding.** { *; }
# Ignore missing Play Core classes (not used, but referenced by Flutter) # Ignore missing Play Core classes (not used, but referenced by Flutter)
-dontwarn com.google.android.play.core.splitcompat.** -dontwarn com.google.android.play.core.splitcompat.**
@@ -14,13 +15,22 @@
# Ignore missing javax.xml.stream (not used on Android) # Ignore missing javax.xml.stream (not used on Android)
-dontwarn javax.xml.stream.** -dontwarn javax.xml.stream.**
# Go backend (gobackend.aar) # Go backend (gobackend.aar) - CRITICAL for release builds
-keep class gobackend.** { *; } -keep class gobackend.** { *; }
-keep class go.** { *; } -keep class go.** { *; }
-keep interface gobackend.** { *; }
-keepclassmembers class gobackend.** { *; }
# Go mobile binding internals
-keep class org.golang.** { *; }
-dontwarn org.golang.**
# 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.**
@@ -30,15 +40,77 @@
native <methods>; native <methods>;
} }
# Kotlin coroutines # Kotlin coroutines - expanded rules
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** { -keepclassmembers class kotlinx.coroutines.** {
volatile <fields>; volatile <fields>;
} }
-keepclassmembernames class kotlinx.** {
volatile <fields>;
}
-dontwarn kotlinx.coroutines.**
# Kotlin serialization
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
-dontwarn kotlin.**
-keep class kotlin.** { *; }
-keep class kotlin.Metadata { *; }
# Keep MainActivity and related classes
-keep class com.zarz.spotiflac.** { *; }
# Prevent R8 from removing metadata # Prevent R8 from removing metadata
-keepattributes *Annotation* -keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
-keepattributes Signature -keepattributes Signature
-keepattributes Exceptions -keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes EnclosingMethod
# JSON parsing (used by Go backend responses)
-keep class org.json.** { *; }
# Shared Preferences
-keep class androidx.datastore.** { *; }
-dontwarn androidx.datastore.**
# Flutter Plugins - CRITICAL: Prevent R8 from removing plugin implementations
# Path Provider
-keep class io.flutter.plugins.pathprovider.** { *; }
-keep class dev.flutter.pigeon.** { *; }
# Local Notifications
-keep class com.dexterous.** { *; }
-keep class com.dexterous.flutterlocalnotifications.** { *; }
# Receive Sharing Intent
-keep class com.kasem.receive_sharing_intent.** { *; }
# Permission Handler
-keep class com.baseflow.permissionhandler.** { *; }
# File Picker
-keep class com.mr.flutter.plugin.filepicker.** { *; }
# URL Launcher
-keep class io.flutter.plugins.urllauncher.** { *; }
# Share Plus
-keep class dev.fluttercommunity.plus.share.** { *; }
# Device Info Plus
-keep class dev.fluttercommunity.plus.device_info.** { *; }
# Open File
-keep class com.crazecoder.openfile.** { *; }
# Sqflite
-keep class com.tekartik.sqflite.** { *; }
# Dynamic Color
-keep class io.material.** { *; }
# Keep all Flutter plugin registrants
-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
-keep class ** extends io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
+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")
-335
View File
@@ -1,335 +0,0 @@
import 'dart:io';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg');
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
class FFmpegServiceIOS {
/// Execute FFmpeg command and return result
static Future<FFmpegResultIOS> _execute(String command) async {
try {
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
final output = await session.getOutput() ?? '';
return FFmpegResultIOS(
success: ReturnCode.isSuccess(returnCode),
returnCode: returnCode?.getValue() ?? -1,
output: output,
);
} catch (e) {
_log.e('FFmpeg execute error: $e');
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
}
}
/// Convert M4A (DASH segments) to FLAC
static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = inputPath.replaceAll('.m4a', '.flac');
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
final result = await _execute(command);
if (result.success) {
try {
await File(inputPath).delete();
} catch (_) {}
return outputPath;
}
_log.e('M4A to FLAC conversion failed: ${result.output}');
return null;
}
/// Convert FLAC to MP3
/// If deleteOriginal is true, deletes the FLAC file after conversion
static Future<String?> convertFlacToMp3(
String inputPath, {
String bitrate = '320k',
bool deleteOriginal = true,
}) async {
// Convert in same folder, just change extension
final outputPath = inputPath.replaceAll('.flac', '.mp3');
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
final result = await _execute(command);
if (result.success) {
// Delete original FLAC if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath;
}
_log.e('FLAC to MP3 conversion failed: ${result.output}');
return null;
}
/// Convert FLAC to M4A
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
final dir = File(inputPath).parent.path;
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}M4A';
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
String command;
if (codec == 'alac') {
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
} else {
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
}
final result = await _execute(command);
if (result.success) return outputPath;
_log.e('FLAC to M4A conversion failed: ${result.output}');
return null;
}
/// Embed cover art to FLAC file
static Future<String?> embedCover(String flacPath, String coverPath) async {
final tempOutput = '$flacPath.tmp';
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
final result = await _execute(command);
if (result.success) {
try {
await File(flacPath).delete();
await File(tempOutput).rename(flacPath);
return flacPath;
} catch (e) {
_log.e('Failed to replace file after cover embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) await tempFile.delete();
} catch (_) {}
_log.e('Cover embed failed: ${result.output}');
return null;
}
/// Embed metadata and cover art to FLAC file
/// Returns the file path on success, null on failure
static Future<String?> embedMetadata({
required String flacPath,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempOutput = '$flacPath.tmp';
// Construct command
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" ');
// Add cover input if available
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
// Map audio stream
cmdBuffer.write('-map 0:a ');
// Map cover stream if available
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v copy ');
cmdBuffer.write('-disposition:v attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
// Copy audio codec (don't re-encode)
cmdBuffer.write('-c:a copy ');
// Add text metadata
if (metadata != null) {
metadata.forEach((key, value) {
// Sanitize value: escape double quotes
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('"$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg command: $command');
final result = await _execute(command);
if (result.success) {
try {
await File(flacPath).delete();
await File(tempOutput).rename(flacPath);
return flacPath;
} catch (e) {
_log.e('Failed to replace file after metadata embed: $e');
return null;
}
}
// Clean up temp file if exists
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
_log.e('Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Embed metadata and cover art to MP3 file using ID3v2 tags
/// Returns the file path on success, null on failure
static Future<String?> embedMetadataToMp3({
required String mp3Path,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempOutput = '$mp3Path.tmp';
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" ');
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v:0 copy ');
cmdBuffer.write('-id3v2_version 3 ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg MP3 embed command: $command');
final result = await _execute(command);
if (result.success) {
try {
await File(mp3Path).delete();
await File(tempOutput).rename(mp3Path);
_log.d('MP3 metadata embedded successfully');
return mp3Path;
} catch (e) {
_log.e('Failed to replace MP3 file after metadata embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
final id3Map = <String, String>{};
for (final entry in vorbisMetadata.entries) {
final key = entry.key.toUpperCase();
final value = entry.value;
// Map Vorbis comments to ID3v2 frame names
switch (key) {
case 'TITLE':
id3Map['title'] = value;
break;
case 'ARTIST':
id3Map['artist'] = value;
break;
case 'ALBUM':
id3Map['album'] = value;
break;
case 'ALBUMARTIST':
id3Map['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
id3Map['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
id3Map['disc'] = value;
break;
case 'DATE':
case 'YEAR':
id3Map['date'] = value;
break;
case 'ISRC':
id3Map['TSRC'] = value; // ID3v2 ISRC frame
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value;
break;
default:
// Pass through other tags as-is
id3Map[key.toLowerCase()] = value;
}
}
return id3Map;
}
/// Check if FFmpeg is available
static Future<bool> isAvailable() async {
try {
final session = await FFmpegKit.execute('-version');
final returnCode = await session.getReturnCode();
return ReturnCode.isSuccess(returnCode);
} catch (e) {
return false;
}
}
/// Get FFmpeg version info
static Future<String?> getVersion() async {
try {
final session = await FFmpegKit.execute('-version');
return await session.getOutput();
} catch (e) {
return null;
}
}
}
class FFmpegResultIOS {
final bool success;
final int returnCode;
final String output;
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
}
+428 -406
View File
File diff suppressed because it is too large Load Diff
+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
+464 -112
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,11 +290,38 @@ type deezerPlaylistFull struct {
} `json:"tracks"` } `json:"tracks"`
} }
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download 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) (*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\n", query, trackLimit, artistLimit)
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit) albumLimit := 5
playlistLimit := 5
if filter != "" {
switch filter {
case "track":
trackLimit = 50
artistLimit = 0
albumLimit = 0
playlistLimit = 0
case "artist":
trackLimit = 0
artistLimit = 20
albumLimit = 0
playlistLimit = 0
case "album":
trackLimit = 0
artistLimit = 0
albumLimit = 20
playlistLimit = 0
case "playlist":
trackLimit = 0
artistLimit = 0
albumLimit = 0
playlistLimit = 20
}
}
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d:%d:%d:%s", query, trackLimit, artistLimit, albumLimit, playlistLimit, filter)
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
@@ -197,81 +332,202 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
result := &SearchAllResult{ result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0, trackLimit), Tracks: make([]TrackMetadata, 0, trackLimit),
Artists: make([]SearchArtistResult, 0, artistLimit), Artists: make([]SearchArtistResult, 0, artistLimit),
Albums: make([]SearchAlbumResult, 0, albumLimit),
Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
} }
// Search tracks - NO ISRC fetch for performance 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)
var trackResp struct { var trackResp struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
Error *struct { Error *struct {
Type string `json:"type"` Type string `json:"type"`
Message string `json:"message"` Message string `json:"message"`
Code int `json:"code"` Code int `json:"code"`
} `json:"error"` } `json:"error"`
} }
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil { if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
GoLog("[Deezer] Track search failed: %v\n", err) GoLog("[Deezer] Track search failed: %v\n", err)
return nil, fmt.Errorf("deezer track search failed: %w", err) return nil, fmt.Errorf("deezer track search failed: %w", err)
} }
if trackResp.Error != nil { if trackResp.Error != nil {
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message) GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code) return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
} }
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data)) GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
for _, track := range trackResp.Data { for _, track := range trackResp.Data {
result.Tracks = append(result.Tracks, c.convertTrack(track)) result.Tracks = append(result.Tracks, c.convertTrack(track))
}
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
var artistResp struct {
Data []deezerArtist `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
if artistResp.Error != nil {
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
} else {
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
for _, artist := range artistResp.Data {
result.Artists = append(result.Artists, SearchArtistResult{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: c.getBestArtistImage(artist),
Followers: artist.NbFan,
Popularity: 0,
})
}
} }
} else {
GoLog("[Deezer] Artist search failed: %v\n", err)
} }
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists)) if artistLimit > 0 {
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
var artistResp struct {
Data []deezerArtist `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
if artistResp.Error != nil {
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
} else {
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
for _, artist := range artistResp.Data {
result.Artists = append(result.Artists, SearchArtistResult{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: c.getBestArtistImage(artist),
Followers: artist.NbFan,
Popularity: 0,
})
}
}
} else {
GoLog("[Deezer] Artist search failed: %v\n", err)
}
}
if albumLimit > 0 {
albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit)
GoLog("[Deezer] Fetching albums from: %s\n", albumURL)
var albumResp struct {
Data []struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
NbTracks int `json:"nb_tracks"`
ReleaseDate string `json:"release_date"`
RecordType string `json:"record_type"`
Artist deezerArtist `json:"artist"`
} `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, albumURL, &albumResp); err == nil {
if albumResp.Error != nil {
GoLog("[Deezer] Album API error: type=%s, code=%d, message=%s\n", albumResp.Error.Type, albumResp.Error.Code, albumResp.Error.Message)
} else {
GoLog("[Deezer] Got %d albums from API\n", len(albumResp.Data))
for _, album := range albumResp.Data {
coverURL := album.CoverXL
if coverURL == "" {
coverURL = album.CoverBig
}
if coverURL == "" {
coverURL = album.CoverMedium
}
if coverURL == "" {
coverURL = album.Cover
}
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
}
result.Albums = append(result.Albums, SearchAlbumResult{
ID: fmt.Sprintf("deezer:%d", album.ID),
Name: album.Title,
Artists: album.Artist.Name,
Images: coverURL,
ReleaseDate: album.ReleaseDate,
TotalTracks: album.NbTracks,
AlbumType: albumType,
})
}
}
} else {
GoLog("[Deezer] Album search failed: %v\n", err)
}
}
if playlistLimit > 0 {
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
var playlistResp struct {
Data []struct {
ID int64 `json:"id"`
Title string `json:"title"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbTracks int `json:"nb_tracks"`
User struct {
Name string `json:"name"`
} `json:"user"`
} `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, playlistURL, &playlistResp); err == nil {
if playlistResp.Error != nil {
GoLog("[Deezer] Playlist API error: type=%s, code=%d, message=%s\n", playlistResp.Error.Type, playlistResp.Error.Code, playlistResp.Error.Message)
} else {
GoLog("[Deezer] Got %d playlists from API\n", len(playlistResp.Data))
for _, playlist := range playlistResp.Data {
pictureURL := playlist.PictureXL
if pictureURL == "" {
pictureURL = playlist.PictureBig
}
if pictureURL == "" {
pictureURL = playlist.PictureMedium
}
if pictureURL == "" {
pictureURL = playlist.Picture
}
result.Playlists = append(result.Playlists, SearchPlaylistResult{
ID: fmt.Sprintf("deezer:%d", playlist.ID),
Name: playlist.Title,
Owner: playlist.User.Name,
Images: pictureURL,
TotalTracks: playlist.NbTracks,
})
}
}
} else {
GoLog("[Deezer] Playlist search failed: %v\n", err)
}
}
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)
@@ -285,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() {
@@ -311,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 != "" {
@@ -327,23 +581,60 @@ 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,
} }
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data) allTracks := album.Tracks.Data
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data)) if album.NbTracks > len(allTracks) {
// Normalize record_type (Deezer uses "compile" instead of "compilation") GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerAlbumURL, albumID), len(allTracks))
for len(allTracks) < album.NbTracks {
var tracksResp struct {
Data []deezerTrack `json:"data"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
GoLog("[Deezer] Warning: failed to fetch album tracks page: %v", err)
break
}
if len(tracksResp.Data) == 0 {
break
}
allTracks = append(allTracks, tracksResp.Data...)
if tracksResp.Next == "" {
break
}
tracksURL = tracksResp.Next
}
GoLog("[Deezer] Fetched total %d tracks for album", len(allTracks))
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
albumType := album.RecordType albumType := album.RecordType
if albumType == "compile" { if albumType == "compile" {
albumType = "compilation" albumType = "compilation"
} }
for _, track := range album.Tracks.Data { for i, track := range allTracks {
trackIDStr := fmt.Sprintf("%d", track.ID) trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr] isrc := isrcMap[trackIDStr]
trackNum := track.TrackPosition
if trackNum == 0 {
trackNum = i + 1
}
tracks = append(tracks, AlbumTrackMetadata{ tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID), SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name, Artists: track.Artist.Name,
@@ -353,7 +644,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
DurationMS: track.Duration * 1000, DurationMS: track.Duration * 1000,
Images: albumImage, Images: albumImage,
ReleaseDate: album.ReleaseDate, ReleaseDate: album.ReleaseDate,
TrackNumber: track.TrackPosition, TrackNumber: trackNum,
TotalTracks: album.NbTracks, TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber, DiscNumber: track.DiskNumber,
ExternalURL: track.Link, ExternalURL: track.Link,
@@ -369,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
@@ -386,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 {
@@ -401,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 {
@@ -413,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"`
} }
@@ -454,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
@@ -485,10 +778,43 @@ 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
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data) allTracks := playlist.Tracks.Data
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data)) if playlist.NbTracks > len(allTracks) {
for _, track := range playlist.Tracks.Data { GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerPlaylistURL, playlistID), len(allTracks))
for len(allTracks) < playlist.NbTracks {
var tracksResp struct {
Data []deezerTrack `json:"data"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
GoLog("[Deezer] Warning: failed to fetch playlist tracks page: %v", err)
break
}
if len(tracksResp.Data) == 0 {
break
}
allTracks = append(allTracks, tracksResp.Data...)
if tracksResp.Next == "" {
break
}
tracksURL = tracksResp.Next
}
GoLog("[Deezer] Fetched total %d tracks for playlist", len(allTracks))
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
for _, track := range allTracks {
albumImage := track.Album.CoverXL albumImage := track.Album.CoverXL
if albumImage == "" { if albumImage == "" {
albumImage = track.Album.CoverBig albumImage = track.Album.CoverBig
@@ -559,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
@@ -591,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()
} }
@@ -598,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
@@ -620,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)
} }
@@ -635,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 {
@@ -651,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
@@ -696,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")
@@ -734,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)
@@ -745,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)
@@ -757,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 {
@@ -767,33 +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 := track.SpotifyID
if strings.HasPrefix(deezerID, "deezer:") {
deezerID = strings.TrimPrefix(deezerID, "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
@@ -819,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
+1095 -208
View File
File diff suppressed because it is too large Load Diff
+5 -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()
@@ -809,8 +786,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 +898,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 +914,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()
+46 -66
View File
@@ -7,7 +7,6 @@ import (
"strings" "strings"
) )
// ExtensionType represents the type of extension
type ExtensionType string type ExtensionType string
const ( const (
@@ -15,7 +14,6 @@ const (
ExtensionTypeDownloadProvider ExtensionType = "download_provider" ExtensionTypeDownloadProvider ExtensionType = "download_provider"
) )
// SettingType represents the type of a setting field
type SettingType string type SettingType string
const ( const (
@@ -26,14 +24,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 +38,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,49 +57,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"`
}
type SearchFilter struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
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"`
} }
// 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"`
@@ -113,22 +108,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
@@ -138,7 +132,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,7 +175,6 @@ func (m *ExtensionManifest) Validate() error {
} }
} }
// 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{
@@ -217,7 +209,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 {
@@ -227,17 +218,14 @@ 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) 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 {
@@ -247,7 +235,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
} }
@@ -256,27 +244,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
@@ -285,7 +268,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
} }
@@ -293,7 +275,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
@@ -301,7 +282,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)
} }
+221 -174
View File
@@ -25,27 +25,26 @@ type ExtTrackMetadata struct {
AlbumArtist string `json:"album_artist,omitempty"` AlbumArtist string `json:"album_artist,omitempty"`
DurationMS int `json:"duration_ms"` DurationMS int `json:"duration_ms"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
Images string `json:"images,omitempty"` // Alternative field for cover URL (used by some extensions) Images string `json:"images,omitempty"`
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"` TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
ProviderID string `json:"provider_id"` ProviderID string `json:"provider_id"`
ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results ItemType string `json:"item_type,omitempty"`
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation AlbumType string `json:"album_type,omitempty"`
// Enrichment fields from Odesli/song.link
TidalID string `json:"tidal_id,omitempty"` TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"` QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"` SpotifyID string `json:"spotify_id,omitempty"`
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping ExternalLinks map[string]string `json:"external_links,omitempty"`
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
Label string `json:"label,omitempty"` // Record label Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"` // Copyright information Copyright string `json:"copyright,omitempty"`
Genre string `json:"genre,omitempty"` // Music genre(s) Genre string `json:"genre,omitempty"`
} }
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
func (t *ExtTrackMetadata) ResolvedCoverURL() string { func (t *ExtTrackMetadata) ResolvedCoverURL() string {
if t.CoverURL != "" { if t.CoverURL != "" {
return t.CoverURL return t.CoverURL
@@ -53,7 +52,6 @@ func (t *ExtTrackMetadata) ResolvedCoverURL() string {
return t.Images return t.Images
} }
// ExtAlbumMetadata represents album metadata from an extension
type ExtAlbumMetadata struct { type ExtAlbumMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -67,34 +65,28 @@ type ExtAlbumMetadata struct {
ProviderID string `json:"provider_id"` ProviderID string `json:"provider_id"`
} }
// ExtArtistMetadata represents artist metadata from an extension
type ExtArtistMetadata struct { type ExtArtistMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"` ImageURL string `json:"image_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background HeaderImage string `json:"header_image,omitempty"`
Listeners int `json:"listeners,omitempty"` // Monthly listeners Listeners int `json:"listeners,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"` Albums []ExtAlbumMetadata `json:"albums,omitempty"`
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
ProviderID string `json:"provider_id"` ProviderID string `json:"provider_id"`
} }
// ExtSearchResult represents search results from an extension
type ExtSearchResult struct { type ExtSearchResult struct {
Tracks []ExtTrackMetadata `json:"tracks"` Tracks []ExtTrackMetadata `json:"tracks"`
Total int `json:"total"` Total int `json:"total"`
} }
// ==================== Download Types ====================
// ExtAvailabilityResult represents availability check result
type ExtAvailabilityResult struct { type ExtAvailabilityResult struct {
Available bool `json:"available"` Available bool `json:"available"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
TrackID string `json:"track_id,omitempty"` TrackID string `json:"track_id,omitempty"`
} }
// ExtDownloadURLResult represents download URL info
type ExtDownloadURLResult struct { type ExtDownloadURLResult struct {
URL string `json:"url"` URL string `json:"url"`
Format string `json:"format"` Format string `json:"format"`
@@ -102,7 +94,6 @@ type ExtDownloadURLResult struct {
SampleRate int `json:"sample_rate,omitempty"` SampleRate int `json:"sample_rate,omitempty"`
} }
// ExtDownloadResult represents download result from an extension
type ExtDownloadResult struct { type ExtDownloadResult struct {
Success bool `json:"success"` Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"` FilePath string `json:"file_path,omitempty"`
@@ -110,7 +101,7 @@ type ExtDownloadResult struct {
SampleRate int `json:"sample_rate,omitempty"` SampleRate int `json:"sample_rate,omitempty"`
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"` ErrorType string `json:"error_type,omitempty"`
// Metadata returned by extension (optional - if provided, can skip enrichment)
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"` Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"` Album string `json:"album,omitempty"`
@@ -122,15 +113,11 @@ type ExtDownloadResult struct {
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
} }
// ==================== Provider Wrapper ====================
// ExtensionProviderWrapper wraps an extension to call its provider methods
type ExtensionProviderWrapper struct { type ExtensionProviderWrapper struct {
extension *LoadedExtension extension *LoadedExtension
vm *goja.Runtime vm *goja.Runtime
} }
// NewExtensionProviderWrapper creates a new provider wrapper
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper { func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper {
return &ExtensionProviderWrapper{ return &ExtensionProviderWrapper{
extension: ext, extension: ext,
@@ -138,9 +125,6 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
} }
} }
// ==================== Metadata Provider Methods ====================
// SearchTracks searches for tracks using the extension
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -150,11 +134,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
// Call extension's searchTracks function
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') { if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') {
@@ -176,7 +158,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return nil, fmt.Errorf("searchTracks returned null") return nil, fmt.Errorf("searchTracks returned null")
} }
// Convert result to Go struct
exported := result.Export() exported := result.Export()
jsonBytes, err := json.Marshal(exported) jsonBytes, err := json.Marshal(exported)
if err != nil { if err != nil {
@@ -185,14 +166,11 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
var searchResult ExtSearchResult var searchResult ExtSearchResult
// Try to parse as ExtSearchResult object first
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil { if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
// If that fails, try parsing as array of tracks directly
var tracks []ExtTrackMetadata var tracks []ExtTrackMetadata
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil { if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr) return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
} }
// Wrap array in ExtSearchResult
searchResult = ExtSearchResult{ searchResult = ExtSearchResult{
Tracks: tracks, Tracks: tracks,
Total: len(tracks), Total: len(tracks),
@@ -206,7 +184,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return &searchResult, nil return &searchResult, nil
} }
// GetTrack gets track details by ID
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) { func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -216,7 +193,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
@@ -256,7 +232,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return &track, nil return &track, nil
} }
// GetAlbum gets album details by ID
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) { func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -266,7 +241,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
@@ -309,7 +283,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return &album, nil return &album, nil
} }
// GetArtist gets artist details by ID
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) { func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -319,7 +292,6 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
@@ -359,27 +331,22 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return &artist, nil return &artist, nil
} }
// EnrichTrack enriches track metadata before download (e.g., fetch real ISRC)
// This is called lazily when download starts, not when playlist/album is loaded
// Extension should implement enrichTrack(track) function that returns enriched track
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) { func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return track, nil // Not a metadata provider, return as-is return track, nil
} }
if !p.extension.Enabled { if !p.extension.Enabled {
return track, nil // Extension disabled, return as-is return track, nil
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
// Convert track to JSON for passing to JS
trackJSON, err := json.Marshal(track) trackJSON, err := json.Marshal(track)
if err != nil { if err != nil {
GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err) GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err)
return track, nil // Return original on error return track, nil
} }
script := fmt.Sprintf(` script := fmt.Sprintf(`
@@ -399,10 +366,9 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
} else { } else {
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err) GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
} }
return track, nil // Return original on error return track, nil
} }
// If extension doesn't implement enrichTrack or returns null, return original
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return track, nil return track, nil
} }
@@ -420,18 +386,11 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return track, nil return track, nil
} }
// Preserve provider ID
enrichedTrack.ProviderID = track.ProviderID enrichedTrack.ProviderID = track.ProviderID
GoLog("[Extension] EnrichTrack: enriched track from %s (ISRC: %s -> %s)\n",
p.extension.ID, track.ISRC, enrichedTrack.ISRC)
return &enrichedTrack, nil return &enrichedTrack, nil
} }
// ==================== Download Provider Methods ====================
// CheckAvailability checks if a track is available for download
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) { func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) {
if !p.extension.Manifest.IsDownloadProvider() { if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
@@ -441,7 +400,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
@@ -480,7 +438,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return &availability, nil return &availability, nil
} }
// GetDownloadURL gets the download URL for a track
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) { func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
if !p.extension.Manifest.IsDownloadProvider() { if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
@@ -490,7 +447,6 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
@@ -529,10 +485,8 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return &urlResult, nil return &urlResult, nil
} }
// ExtDownloadTimeout is longer for extension download operations (5 minutes)
const ExtDownloadTimeout = 5 * time.Minute const ExtDownloadTimeout = 5 * time.Minute
// Download downloads a track with progress reporting
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) { func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() { if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
@@ -542,15 +496,12 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
// Set up progress callback in VM
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 { if len(call.Arguments) > 0 {
percent := int(call.Arguments[0].ToInteger()) percent := int(call.Arguments[0].ToInteger())
// Clamp to 0-100
if percent < 0 { if percent < 0 {
percent = 0 percent = 0
} }
@@ -573,7 +524,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
})() })()
`, trackID, quality, outputPath) `, trackID, quality, outputPath)
// Use longer timeout for downloads (5 minutes)
result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout) result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout)
if err != nil { if err != nil {
errMsg := err.Error() errMsg := err.Error()
@@ -619,9 +569,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
return &downloadResult, nil return &downloadResult, nil
} }
// ==================== Extension Manager Provider Methods ====================
// GetMetadataProviders returns all enabled metadata provider extensions
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper { func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -635,7 +582,6 @@ func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
return providers return providers
} }
// GetDownloadProviders returns all enabled download provider extensions
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper { func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -649,7 +595,6 @@ func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
return providers return providers
} }
// SearchTracksWithExtensions searches all metadata providers
func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) { func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
providers := m.GetMetadataProviders() providers := m.GetMetadataProviders()
if len(providers) == 0 { if len(providers) == 0 {
@@ -671,18 +616,12 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
return allTracks, nil return allTracks, nil
} }
// ==================== Provider Priority ====================
// providerPriority stores the order of download providers
var providerPriority []string var providerPriority []string
var providerPriorityMu sync.RWMutex var providerPriorityMu sync.RWMutex
// metadataProviderPriority stores the order of metadata providers
var metadataProviderPriority []string var metadataProviderPriority []string
var metadataProviderPriorityMu sync.RWMutex var metadataProviderPriorityMu sync.RWMutex
// SetProviderPriority sets the order of download providers
// providerIDs should include both built-in ("tidal", "qobuz", "amazon") and extension IDs
func SetProviderPriority(providerIDs []string) { func SetProviderPriority(providerIDs []string) {
providerPriorityMu.Lock() providerPriorityMu.Lock()
defer providerPriorityMu.Unlock() defer providerPriorityMu.Unlock()
@@ -690,13 +629,11 @@ func SetProviderPriority(providerIDs []string) {
GoLog("[Extension] Download provider priority set: %v\n", providerIDs) GoLog("[Extension] Download provider priority set: %v\n", providerIDs)
} }
// GetProviderPriority returns the current provider priority order
func GetProviderPriority() []string { func GetProviderPriority() []string {
providerPriorityMu.RLock() providerPriorityMu.RLock()
defer providerPriorityMu.RUnlock() defer providerPriorityMu.RUnlock()
if len(providerPriority) == 0 { if len(providerPriority) == 0 {
// Default order: built-in providers first
return []string{"tidal", "qobuz", "amazon"} return []string{"tidal", "qobuz", "amazon"}
} }
@@ -705,8 +642,6 @@ func GetProviderPriority() []string {
return result return result
} }
// SetMetadataProviderPriority sets the order of metadata providers
// providerIDs should include both built-in ("spotify", "deezer") and extension IDs
func SetMetadataProviderPriority(providerIDs []string) { func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock() metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock() defer metadataProviderPriorityMu.Unlock()
@@ -714,13 +649,11 @@ func SetMetadataProviderPriority(providerIDs []string) {
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs) GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
} }
// GetMetadataProviderPriority returns the current metadata provider priority order
func GetMetadataProviderPriority() []string { func GetMetadataProviderPriority() []string {
metadataProviderPriorityMu.RLock() metadataProviderPriorityMu.RLock()
defer metadataProviderPriorityMu.RUnlock() defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 { if len(metadataProviderPriority) == 0 {
// Default order: built-in providers first
return []string{"deezer", "spotify"} return []string{"deezer", "spotify"}
} }
@@ -729,30 +662,34 @@ func GetMetadataProviderPriority() []string {
return result return result
} }
// isBuiltInProvider checks if a provider ID is a built-in provider
func isBuiltInProvider(providerID string) bool { func isBuiltInProvider(providerID string) bool {
switch providerID { switch providerID {
case "tidal", "qobuz", "amazon": case "tidal", "qobuz", "amazon", "deezer":
return true return true
default: default:
return false return false
} }
} }
// ==================== Download with Fallback ====================
// DownloadWithExtensionFallback tries to download from providers in priority order
// Includes both built-in providers and extension providers
// If req.Source is set (extension ID), that extension is tried first
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
priority := GetProviderPriority() priority := GetProviderPriority()
extManager := GetExtensionManager() extManager := GetExtensionManager()
var lastErr error if req.Service != "" && isBuiltInProvider(req.Service) {
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
newPriority := []string{req.Service}
for _, p := range priority {
if p != req.Service {
newPriority = append(newPriority, p)
}
}
priority = newPriority
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
}
var lastErr error
var skipBuiltIn bool
// LAZY ENRICHMENT: If track came from an extension, try to enrich metadata (e.g., get real ISRC)
// This is done lazily at download time, not when playlist/album is loaded
if req.Source != "" && !isBuiltInProvider(req.Source) { if req.Source != "" && !isBuiltInProvider(req.Source) {
ext, err := extManager.GetExtension(req.Source) ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() { if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
@@ -796,7 +733,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if enrichedTrack.Artists != "" { if enrichedTrack.Artists != "" {
req.ArtistName = enrichedTrack.Artists req.ArtistName = enrichedTrack.Artists
} }
// Copy extended metadata from enrichment (label, copyright, genre, release_date)
if enrichedTrack.Label != "" && req.Label == "" { if enrichedTrack.Label != "" && req.Label == "" {
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label) GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
req.Label = enrichedTrack.Label req.Label = enrichedTrack.Label
@@ -817,7 +753,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
} }
// If source extension is specified, try it first before the priority list
if req.Source != "" && !isBuiltInProvider(req.Source) { if req.Source != "" && !isBuiltInProvider(req.Source) {
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source) GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
@@ -827,15 +762,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
provider := NewExtensionProviderWrapper(ext) provider := NewExtensionProviderWrapper(ext)
// For tracks from extension search, use the track ID directly (e.g., "youtube:VIDEO_ID") trackID := req.SpotifyID
// The extension already knows how to handle this ID
trackID := req.SpotifyID // This contains the extension's track ID (e.g., "youtube:xxx")
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn) GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
outputPath := buildOutputPath(req) outputPath := buildOutputPath(req)
// Download directly using the track ID from the extension
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) { result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
if req.ItemID != "" { if req.ItemID != "" {
SetItemProgress(req.ItemID, float64(percent), 0, 0) SetItemProgress(req.ItemID, float64(percent), 0, 0)
@@ -855,7 +787,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Copyright: req.Copyright, Copyright: req.Copyright,
} }
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" { if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
@@ -864,7 +795,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
} }
// If extension has skipMetadataEnrichment, copy metadata
if ext.Manifest.SkipMetadataEnrichment { if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true resp.SkipMetadataEnrichment = true
if result.Title != "" { if result.Title != "" {
@@ -914,12 +844,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr) GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
// If skipBuiltInFallback is true, don't continue to other providers
if skipBuiltIn { if skipBuiltIn {
GoLog("[DownloadWithExtensionFallback] skipBuiltInFallback is true, not trying other providers\n") GoLog("[DownloadWithExtensionFallback] skipBuiltInFallback is true, not trying other providers\n")
return &DownloadResponse{ return &DownloadResponse{
Success: false, Success: false,
Error: fmt.Sprintf("Download failed: %v", lastErr), Error: "Download failed: " + lastErr.Error(),
ErrorType: "extension_error", ErrorType: "extension_error",
Service: req.Source, Service: req.Source,
}, nil }, nil
@@ -929,14 +858,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
} }
// Continue with priority list
for _, providerID := range priority { for _, providerID := range priority {
// Skip if we already tried this as source
if providerID == req.Source { if providerID == req.Source {
continue continue
} }
// Skip built-in providers if skipBuiltIn is set
if skipBuiltIn && isBuiltInProvider(providerID) { if skipBuiltIn && isBuiltInProvider(providerID) {
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID) GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
continue continue
@@ -945,7 +871,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerID) { if isBuiltInProvider(providerID) {
// For built-in providers, enrich with Deezer metadata if not already present
if (req.Genre == "" || req.Label == "") && req.ISRC != "" { if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -966,11 +891,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
} }
// Use built-in provider
result, err := tryBuiltInProvider(providerID, req) result, err := tryBuiltInProvider(providerID, req)
if err == nil && result.Success { if err == nil && result.Success {
result.Service = providerID result.Service = providerID
// Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion)
if req.Label != "" { if req.Label != "" {
result.Label = req.Label result.Label = req.Label
} }
@@ -998,7 +921,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err) GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
} }
} else { } else {
// Try extension provider
ext, err := extManager.GetExtension(providerID) ext, err := extManager.GetExtension(providerID)
if err != nil || !ext.Enabled || ext.Error != "" { if err != nil || !ext.Enabled || ext.Error != "" {
GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID) GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID)
@@ -1041,7 +963,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Copyright: req.Copyright, Copyright: req.Copyright,
} }
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" { if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
@@ -1050,10 +971,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
} }
// If extension has skipMetadataEnrichment and returned metadata, use it
if ext.Manifest.SkipMetadataEnrichment { if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true resp.SkipMetadataEnrichment = true
// Copy metadata from extension result if provided
if result.Title != "" { if result.Title != "" {
resp.Title = result.Title resp.Title = result.Title
} }
@@ -1106,7 +1025,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if lastErr != nil { if lastErr != nil {
return &DownloadResponse{ return &DownloadResponse{
Success: false, Success: false,
Error: fmt.Sprintf("All providers failed. Last error: %v", lastErr), Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: "not_found", ErrorType: "not_found",
}, nil }, nil
} }
@@ -1118,7 +1037,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}, nil }, nil
} }
// tryBuiltInProvider attempts download from a built-in provider
func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) { func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) {
req.Service = providerID req.Service = providerID
@@ -1164,16 +1082,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
amazonResult, amazonErr := downloadFromAmazon(req) amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil { if amazonErr == nil {
result = DownloadResult{ result = DownloadResult{
FilePath: amazonResult.FilePath, FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth, BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate, SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title, Title: amazonResult.Title,
Artist: amazonResult.Artist, Artist: amazonResult.Artist,
Album: amazonResult.Album, Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate, ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber, TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber, DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC, ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
} }
} }
err = amazonErr err = amazonErr
@@ -1201,11 +1121,16 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
Genre: req.Genre, Genre: req.Genre,
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
}, nil }, nil
} }
// buildOutputPath builds the output file path from request
func buildOutputPath(req DownloadRequest) string { func buildOutputPath(req DownloadRequest) string {
if strings.TrimSpace(req.OutputPath) != "" {
return strings.TrimSpace(req.OutputPath)
}
metadata := map[string]interface{}{ metadata := map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
"artist": req.ArtistName, "artist": req.ArtistName,
@@ -1221,12 +1146,16 @@ func buildOutputPath(req DownloadRequest) string {
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)) filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
} }
return fmt.Sprintf("%s/%s.flac", req.OutputDir, filename) ext := strings.TrimSpace(req.OutputExt)
if ext == "" {
ext = ".flac"
} else if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext)
} }
// ==================== Custom Search ====================
// CustomSearch performs a custom search using an extension's search function
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
if !p.extension.Manifest.HasCustomSearch() { if !p.extension.Manifest.HasCustomSearch() {
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
@@ -1236,21 +1165,33 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
// Convert options to JSON if options == nil {
optionsJSON, _ := json.Marshal(options) options = map[string]interface{}{}
}
script := fmt.Sprintf(` // Avoid embedding user input directly into JS source. Some inputs can trigger
// parser/runtime edge cases on specific devices/Goja builds.
const queryVar = "__sf_custom_search_query"
const optionsVar = "__sf_custom_search_options"
global := p.vm.GlobalObject()
_ = global.Set(queryVar, query)
_ = global.Set(optionsVar, options)
defer func() {
global.Delete(queryVar)
global.Delete(optionsVar)
}()
const script = `
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') { if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
return extension.customSearch(%q, %s); return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options);
} }
return null; return null;
})() })()
`, query, string(optionsJSON)) `
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil { if err != nil {
@@ -1261,7 +1202,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
} }
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
// Return empty array instead of error for no results
return []ExtTrackMetadata{}, nil return []ExtTrackMetadata{}, nil
} }
@@ -1276,7 +1216,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return nil, fmt.Errorf("failed to parse search result: %w", err) return nil, fmt.Errorf("failed to parse search result: %w", err)
} }
// Return empty array if no tracks found
if tracks == nil { if tracks == nil {
tracks = []ExtTrackMetadata{} tracks = []ExtTrackMetadata{}
} }
@@ -1288,20 +1227,16 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return tracks, nil return tracks, nil
} }
// ==================== Custom URL Handler ====================
// ExtURLHandleResult represents the result of URL handling
type ExtURLHandleResult struct { type ExtURLHandleResult struct {
Type string `json:"type"` // "track", "album", "playlist", "artist" Type string `json:"type"`
Track *ExtTrackMetadata `json:"track,omitempty"` // For single track Track *ExtTrackMetadata `json:"track,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks,omitempty"` // For album/playlist Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
Album *ExtAlbumMetadata `json:"album,omitempty"` // Album info Album *ExtAlbumMetadata `json:"album,omitempty"`
Artist *ExtArtistMetadata `json:"artist,omitempty"` // Artist info Artist *ExtArtistMetadata `json:"artist,omitempty"`
Name string `json:"name,omitempty"` // Playlist/album name Name string `json:"name,omitempty"`
CoverURL string `json:"cover_url,omitempty"` // Cover image CoverURL string `json:"cover_url,omitempty"`
} }
// HandleURL processes a URL using the extension's URL handler
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) { func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
if !p.extension.Manifest.HasURLHandler() { if !p.extension.Manifest.HasURLHandler() {
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
@@ -1311,7 +1246,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
@@ -1347,7 +1281,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return nil, fmt.Errorf("failed to parse URL handle result: %w", err) return nil, fmt.Errorf("failed to parse URL handle result: %w", err)
} }
// Set provider ID on tracks
if handleResult.Track != nil { if handleResult.Track != nil {
handleResult.Track.ProviderID = p.extension.ID handleResult.Track.ProviderID = p.extension.ID
} }
@@ -1376,9 +1309,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return &handleResult, nil return &handleResult, nil
} }
// ==================== Custom Track Matching ====================
// MatchTrackResult represents the result of custom track matching
type MatchTrackResult struct { type MatchTrackResult struct {
Matched bool `json:"matched"` Matched bool `json:"matched"`
TrackID string `json:"track_id,omitempty"` TrackID string `json:"track_id,omitempty"`
@@ -1386,7 +1316,6 @@ type MatchTrackResult struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
} }
// MatchTrack uses extension's custom matching algorithm
func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) { func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
if !p.extension.Manifest.HasCustomMatching() { if !p.extension.Manifest.HasCustomMatching() {
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
@@ -1396,7 +1325,6 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
@@ -1438,22 +1366,26 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
return &matchResult, nil return &matchResult, nil
} }
// ==================== Post-Processing ====================
// PostProcessResult represents the result of post-processing
type PostProcessResult struct { type PostProcessResult struct {
Success bool `json:"success"` Success bool `json:"success"`
NewFilePath string `json:"new_file_path,omitempty"` NewFilePath string `json:"new_file_path,omitempty"`
NewFileURI string `json:"new_file_uri,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
// Additional metadata that may have changed BitDepth int `json:"bit_depth,omitempty"`
BitDepth int `json:"bit_depth,omitempty"` SampleRate int `json:"sample_rate,omitempty"`
SampleRate int `json:"sample_rate,omitempty"` }
type PostProcessInput struct {
Path string `json:"path,omitempty"`
URI string `json:"uri,omitempty"`
Name string `json:"name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size,omitempty"`
IsSAF bool `json:"is_saf,omitempty"`
} }
// PostProcessTimeout is longer for post-processing (2 minutes)
const PostProcessTimeout = 2 * time.Minute const PostProcessTimeout = 2 * time.Minute
// PostProcess runs post-processing hooks on a downloaded file
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() { if !p.extension.Manifest.HasPostProcessing() {
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
@@ -1463,7 +1395,6 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
@@ -1517,9 +1448,75 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return &postResult, nil return &postResult, nil
} }
// ==================== Extension Manager Advanced Methods ==================== func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() {
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
inputJSON, _ := json.Marshal(input)
filePath := input.Path
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined') {
if (typeof extension.postProcessV2 === 'function') {
return extension.postProcessV2(%s, %s, %q);
}
if (typeof extension.postProcess === 'function') {
return extension.postProcess(%q, %s, %q);
}
}
return null;
})()
`, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID)
result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout)
if err != nil {
errMsg := err.Error()
if IsTimeoutError(err) {
errMsg = "postProcess timeout: extension took too long to complete"
}
return &PostProcessResult{
Success: false,
Error: errMsg,
}, nil
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return &PostProcessResult{
Success: false,
Error: "postProcess returned null",
}, nil
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
return &PostProcessResult{
Success: false,
Error: fmt.Sprintf("failed to marshal result: %v", err),
}, nil
}
var postResult PostProcessResult
if err := json.Unmarshal(jsonBytes, &postResult); err != nil {
return &PostProcessResult{
Success: false,
Error: fmt.Sprintf("failed to parse result: %v", err),
}, nil
}
return &postResult, nil
}
// GetSearchProviders returns all extensions that provide custom search
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper { func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -1533,7 +1530,6 @@ func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
return providers return providers
} }
// GetURLHandlers returns all extensions that handle custom URLs
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper { func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -1547,7 +1543,6 @@ func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
return providers return providers
} }
// FindURLHandler finds an extension that can handle the given URL
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper { func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -1560,14 +1555,11 @@ func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper
return nil return nil
} }
// ExtURLHandleResultWithExtID wraps ExtURLHandleResult with extension ID for gomobile compatibility
type ExtURLHandleResultWithExtID struct { type ExtURLHandleResultWithExtID struct {
Result *ExtURLHandleResult Result *ExtURLHandleResult
ExtensionID string ExtensionID string
} }
// HandleURLWithExtension tries to handle a URL with any matching extension
// Returns result with extension ID, or error if no handler found
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) { func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
handler := m.FindURLHandler(url) handler := m.FindURLHandler(url)
if handler == nil { if handler == nil {
@@ -1647,3 +1639,58 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
} }
// RunPostProcessingV2 runs all enabled post-processing hooks on a file input.
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders()
if len(providers) == 0 {
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
}
currentInput := input
for _, provider := range providers {
hooks := provider.extension.Manifest.GetPostProcessingHooks()
for _, hook := range hooks {
if !hook.DefaultEnabled {
continue
}
ext := strings.ToLower(filepath.Ext(currentInput.Path))
if ext == "" && currentInput.Name != "" {
ext = strings.ToLower(filepath.Ext(currentInput.Name))
}
if len(hook.SupportedFormats) > 0 && ext != "" {
supported := false
for _, format := range hook.SupportedFormats {
if "."+format == ext || format == ext[1:] {
supported = true
break
}
}
if !supported {
continue
}
}
GoLog("[PostProcessV2] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentInput.Path)
result, err := provider.PostProcessV2(currentInput, metadata, hook.ID)
if err != nil {
GoLog("[PostProcessV2] Hook %s failed: %v\n", hook.ID, err)
continue
}
if result.Success && result.NewFilePath != "" {
currentInput.Path = result.NewFilePath
if currentInput.Name == "" {
currentInput.Name = filepath.Base(result.NewFilePath)
}
}
if result.Success && result.NewFileURI != "" {
currentInput.URI = result.NewFileURI
}
}
}
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
}
+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
+15 -9
View File
@@ -1,23 +1,29 @@
module github.com/zarz/spotiflac_android/go_backend module github.com/zarz/spotiflac_android/go_backend
go 1.24.0 go 1.25.0
toolchain go1.24.5 toolchain go1.25.7
require ( 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
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
golang.org/x/net v0.49.0
) )
require ( require (
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect
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
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect github.com/klauspost/compress v1.17.4 // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/tools v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
) )
+36 -14
View File
@@ -1,28 +1,50 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4 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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
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=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
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/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
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/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
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 -30
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,9 +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
) )
// 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,
@@ -59,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,
@@ -76,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
} }
@@ -84,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
@@ -116,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())
@@ -133,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")
} }
@@ -148,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)
@@ -193,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)
@@ -205,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
} }
@@ -224,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 {
@@ -240,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")
@@ -271,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] + "..."
} }
@@ -297,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 {
@@ -320,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 {
@@ -363,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{
@@ -424,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 {
+27
View File
@@ -0,0 +1,27 @@
//go:build ios
package gobackend
import (
"net/http"
)
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
// Fall back to standard HTTP client
// GetCloudflareBypassClient returns the standard HTTP client on iOS
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
func GetCloudflareBypassClient() *http.Client {
return sharedClient
}
// DoRequestWithCloudflareBypass on iOS just uses the standard client
// uTLS Chrome fingerprint bypass is not available on iOS
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
}
return resp, err
}
+181
View File
@@ -0,0 +1,181 @@
//go:build !ios
package gobackend
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
utls "github.com/refraction-networking/utls"
"golang.org/x/net/http2"
)
// uTLS transport that mimics Chrome's TLS fingerprint to bypass Cloudflare
// Uses HTTP/2 for optimal performance as uTLS works best with HTTP/2
type utlsTransport struct {
dialer *net.Dialer
mu sync.Mutex
h2Transports map[string]*http2.Transport
}
func newUTLSTransport() *utlsTransport {
return &utlsTransport{
dialer: &net.Dialer{
Timeout: 30 * Second,
KeepAlive: 30 * Second,
},
h2Transports: make(map[string]*http2.Transport),
}
}
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme != "https" {
return sharedTransport.RoundTrip(req)
}
host := req.URL.Hostname()
port := t.getPort(req.URL)
addr := net.JoinHostPort(host, port)
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
if err != nil {
return nil, err
}
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
}, utls.HelloChrome_Auto)
if err := tlsConn.Handshake(); err != nil {
conn.Close()
return nil, err
}
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
if negotiatedProto == "h2" {
h2Transport := &http2.Transport{
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
return tlsConn, nil
},
AllowHTTP: false,
DisableCompression: false,
}
return h2Transport.RoundTrip(req)
}
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return tlsConn, nil
},
DisableKeepAlives: true,
}
return transport.RoundTrip(req)
}
func (t *utlsTransport) getPort(u *url.URL) string {
if u.Port() != "" {
return u.Port()
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
// Cloudflare bypass client using uTLS Chrome fingerprint
var cloudflareBypassTransport = newUTLSTransport()
var cloudflareBypassClient = &http.Client{
Transport: cloudflareBypassTransport,
Timeout: DefaultTimeout,
}
// GetCloudflareBypassClient returns an HTTP client that mimics Chrome's TLS fingerprint
// Use this when requests are blocked by Cloudflare (common when using VPN)
func GetCloudflareBypassClient() *http.Client {
return cloudflareBypassClient
}
// DoRequestWithCloudflareBypass attempts request with standard client first,
// then retries with uTLS Chrome fingerprint if Cloudflare blocks it.
// This is useful when using VPN as Cloudflare detects Go's default TLS fingerprint.
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
// Try with standard client first
resp, err := sharedClient.Do(req)
if err == nil {
// Check for Cloudflare challenge page (403 with specific markers)
if resp.StatusCode == 403 || resp.StatusCode == 503 {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr == nil {
bodyStr := strings.ToLower(string(body))
cloudflareMarkers := []string{
"cloudflare", "cf-ray", "checking your browser",
"please wait", "ddos protection", "ray id",
"enable javascript", "challenge-platform",
}
isCloudflare := false
for _, marker := range cloudflareMarkers {
if strings.Contains(bodyStr, marker) {
isCloudflare = true
break
}
}
if isCloudflare {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
// Clone request for retry
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
// Retry with uTLS Chrome fingerprint
return cloudflareBypassClient.Do(reqCopy)
}
}
// Not Cloudflare, return original response (recreate body)
return &http.Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
Header: resp.Header,
Body: io.NopCloser(strings.NewReader(string(body))),
}, nil
}
return resp, nil
}
// Check if error might be TLS-related (Cloudflare blocking)
errStr := strings.ToLower(err.Error())
tlsRelated := strings.Contains(errStr, "tls") ||
strings.Contains(errStr, "handshake") ||
strings.Contains(errStr, "certificate") ||
strings.Contains(errStr, "connection reset")
if tlsRelated {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
// Clone request for retry
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
// Retry with uTLS Chrome fingerprint
return cloudflareBypassClient.Do(reqCopy)
}
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
return nil, err
}
+189
View File
@@ -0,0 +1,189 @@
package gobackend
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// IDHSClient is a client for I Don't Have Spotify API
// Used as fallback when SongLink fails or is rate limited
type IDHSClient struct {
client *http.Client
}
var (
globalIDHSClient *IDHSClient
idhsClientOnce sync.Once
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
)
// IDHSSearchRequest represents the request body for IDHS API
type IDHSSearchRequest struct {
Link string `json:"link"`
Adapters []string `json:"adapters,omitempty"`
}
// IDHSSearchResponse represents the response from IDHS API
type IDHSSearchResponse struct {
ID string `json:"id"`
Type string `json:"type"` // song, album, artist, podcast, show
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image,omitempty"`
Audio string `json:"audio,omitempty"`
Source string `json:"source"`
UniversalLink string `json:"universalLink"`
Links []IDHSLink `json:"links"`
}
// IDHSLink represents a link to a streaming platform
type IDHSLink struct {
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
URL string `json:"url"`
IsVerified bool `json:"isVerified,omitempty"`
NotAvailable bool `json:"notAvailable,omitempty"`
}
// NewIDHSClient creates a new IDHS client
func NewIDHSClient() *IDHSClient {
idhsClientOnce.Do(func() {
globalIDHSClient = &IDHSClient{
client: NewHTTPClientWithTimeout(15 * time.Second),
}
})
return globalIDHSClient
}
// Search converts a music link to links on other platforms
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
idhsRateLimiter.WaitForSlot()
reqBody := IDHSSearchRequest{
Link: link,
Adapters: adapters,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", "https://idonthavespotify.sjdonado.com/api/search?v=1", bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 400 {
return nil, fmt.Errorf("invalid link or missing parameters")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("IDHS rate limit exceeded")
}
if resp.StatusCode == 500 {
return nil, fmt.Errorf("IDHS processing failed")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("IDHS API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result IDHSSearchResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
// Request only the platforms we need
adapters := []string{"tidal", "deezer"}
result, err := c.Search(spotifyURL, adapters)
if err != nil {
return nil, err
}
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
}
for _, link := range result.Links {
if link.NotAvailable {
continue
}
switch strings.ToLower(link.Type) {
case "tidal":
availability.Tidal = true
availability.TidalURL = link.URL
case "deezer":
availability.Deezer = true
availability.DeezerURL = link.URL
availability.DeezerID = extractDeezerIDFromURL(link.URL)
}
}
LogDebug("IDHS", "Availability from Spotify %s: Tidal=%v, Deezer=%v",
spotifyTrackID, availability.Tidal, availability.Deezer)
return availability, nil
}
// GetAvailabilityFromDeezer checks track availability using IDHS
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
// Request only the platforms we need
adapters := []string{"spotify", "tidal"}
result, err := c.Search(deezerURL, adapters)
if err != nil {
return nil, err
}
availability := &TrackAvailability{
Deezer: true,
DeezerID: deezerTrackID,
}
for _, link := range result.Links {
if link.NotAvailable {
continue
}
switch strings.ToLower(link.Type) {
case "spotify":
availability.SpotifyID = extractSpotifyIDFromURL(link.URL)
case "tidal":
availability.Tidal = true
availability.TidalURL = link.URL
}
}
LogDebug("IDHS", "Availability from Deezer %s: Spotify=%s, Tidal=%v",
deezerTrackID, availability.SpotifyID, availability.Tidal)
return availability, nil
}
+609
View File
@@ -0,0 +1,609 @@
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"`
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
result.Duration = quality.Duration
}
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
result.Duration = quality.Duration
}
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
}
+35 -18
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...))
} }
@@ -150,11 +175,11 @@ func GoLog(format string, args ...interface{}) {
// Determine level from message content // Determine level from message content
msgLower := strings.ToLower(message) msgLower := strings.ToLower(message)
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") { if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
level = "ERROR" level = "ERROR"
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") { } else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
level = "WARN" level = "WARN"
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") { } else if strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
level = "INFO" level = "INFO"
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") { } else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
level = "DEBUG" level = "DEBUG"
@@ -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)
} }
+37 -40
View File
@@ -238,9 +238,9 @@ 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) {
// Check cache first primaryArtist := normalizeArtistName(artistName)
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) fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached cachedCopy := *cached
@@ -251,39 +251,48 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
var lyrics *LyricsResponse var lyrics *LyricsResponse
var err error var err error
// Try exact match first isValidResult := func(l *LyricsResponse) bool {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName) return l != nil && (len(l.Lines) > 0 || l.Instrumental)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { }
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB" lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
} }
// Try with simplified track name 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
}
}
simplifiedTrack := simplifyTrackName(trackName) simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName { if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack) lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB (simplified)" lyrics.Source = "LRCLIB (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
} }
} }
// Search with duration matching query := primaryArtist + " " + trackName
query := artistName + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search" lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
} }
// Search with simplified name and duration matching
if simplifiedTrack != trackName { if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search (simplified)" lyrics.Source = "LRCLIB Search (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
@@ -375,32 +384,6 @@ func msToLRCTimestamp(ms int64) string {
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 ""
@@ -462,6 +445,20 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result) return strings.TrimSpace(result)
} }
func normalizeArtistName(name string) string {
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
result := name
for _, sep := range separators {
if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
result = result[:idx]
break
}
}
return strings.TrimSpace(result)
}
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) { func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
if lrcContent == "" { if lrcContent == "" {
return "", fmt.Errorf("empty LRC content") return "", fmt.Errorf("empty LRC content")
+220 -451
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,371 +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
}
func findAtom(data []byte, name string, offset int) int {
for i := offset; i < len(data)-8; {
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
if size < 8 {
break
}
atomName := string(data[i+4 : i+8])
if atomName == name {
return i
}
i += size
}
return -1
}
// 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 {
@@ -989,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")
+10
View File
@@ -0,0 +1,10 @@
// mobile_deps.go
// This file ensures gomobile dependencies are not removed by go mod tidy.
// These packages are required by gomobile bind but not directly imported in code.
package gobackend
import (
// Required for gomobile bind to work
_ "golang.org/x/mobile/bind"
)
+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)
}
+113 -36
View File
@@ -1,6 +1,7 @@
package gobackend package gobackend
import ( import (
"encoding/json"
"fmt" "fmt"
"sync" "sync"
"time" "time"
@@ -9,14 +10,16 @@ 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
lastCleanup time.Time
cleanupInterval time.Duration
} }
var ( var (
@@ -27,8 +30,9 @@ var (
func GetTrackIDCache() *TrackIDCache { func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() { trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{ globalTrackIDCache = &TrackIDCache{
cache: make(map[string]*TrackIDCacheEntry), cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute, ttl: 30 * time.Minute,
cleanupInterval: 5 * time.Minute,
} }
}) })
return globalTrackIDCache return globalTrackIDCache
@@ -36,13 +40,33 @@ func GetTrackIDCache() *TrackIDCache {
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock()
entry, exists := c.cache[isrc] entry, exists := c.cache[isrc]
if !exists || time.Now().After(entry.ExpiresAt) { if !exists {
c.mu.RUnlock()
return nil return nil
} }
return entry expired := time.Now().After(entry.ExpiresAt)
c.mu.RUnlock()
if !expired {
return entry
}
c.mu.Lock()
entry, exists = c.cache[isrc]
if exists && time.Now().After(entry.ExpiresAt) {
delete(c.cache, isrc)
}
c.mu.Unlock()
return nil
}
func (c *TrackIDCache) pruneExpiredLocked(now time.Time) {
for key, entry := range c.cache {
if now.After(entry.ExpiresAt) {
delete(c.cache, key)
}
}
} }
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) { func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
@@ -55,7 +79,13 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.cache[isrc] = entry c.cache[isrc] = entry
} }
entry.TidalTrackID = trackID entry.TidalTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl) now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
} }
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
@@ -68,10 +98,16 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.cache[isrc] = entry c.cache[isrc] = entry
} }
entry.QobuzTrackID = trackID entry.QobuzTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl) now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
} }
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()
@@ -80,8 +116,14 @@ 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
entry.ExpiresAt = time.Now().Add(c.ttl) now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
} }
func (c *TrackIDCache) Clear() { func (c *TrackIDCache) Clear() {
@@ -96,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
@@ -116,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()
}() }()
} }
@@ -137,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()
}() }()
} }
@@ -163,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) {
@@ -172,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
} }
@@ -193,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)
} }
@@ -201,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) {
@@ -209,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
@@ -240,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
+400 -212
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,14 +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
encodedAPIs := []string{ encodedAPIs := []string{
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC) "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==",
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC) "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=",
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=",
} }
var apis []string var apis []string
@@ -393,6 +356,124 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
return apis return apis
} }
func mapJumoQuality(quality string) int {
switch quality {
case "6":
return 6
case "7":
return 7
case "27":
return 27
default:
return 6
}
}
func decodeXOR(data []byte) string {
text := string(data)
runes := []rune(text)
result := make([]rune, len(runes))
for i, char := range runes {
key := rune((i * 17) % 128)
result[i] = char ^ 253 ^ key
}
return string(result)
}
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) {
formatID := mapJumoQuality(quality)
region := "US"
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
GoLog("[Qobuz] Trying Jumo API fallback...\n")
client := NewHTTPClientWithTimeout(30 * time.Second)
req, err := http.NewRequest("GET", jumoURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("Jumo API returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
decoded := decodeXOR(body)
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err)
}
}
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully\n")
return urlVal, nil
}
if data, ok := result["data"].(map[string]any); ok {
if urlVal, ok := data["url"].(string); ok && urlVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully (from data)\n")
return urlVal, nil
}
}
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n")
return linkVal, nil
}
return "", fmt.Errorf("URL not found in Jumo response")
}
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
@@ -421,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
@@ -435,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)
@@ -468,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 {
@@ -522,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) {
@@ -559,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)
@@ -567,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) {
@@ -626,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]
@@ -637,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))
@@ -646,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 {
@@ -662,12 +726,12 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
if len(durationMatches) > 0 { if len(durationMatches) > 0 {
for _, track := range durationMatches { for _, track := range durationMatches {
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified, hi-res)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
track.Title, track.Performer.Name) track.Title, track.Performer.Name)
return track, nil return track, nil
} }
} }
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified)\n",
durationMatches[0].Title, durationMatches[0].Performer.Name) durationMatches[0].Title, durationMatches[0].Performer.Name)
return durationMatches[0], nil return durationMatches[0], nil
} }
@@ -675,17 +739,16 @@ 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",
track.Title, track.Performer.Name) track.Title, track.Performer.Name)
return track, nil return track, nil
} }
} }
if len(tracksToCheck) > 0 { if len(tracksToCheck) > 0 {
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title verified)\n",
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name) tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
return tracksToCheck[0], nil return tracksToCheck[0], nil
} }
@@ -693,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
@@ -701,90 +763,161 @@ 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++ {
result := <-resultChan result := <-resultChan
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
@@ -812,18 +945,38 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
} }
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality) _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
if err != nil { if err == nil {
return "", err return downloadURL, nil
} }
return downloadURL, nil GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
if jumoErr == nil {
return jumoURL, nil
}
if quality == "27" {
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
jumoURL, jumoErr = q.downloadFromJumo(trackID, "7")
if jumoErr == nil {
return jumoURL, nil
}
}
if quality == "27" || quality == "7" {
GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n")
jumoURL, jumoErr = q.downloadFromJumo(trackID, "6")
if jumoErr == nil {
return jumoURL, nil
}
}
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)
@@ -858,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
} }
@@ -873,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)
} }
@@ -913,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
@@ -927,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
@@ -941,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",
@@ -972,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)
@@ -991,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)
@@ -1005,29 +1182,33 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
"year": extractYear(req.ReleaseDate), "year": extractYear(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)
@@ -1035,7 +1216,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() {
@@ -1051,15 +1231,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 != "" {
@@ -1072,7 +1250,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
@@ -1082,15 +1259,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
@@ -1099,40 +1276,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,
@@ -1142,7 +1329,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")
}
}
+359 -71
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,17 +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")
}
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
@@ -102,6 +102,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
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 != "" {
@@ -115,8 +116,26 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
} }
if isrc != "" { if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = checkQobuzAvailability(isrc) 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
@@ -139,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, "/")
@@ -186,19 +171,175 @@ 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 {
return "", err return "", err
} }
if !availability.Deezer || availability.DeezerID == "" { if !availability.Deezer || availability.DeezerID == "" {
return "", fmt.Errorf("track not found on Deezer") return "", fmt.Errorf("track not found on Deezer")
} }
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"`
@@ -208,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)
@@ -268,11 +407,11 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
if err != nil { if err != nil {
return "", err return "", err
} }
if !availability.Deezer || availability.DeezerID == "" { if !availability.Deezer || availability.DeezerID == "" {
return "", fmt.Errorf("album not found on Deezer") return "", fmt.Errorf("album not found on Deezer")
} }
return availability.DeezerID, nil return availability.DeezerID, nil
} }
@@ -281,7 +420,23 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
if deezerTrackID == "" { if deezerTrackID == "" {
return nil, fmt.Errorf("deezer track ID is empty") return nil, fmt.Errorf("deezer track ID is empty")
} }
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
if err != nil {
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
idhsClient := NewIDHSClient()
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
if err != nil {
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
}
LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID)
}
return availability, nil
}
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
@@ -301,7 +456,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
} }
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)")
} }
@@ -348,6 +502,7 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
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 != "" {
@@ -355,10 +510,29 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
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
} }
@@ -369,12 +543,9 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
if entityID == "" { if entityID == "" {
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),
@@ -392,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)
} }
@@ -430,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 != "" {
@@ -437,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
} }
@@ -464,11 +654,11 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e
if err != nil { if err != nil {
return "", err return "", err
} }
if availability.SpotifyID == "" { if availability.SpotifyID == "" {
return "", fmt.Errorf("track not found on Spotify") return "", fmt.Errorf("track not found on Spotify")
} }
return availability.SpotifyID, nil return availability.SpotifyID, nil
} }
@@ -478,11 +668,11 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
if err != nil { if err != nil {
return "", err return "", err
} }
if !availability.Tidal || availability.TidalURL == "" { if !availability.Tidal || availability.TidalURL == "" {
return "", fmt.Errorf("track not found on Tidal") return "", fmt.Errorf("track not found on Tidal")
} }
return availability.TidalURL, nil return availability.TidalURL, nil
} }
@@ -491,10 +681,108 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
if err != nil { if err != nil {
return "", err return "", err
} }
if !availability.Amazon || availability.AmazonURL == "" { if !availability.Amazon || availability.AmazonURL == "" {
return "", fmt.Errorf("track not found on Amazon Music") return "", fmt.Errorf("track not found on Amazon Music")
} }
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)
}
}
+33 -18
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"`
} }
@@ -238,9 +235,29 @@ type SearchArtistResult struct {
Popularity int `json:"popularity"` Popularity int `json:"popularity"`
} }
type SearchAlbumResult struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TotalTracks int `json:"total_tracks"`
AlbumType string `json:"album_type"`
}
type SearchPlaylistResult struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Images string `json:"images"`
TotalTracks int `json:"total_tracks"`
}
type SearchAllResult struct { type SearchAllResult struct {
Tracks []TrackMetadata `json:"tracks"` Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"` Artists []SearchArtistResult `json:"artists"`
Albums []SearchAlbumResult `json:"albums"`
Playlists []SearchPlaylistResult `json:"playlists"`
} }
type spotifyURI struct { type spotifyURI struct {
@@ -514,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
@@ -547,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
@@ -919,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",
+387 -176
View File
@@ -119,17 +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{
"dGlkYWwua2lub3BsdXMub25saW5l", "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority)
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", "dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
"dHJpdG9uLnNxdWlkLnd0Zg==", "dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
"dm9nZWwucXFkbC5zaXRl", "dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
"bWF1cy5xcWRsLnNpdGU=", "bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
"aHVuZC5xcWRsLnNpdGU=", "aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
"a2F0emUucXFkbC5zaXRl", "a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
"d29sZi5xcWRsLnNpdGU=", "d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
} }
var apis []string var apis []string
@@ -249,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 {
@@ -317,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
@@ -341,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)
} }
@@ -442,13 +440,13 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
durationDiff = -durationDiff durationDiff = -durationDiff
} }
if durationDiff <= 3 { if durationDiff <= 3 {
GoLog("[Tidal] ISRC match: '%s' (duration verified)\n", track.Title) GoLog("[Tidal] ISRC match: '%s' (duration verified)\n", track.Title)
return track, nil return track, nil
} }
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n", GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
expectedDuration, track.Duration) expectedDuration, track.Duration)
} else { } else {
GoLog("[Tidal] ISRC match: '%s'\n", track.Title) GoLog("[Tidal] ISRC match: '%s'\n", track.Title)
return track, nil return track, nil
} }
} }
@@ -487,7 +485,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
} }
if len(durationVerifiedMatches) > 0 { if len(durationVerifiedMatches) > 0 {
GoLog("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", GoLog("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration) durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil return durationVerifiedMatches[0], nil
} }
@@ -498,11 +496,11 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
expectedDuration, isrcMatches[0].Duration) expectedDuration, isrcMatches[0].Duration)
} }
GoLog("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) GoLog("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil return isrcMatches[0], nil
} }
GoLog("[Tidal] No ISRC match found for: %s\n", spotifyISRC) GoLog("[Tidal] No ISRC match found for: %s\n", spotifyISRC)
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
} }
@@ -584,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()
@@ -598,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)
} }
@@ -669,7 +721,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
for i := 0; i < len(apis); i++ { for i := 0; i < len(apis); i++ {
result := <-resultChan result := <-resultChan
if result.err == nil { if result.err == nil {
GoLog("[Tidal] [Parallel] Got response from %s (%d-bit/%dHz) in %v\n", GoLog("[Tidal] [Parallel] Got response from %s (%d-bit/%dHz) in %v\n",
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration) result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
go func(remaining int) { go func(remaining int) {
@@ -787,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)
@@ -795,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:") {
@@ -809,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 != "" {
@@ -846,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
} }
@@ -865,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 {
@@ -933,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)
} }
@@ -949,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)
@@ -980,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
} }
@@ -1003,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)
} }
@@ -1011,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
} }
@@ -1023,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
} }
@@ -1039,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
} }
@@ -1056,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)
} }
@@ -1064,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
} }
@@ -1074,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)
} }
@@ -1094,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 {
@@ -1104,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
} }
@@ -1163,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
} }
@@ -1196,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
} }
@@ -1205,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)
@@ -1219,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)
@@ -1227,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 {
@@ -1348,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
@@ -1405,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)
} }
} }
} }
@@ -1500,6 +1598,11 @@ 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,
@@ -1508,27 +1611,55 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
"year": extractYear(req.ReleaseDate), "year": extractYear(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)
@@ -1561,7 +1692,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
} }
@@ -1578,11 +1709,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
@@ -1591,7 +1724,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 {
@@ -1622,7 +1754,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)
} }
@@ -1633,7 +1773,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)
@@ -1653,16 +1793,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,
@@ -1670,5 +1843,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)
}
}
+566
View File
@@ -0,0 +1,566 @@
// 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),
"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
}
+69 -1
View File
@@ -217,12 +217,21 @@ 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
let trackLimit = args["track_limit"] as? Int ?? 15 let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3 let artistLimit = args["artist_limit"] as? Int ?? 3
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), &error) let filter = args["filter"] as? String ?? ""
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error } if let error = error { throw error }
return response return response
@@ -241,6 +250,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
@@ -624,6 +647,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)
@@ -686,6 +717,43 @@ 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
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>
+17 -1
View File
@@ -4,15 +4,27 @@ 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( GoRoute(
path: '/', path: '/',
@@ -22,6 +34,10 @@ final _routerProvider = Provider<GoRouter>((ref) {
path: '/setup', path: '/setup',
builder: (context, state) => const SetupScreen(), builder: (context, state) => const SetupScreen(),
), ),
GoRoute(
path: '/tutorial',
builder: (context, state) => const TutorialScreen(),
),
], ],
); );
}); });
+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.2.0'; static const String version = '3.6.5';
static const String buildNumber = '63'; static const String buildNumber = '79';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
@@ -17,4 +17,5 @@ static const String version = '3.2.0';
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
+799 -12
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';
@@ -470,6 +471,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -484,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.';
@@ -680,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';
@@ -935,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';
@@ -1618,6 +1639,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,25 +1877,45 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
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';
@@ -1878,6 +1928,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';
@@ -1957,6 +2029,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';
@@ -2006,6 +2111,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2073,6 +2185,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';
@@ -2148,4 +2266,673 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'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';
} }
+799 -18
View File
@@ -18,6 +18,9 @@ class AppLocalizationsEs 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 AppLocalizationsEs 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 AppLocalizationsEs 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';
@@ -470,6 +471,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -484,6 +489,13 @@ class AppLocalizationsEs 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.';
@@ -680,6 +692,10 @@ class AppLocalizationsEs 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';
@@ -935,6 +951,11 @@ class AppLocalizationsEs 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';
@@ -1618,6 +1639,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,25 +1877,45 @@ class AppLocalizationsEs extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
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';
@@ -1878,6 +1928,28 @@ class AppLocalizationsEs 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';
@@ -1957,6 +2029,39 @@ class AppLocalizationsEs 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';
@@ -2006,6 +2111,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2073,6 +2185,12 @@ class AppLocalizationsEs 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';
@@ -2148,6 +2266,675 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'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';
} }
/// The translations for Spanish Castilian, as used in Spain (`es_ES`). /// The translations for Spanish Castilian, as used in Spain (`es_ES`).
@@ -2586,12 +3373,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override @override
String get aboutSupport => 'Soporte'; String get aboutSupport => 'Soporte';
@override
String get aboutBuyMeCoffee => 'Invítame a un café';
@override
String get aboutBuyMeCoffeeSubtitle => 'Apoyar el desarrollo en Ko-fi';
@override @override
String get aboutApp => 'Aplicación'; String get aboutApp => 'Aplicación';
+799 -12
View File
@@ -18,6 +18,9 @@ class AppLocalizationsFr 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 AppLocalizationsFr 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 AppLocalizationsFr 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';
@@ -470,6 +471,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -484,6 +489,13 @@ class AppLocalizationsFr 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.';
@@ -680,6 +692,10 @@ class AppLocalizationsFr 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';
@@ -935,6 +951,11 @@ class AppLocalizationsFr 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';
@@ -1618,6 +1639,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,25 +1877,45 @@ class AppLocalizationsFr extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
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';
@@ -1878,6 +1928,28 @@ class AppLocalizationsFr 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';
@@ -1957,6 +2029,39 @@ class AppLocalizationsFr 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';
@@ -2006,6 +2111,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2073,6 +2185,12 @@ class AppLocalizationsFr 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';
@@ -2148,4 +2266,673 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'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';
} }
+807 -20
View File
@@ -9,20 +9,23 @@ class AppLocalizationsHi extends AppLocalizations {
AppLocalizationsHi([String locale = 'hi']) : super(locale); AppLocalizationsHi([String locale = 'hi']) : super(locale);
@override @override
String get appName => 'SpotiFLAC'; String get appName => 'SpotiFlac';
@override @override
String get appDescription => String get appDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।';
@override @override
String get navHome => 'Home'; String get navHome => 'होम';
@override @override
String get navHistory => 'History'; String get navLibrary => 'Library';
@override @override
String get navSettings => 'Settings'; String get navHistory => 'इतिहास';
@override
String get navSettings => 'विकल्प';
@override @override
String get navStore => 'Store'; String get navStore => 'Store';
@@ -184,7 +187,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get quality128 => '128 kbps'; String get quality128 => '128 kbps';
@override @override
String get appearanceTitle => 'Appearance'; String get appearanceTitle => 'दिखावट';
@override @override
String get appearanceTheme => 'Theme'; String get appearanceTheme => 'Theme';
@@ -199,10 +202,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get appearanceThemeDark => 'Dark'; String get appearanceThemeDark => 'Dark';
@override @override
String get appearanceDynamicColor => 'Dynamic Color'; String get appearanceDynamicColor => 'डायनेमिक रंग';
@override @override
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; String get appearanceDynamicColorSubtitle => 'वॉलपेपर से रंग इस्तेमाल करें';
@override @override
String get appearanceAccentColor => 'Accent Color'; String get appearanceAccentColor => 'Accent Color';
@@ -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';
@@ -470,6 +471,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -484,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.';
@@ -680,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';
@@ -935,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';
@@ -1618,6 +1639,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,25 +1877,45 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
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';
@@ -1878,6 +1928,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';
@@ -1957,6 +2029,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';
@@ -2006,6 +2111,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2073,6 +2185,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';
@@ -2148,4 +2266,673 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'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';
} }
+821 -28
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 =>
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke 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';
@@ -475,6 +476,10 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!'; 'Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -489,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.';
@@ -685,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';
@@ -941,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';
@@ -1628,6 +1649,15 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Gagal memuat lirik'; String get trackLyricsLoadFailed => 'Gagal memuat lirik';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Disalin ke clipboard'; String get trackCopiedToClipboard => 'Disalin ke clipboard';
@@ -1859,25 +1889,45 @@ class AppLocalizationsId extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (konversi dari FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Aktifkan Opsi MP3'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'Opsi kualitas MP3 tersedia'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Unduh FLAC lalu konversi ke MP3 320kbps';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
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';
@@ -1890,6 +1940,29 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Struktur Folder Album'; String get downloadAlbumFolderStructure => 'Struktur Folder Album';
@override
String get downloadUseAlbumArtistForFolders =>
'Gunakan Album Artist untuk folder';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Folder artis memakai Album Artist jika tersedia';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Folder artis hanya memakai Track Artist';
@override
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artist dihapus dari nama folder (misal Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Nama artis lengkap dipakai untuk folder';
@override @override
String get downloadSaveFormat => 'Simpan Format'; String get downloadSaveFormat => 'Simpan Format';
@@ -1970,6 +2043,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';
@@ -2019,6 +2125,13 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih'; String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
@@ -2086,6 +2199,12 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'Belum ada item terbaru';
@override
String get recentShowAllDownloads => 'Tampilkan Semua Download';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2097,68 +2216,742 @@ class AppLocalizationsId extends AppLocalizations {
} }
@override @override
String get discographyDownload => 'Unduh Diskografi'; String get discographyDownload => 'Download Discography';
@override @override
String get discographyDownloadAll => 'Unduh Semua'; String get discographyDownloadAll => 'Unduh Semua';
@override @override
String discographyDownloadAllSubtitle(int count, int albumCount) { String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount rilis'; return '$count tracks from $albumCount releases';
} }
@override @override
String get discographyAlbumsOnly => 'Album Saja'; String get discographyAlbumsOnly => 'Albums Only';
@override @override
String discographyAlbumsOnlySubtitle(int count, int albumCount) { String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount album'; return '$count tracks from $albumCount albums';
} }
@override @override
String get discographySinglesOnly => 'Single & EP Saja'; String get discographySinglesOnly => 'Singles & EPs Only';
@override @override
String discographySinglesOnlySubtitle(int count, int albumCount) { String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount single'; return '$count tracks from $albumCount singles';
} }
@override @override
String get discographySelectAlbums => 'Pilih Album...'; String get discographySelectAlbums => 'Select Albums...';
@override @override
String get discographySelectAlbumsSubtitle => String get discographySelectAlbumsSubtitle =>
'Pilih album atau single tertentu'; 'Choose specific albums or singles';
@override @override
String get discographyFetchingTracks => 'Mengambil lagu...'; String get discographyFetchingTracks => 'Fetching tracks...';
@override @override
String discographyFetchingAlbum(int current, int total) { String discographyFetchingAlbum(int current, int total) {
return 'Mengambil $current dari $total...'; return 'Fetching $current of $total...';
} }
@override @override
String discographySelectedCount(int count) { String discographySelectedCount(int count) {
return '$count dipilih'; return '$count selected';
} }
@override @override
String get discographyDownloadSelected => 'Unduh yang Dipilih'; String get discographyDownloadSelected => 'Download Selected';
@override @override
String discographyAddedToQueue(int count) { String discographyAddedToQueue(int count) {
return 'Menambahkan $count lagu ke antrian'; return 'Added $count tracks to queue';
} }
@override @override
String discographySkippedDownloaded(int added, int skipped) { String discographySkippedDownloaded(int added, int skipped) {
return '$added ditambahkan, $skipped sudah diunduh'; return '$added added, $skipped already downloaded';
} }
@override @override
String get discographyNoAlbums => 'Tidak ada album tersedia'; String get discographyNoAlbums => 'No albums available';
@override @override
String get discographyFailedToFetch => 'Gagal mengambil beberapa album'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'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 => 'Penyimpanan & Cache';
@override
String get settingsCacheSubtitle => 'Lihat ukuran dan bersihkan data cache';
@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 => 'Selamat Datang di SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
@override
String get tutorialWelcomeTip1 =>
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
@override
String get tutorialWelcomeTip2 =>
'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Metadata, cover art, dan lirik otomatis tertanam';
@override
String get tutorialSearchTitle => 'Mencari Musik';
@override
String get tutorialSearchDesc =>
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
@override
String get tutorialSearchTip1 =>
'Tempel URL Spotify atau Deezer langsung di kotak pencarian';
@override
String get tutorialSearchTip2 =>
'Atau ketik nama lagu, artis, atau album untuk mencari';
@override
String get tutorialSearchTip3 =>
'Mendukung lagu, album, playlist, dan halaman artis';
@override
String get tutorialDownloadTitle => 'Mengunduh Musik';
@override
String get tutorialDownloadDesc =>
'Mengunduh musik itu mudah dan cepat. Begini caranya.';
@override
String get tutorialDownloadTip1 =>
'Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh';
@override
String get tutorialDownloadTip2 =>
'Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)';
@override
String get tutorialDownloadTip3 =>
'Unduh seluruh album atau playlist dengan satu ketukan';
@override
String get tutorialLibraryTitle => 'Perpustakaan Anda';
@override
String get tutorialLibraryDesc =>
'Semua musik yang Anda unduh terorganisir di tab Perpustakaan.';
@override
String get tutorialLibraryTip1 =>
'Lihat progres unduhan dan antrian di tab Perpustakaan';
@override
String get tutorialLibraryTip2 =>
'Ketuk lagu mana pun untuk memutarnya dengan pemutar musik';
@override
String get tutorialLibraryTip3 =>
'Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik';
@override
String get tutorialExtensionsTitle => 'Ekstensi';
@override
String get tutorialExtensionsDesc =>
'Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.';
@override
String get tutorialExtensionsTip1 =>
'Jelajahi tab Toko untuk menemukan ekstensi berguna';
@override
String get tutorialExtensionsTip2 =>
'Tambahkan provider unduhan atau sumber pencarian baru';
@override
String get tutorialExtensionsTip3 =>
'Dapatkan lirik, metadata lebih baik, dan fitur lainnya';
@override
String get tutorialSettingsTitle => 'Sesuaikan Pengalaman Anda';
@override
String get tutorialSettingsDesc =>
'Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.';
@override
String get tutorialSettingsTip1 =>
'Ubah lokasi unduhan dan organisasi folder';
@override
String get tutorialSettingsTip2 =>
'Atur kualitas audio dan preferensi format default';
@override
String get tutorialSettingsTip3 => 'Sesuaikan tema dan tampilan aplikasi';
@override
String get tutorialReadyMessage =>
'Anda siap! Mulai unduh musik favorit Anda sekarang.';
@override
String get tutorialExample => 'CONTOH';
@override
String get libraryForceFullScan => 'Pindai Ulang Penuh';
@override
String get libraryForceFullScanSubtitle =>
'Pindai ulang semua file, abaikan cache';
@override
String get cleanupOrphanedDownloads => 'Bersihkan Entri Unduhan Tidak Valid';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Hapus entri riwayat untuk file yang tidak ada lagi';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Menghapus $count entri unduhan tidak valid dari riwayat';
}
@override
String get cleanupOrphanedDownloadsNone =>
'Tidak ada entri unduhan tidak valid';
@override
String get cacheTitle => 'Penyimpanan & Cache';
@override
String get cacheSummaryTitle => 'Ringkasan cache';
@override
String get cacheSummarySubtitle =>
'Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimasi penggunaan cache: $size';
}
@override
String get cacheSectionStorage => 'Data Cache';
@override
String get cacheSectionMaintenance => 'Perawatan';
@override
String get cacheAppDirectory => 'Direktori cache aplikasi';
@override
String get cacheAppDirectoryDesc =>
'Respons HTTP, data WebView, dan data sementara aplikasi.';
@override
String get cacheTempDirectory => 'Direktori sementara';
@override
String get cacheTempDirectoryDesc =>
'File sementara dari proses download dan konversi audio.';
@override
String get cacheCoverImage => 'Cache gambar cover';
@override
String get cacheCoverImageDesc =>
'Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.';
@override
String get cacheLibraryCover => 'Cache cover library';
@override
String get cacheLibraryCoverDesc =>
'Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.';
@override
String get cacheExploreFeed => 'Cache feed Explore';
@override
String get cacheExploreFeedDesc =>
'Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.';
@override
String get cacheTrackLookup => 'Cache pencocokan lagu';
@override
String get cacheTrackLookupDesc =>
'Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.';
@override
String get cacheCleanupUnusedDesc =>
'Hapus entri riwayat download dan library yang filenya sudah tidak ada.';
@override
String get cacheNoData => 'Tidak ada data cache';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size dalam $count file';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entri';
}
@override
String cacheClearSuccess(String target) {
return 'Berhasil dibersihkan: $target';
}
@override
String get cacheClearConfirmTitle => 'Bersihkan cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'Ini akan membersihkan data cache untuk $target. File musik yang sudah diunduh tidak akan dihapus.';
}
@override
String get cacheClearAllConfirmTitle => 'Bersihkan semua cache?';
@override
String get cacheClearAllConfirmMessage =>
'Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.';
@override
String get cacheClearAll => 'Bersihkan semua cache';
@override
String get cacheCleanupUnused => 'Bersihkan data tidak terpakai';
@override
String get cacheCleanupUnusedSubtitle =>
'Hapus riwayat unduhan yatim dan entri library yang file-nya hilang';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Pembersihan selesai: $downloadCount unduhan yatim, $libraryCount entri library hilang';
}
@override
String get cacheRefreshStats => 'Segarkan statistik';
@override
String get trackSaveCoverArt => 'Simpan Cover Art';
@override
String get trackSaveCoverArtSubtitle =>
'Simpan cover album sebagai file .jpg';
@override
String get trackSaveLyrics => 'Simpan Lirik (.lrc)';
@override
String get trackSaveLyricsSubtitle =>
'Ambil dan simpan lirik sebagai file .lrc';
@override
String get trackSaveLyricsProgress => 'Menyimpan lirik...';
@override
String get trackReEnrich => 'Perkaya Ulang Metadata';
@override
String get trackReEnrichSubtitle =>
'Tanamkan ulang metadata tanpa mengunduh ulang';
@override
String get trackReEnrichOnlineSubtitle =>
'Cari metadata dari internet dan tanamkan ke file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art disimpan ke $fileName';
}
@override
String get trackCoverNoSource => 'Tidak ada sumber cover art';
@override
String trackLyricsSaved(String fileName) {
return 'Lirik disimpan ke $fileName';
}
@override
String get trackReEnrichProgress => 'Memperkaya ulang metadata...';
@override
String get trackReEnrichSearching => 'Mencari metadata dari internet...';
@override
String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang';
@override
String get trackReEnrichFfmpegFailed =>
'Gagal menanamkan metadata via FFmpeg';
@override
String trackSaveFailed(String error) {
return 'Gagal: $error';
}
@override
String get trackConvertFormat => 'Konversi Format';
@override
String get trackConvertFormatSubtitle => 'Konversi ke MP3 atau Opus';
@override
String get trackConvertTitle => 'Konversi Audio';
@override
String get trackConvertTargetFormat => 'Format Tujuan';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Konfirmasi Konversi';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Konversi dari $sourceFormat ke $targetFormat pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
}
@override
String get trackConvertConverting => 'Mengkonversi audio...';
@override
String trackConvertSuccess(String format) {
return 'Berhasil dikonversi ke $format';
}
@override
String get trackConvertFailed => 'Konversi gagal';
} }
File diff suppressed because it is too large Load Diff
+799 -12
View File
@@ -18,6 +18,9 @@ class AppLocalizationsKo 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 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 +457,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';
@@ -470,6 +471,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -484,6 +489,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.';
@@ -680,6 +692,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';
@@ -935,6 +951,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';
@@ -1618,6 +1639,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,25 +1877,45 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
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';
@@ -1878,6 +1928,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';
@@ -1957,6 +2029,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';
@@ -2006,6 +2111,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2073,6 +2185,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';
@@ -2148,4 +2266,673 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'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';
} }
+799 -12
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';
@@ -470,6 +471,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -484,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.';
@@ -680,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';
@@ -935,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';
@@ -1618,6 +1639,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,25 +1877,45 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
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';
@@ -1878,6 +1928,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';
@@ -1957,6 +2029,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';
@@ -2006,6 +2111,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2073,6 +2185,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';
@@ -2148,4 +2266,673 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+321 -76
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Suchverlauf...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Einstellungen", "settingsTitle": "Einstellungen",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Übersetzer",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Besonderer Dank", "aboutSpecialThanks": "Besonderer Dank",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,18 +552,30 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Kanal",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Ankündigungen und Updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Mit anderen Nutzern chatten",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Sozial",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
}, },
"aboutBuyMeCoffee": "Spendiere mir einen Kaffee",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Unterstütze die Entwicklung auf Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
"aboutApp": "App", "aboutApp": "App",
"@aboutApp": { "@aboutApp": {
"description": "Section for app info" "description": "Section for app info"
@@ -588,7 +608,7 @@
"@aboutDabMusicDesc": { "@aboutDabMusicDesc": {
"description": "Credit for DAB Music API" "description": "Credit for DAB Music API"
}, },
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -596,7 +616,7 @@
"@albumTitle": { "@albumTitle": {
"description": "Album screen title" "description": "Album screen title"
}, },
"albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", "albumTracks": "{count, plural,=1{1 Song} other{{count} Songs}}",
"@albumTracks": { "@albumTracks": {
"description": "Album track count", "description": "Album track count",
"placeholders": { "placeholders": {
@@ -605,11 +625,11 @@
} }
} }
}, },
"albumDownloadAll": "Download All", "albumDownloadAll": "Alle Herunterladen",
"@albumDownloadAll": { "@albumDownloadAll": {
"description": "Button to download all tracks" "description": "Button to download all tracks"
}, },
"albumDownloadRemaining": "Download Remaining", "albumDownloadRemaining": "Downloads verbleibend",
"@albumDownloadRemaining": { "@albumDownloadRemaining": {
"description": "Button to download remaining tracks" "description": "Button to download remaining tracks"
}, },
@@ -617,11 +637,11 @@
"@playlistTitle": { "@playlistTitle": {
"description": "Playlist screen title" "description": "Playlist screen title"
}, },
"artistTitle": "Artist", "artistTitle": "Künstler",
"@artistTitle": { "@artistTitle": {
"description": "Artist screen title" "description": "Artist screen title"
}, },
"artistAlbums": "Albums", "artistAlbums": "Alben",
"@artistAlbums": { "@artistAlbums": {
"description": "Section header for artist albums" "description": "Section header for artist albums"
}, },
@@ -629,11 +649,11 @@
"@artistSingles": { "@artistSingles": {
"description": "Section header for singles/EPs" "description": "Section header for singles/EPs"
}, },
"artistCompilations": "Compilations", "artistCompilations": "Zusammenstellungen",
"@artistCompilations": { "@artistCompilations": {
"description": "Section header for compilations" "description": "Section header for compilations"
}, },
"artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", "artistReleases": "{count, plural,=1{1 Veröffentlichung} other{{count} Veröffentlichungen}}",
"@artistReleases": { "@artistReleases": {
"description": "Artist release count", "description": "Artist release count",
"placeholders": { "placeholders": {
@@ -642,11 +662,11 @@
} }
} }
}, },
"artistPopular": "Popular", "artistPopular": "Beliebt",
"@artistPopular": { "@artistPopular": {
"description": "Section header for popular/top tracks" "description": "Section header for popular/top tracks"
}, },
"artistMonthlyListeners": "{count} monthly listeners", "artistMonthlyListeners": "{count} monatliche Hörer",
"@artistMonthlyListeners": { "@artistMonthlyListeners": {
"description": "Monthly listener count display", "description": "Monthly listener count display",
"placeholders": { "placeholders": {
@@ -656,11 +676,11 @@
} }
} }
}, },
"trackMetadataTitle": "Track Info", "trackMetadataTitle": "Titel Info",
"@trackMetadataTitle": { "@trackMetadataTitle": {
"description": "Track metadata screen title" "description": "Track metadata screen title"
}, },
"trackMetadataArtist": "Artist", "trackMetadataArtist": "Künstler",
"@trackMetadataArtist": { "@trackMetadataArtist": {
"description": "Metadata field - artist name" "description": "Metadata field - artist name"
}, },
@@ -668,111 +688,111 @@
"@trackMetadataAlbum": { "@trackMetadataAlbum": {
"description": "Metadata field - album name" "description": "Metadata field - album name"
}, },
"trackMetadataDuration": "Duration", "trackMetadataDuration": "Länge",
"@trackMetadataDuration": { "@trackMetadataDuration": {
"description": "Metadata field - track length" "description": "Metadata field - track length"
}, },
"trackMetadataQuality": "Quality", "trackMetadataQuality": "Qualität",
"@trackMetadataQuality": { "@trackMetadataQuality": {
"description": "Metadata field - audio quality" "description": "Metadata field - audio quality"
}, },
"trackMetadataPath": "File Path", "trackMetadataPath": "Dateipfad",
"@trackMetadataPath": { "@trackMetadataPath": {
"description": "Metadata field - file location" "description": "Metadata field - file location"
}, },
"trackMetadataDownloadedAt": "Downloaded", "trackMetadataDownloadedAt": "Heruntergeladen",
"@trackMetadataDownloadedAt": { "@trackMetadataDownloadedAt": {
"description": "Metadata field - download date" "description": "Metadata field - download date"
}, },
"trackMetadataService": "Service", "trackMetadataService": "Anbieter",
"@trackMetadataService": { "@trackMetadataService": {
"description": "Metadata field - download service used" "description": "Metadata field - download service used"
}, },
"trackMetadataPlay": "Play", "trackMetadataPlay": "Abspielen",
"@trackMetadataPlay": { "@trackMetadataPlay": {
"description": "Action button - play track" "description": "Action button - play track"
}, },
"trackMetadataShare": "Share", "trackMetadataShare": "Teilen",
"@trackMetadataShare": { "@trackMetadataShare": {
"description": "Action button - share track" "description": "Action button - share track"
}, },
"trackMetadataDelete": "Delete", "trackMetadataDelete": "Löschen",
"@trackMetadataDelete": { "@trackMetadataDelete": {
"description": "Action button - delete track" "description": "Action button - delete track"
}, },
"trackMetadataRedownload": "Re-download", "trackMetadataRedownload": "Erneut herunterladen",
"@trackMetadataRedownload": { "@trackMetadataRedownload": {
"description": "Action button - download again" "description": "Action button - download again"
}, },
"trackMetadataOpenFolder": "Open Folder", "trackMetadataOpenFolder": "Ordner öffnen",
"@trackMetadataOpenFolder": { "@trackMetadataOpenFolder": {
"description": "Action button - open containing folder" "description": "Action button - open containing folder"
}, },
"setupTitle": "Welcome to SpotiFLAC", "setupTitle": "Willkommen bei SpotiFLAC",
"@setupTitle": { "@setupTitle": {
"description": "Setup wizard title" "description": "Setup wizard title"
}, },
"setupSubtitle": "Let's get you started", "setupSubtitle": "Los geht's",
"@setupSubtitle": { "@setupSubtitle": {
"description": "Setup wizard subtitle" "description": "Setup wizard subtitle"
}, },
"setupStoragePermission": "Storage Permission", "setupStoragePermission": "Speicherberechtigung",
"@setupStoragePermission": { "@setupStoragePermission": {
"description": "Storage permission step title" "description": "Storage permission step title"
}, },
"setupStoragePermissionSubtitle": "Required to save downloaded files", "setupStoragePermissionSubtitle": "Benötigt um heruntergeladene Dateien zu Speichern",
"@setupStoragePermissionSubtitle": { "@setupStoragePermissionSubtitle": {
"description": "Explanation for storage permission" "description": "Explanation for storage permission"
}, },
"setupStoragePermissionGranted": "Permission granted", "setupStoragePermissionGranted": "Berechtigung erteilt",
"@setupStoragePermissionGranted": { "@setupStoragePermissionGranted": {
"description": "Status when permission granted" "description": "Status when permission granted"
}, },
"setupStoragePermissionDenied": "Permission denied", "setupStoragePermissionDenied": "Berechtigung verweigert",
"@setupStoragePermissionDenied": { "@setupStoragePermissionDenied": {
"description": "Status when permission denied" "description": "Status when permission denied"
}, },
"setupGrantPermission": "Grant Permission", "setupGrantPermission": "Berechtigung erlauben",
"@setupGrantPermission": { "@setupGrantPermission": {
"description": "Button to request permission" "description": "Button to request permission"
}, },
"setupDownloadLocation": "Download Location", "setupDownloadLocation": "Speicherort",
"@setupDownloadLocation": { "@setupDownloadLocation": {
"description": "Download folder step title" "description": "Download folder step title"
}, },
"setupChooseFolder": "Choose Folder", "setupChooseFolder": "Ordner wählen",
"@setupChooseFolder": { "@setupChooseFolder": {
"description": "Button to pick folder" "description": "Button to pick folder"
}, },
"setupContinue": "Continue", "setupContinue": "Fortfahren",
"@setupContinue": { "@setupContinue": {
"description": "Continue to next step button" "description": "Continue to next step button"
}, },
"setupSkip": "Skip for now", "setupSkip": "Vorerst überspringen",
"@setupSkip": { "@setupSkip": {
"description": "Skip current step button" "description": "Skip current step button"
}, },
"setupStorageAccessRequired": "Storage Access Required", "setupStorageAccessRequired": "Speicherzugriff erforderlich",
"@setupStorageAccessRequired": { "@setupStorageAccessRequired": {
"description": "Title when storage access needed" "description": "Title when storage access needed"
}, },
"setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", "setupStorageAccessMessage": "SpotiFLAC benötigt die Berechtigung \"Auf alle Dateien zugreifen\", um Musikdateien in deinen gewählten Ordner zu speichern.",
"@setupStorageAccessMessage": { "@setupStorageAccessMessage": {
"description": "Explanation for storage access" "description": "Explanation for storage access"
}, },
"setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "setupStorageAccessMessageAndroid11": "Android 11+ benötigt die Berechtigung „Auf alle Dateien“, um Dateien im ausgewählten Download-Ordner zu speichern.",
"@setupStorageAccessMessageAndroid11": { "@setupStorageAccessMessageAndroid11": {
"description": "Android 11+ specific explanation" "description": "Android 11+ specific explanation"
}, },
"setupOpenSettings": "Open Settings", "setupOpenSettings": "Einstellungen öffnen",
"@setupOpenSettings": { "@setupOpenSettings": {
"description": "Button to open system settings" "description": "Button to open system settings"
}, },
"setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.", "setupPermissionDeniedMessage": "Berechtigung verweigert. Bitte erteilen Sie alle Berechtigungen um fortzufahren.",
"@setupPermissionDeniedMessage": { "@setupPermissionDeniedMessage": {
"description": "Error when permission denied" "description": "Error when permission denied"
}, },
"setupPermissionRequired": "{permissionType} Permission Required", "setupPermissionRequired": "{permissionType} Zugriff verweigert",
"@setupPermissionRequired": { "@setupPermissionRequired": {
"description": "Generic permission required title", "description": "Generic permission required title",
"placeholders": { "placeholders": {
@@ -782,7 +802,7 @@
} }
} }
}, },
"setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.", "setupPermissionRequiredMessage": "{permissionType} Berechtigung ist erforderlich für\ndie beste Benutzererfahrung. Für kannst dies später in den Einstellungen ändern.",
"@setupPermissionRequiredMessage": { "@setupPermissionRequiredMessage": {
"description": "Generic permission required message", "description": "Generic permission required message",
"placeholders": { "placeholders": {
@@ -791,63 +811,63 @@
} }
} }
}, },
"setupSelectDownloadFolder": "Select Download Folder", "setupSelectDownloadFolder": "Wähle Download-Ordner aus",
"@setupSelectDownloadFolder": { "@setupSelectDownloadFolder": {
"description": "Folder selection step title" "description": "Folder selection step title"
}, },
"setupUseDefaultFolder": "Use Default Folder?", "setupUseDefaultFolder": "Als Standardordner verwenden?",
"@setupUseDefaultFolder": { "@setupUseDefaultFolder": {
"description": "Dialog title for default folder" "description": "Dialog title for default folder"
}, },
"setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?", "setupNoFolderSelected": "Kein Ordner ausgewählt. Soll der Standard-Musikordner verwendet werden?",
"@setupNoFolderSelected": { "@setupNoFolderSelected": {
"description": "Prompt when no folder selected" "description": "Prompt when no folder selected"
}, },
"setupUseDefault": "Use Default", "setupUseDefault": "Standart benutzen",
"@setupUseDefault": { "@setupUseDefault": {
"description": "Button to use default folder" "description": "Button to use default folder"
}, },
"setupDownloadLocationTitle": "Download Location", "setupDownloadLocationTitle": "Speicherort",
"@setupDownloadLocationTitle": { "@setupDownloadLocationTitle": {
"description": "Download location dialog title" "description": "Download location dialog title"
}, },
"setupDownloadLocationIosMessage": "On iOS, downloads are saved to the app's Documents folder. You can access them via the Files app.", "setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Sie können sie über die Datei-App aufrufen.",
"@setupDownloadLocationIosMessage": { "@setupDownloadLocationIosMessage": {
"description": "iOS-specific folder info" "description": "iOS-specific folder info"
}, },
"setupAppDocumentsFolder": "App Documents Folder", "setupAppDocumentsFolder": "App-Dokumentenordner",
"@setupAppDocumentsFolder": { "@setupAppDocumentsFolder": {
"description": "iOS documents folder option" "description": "iOS documents folder option"
}, },
"setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app", "setupAppDocumentsFolderSubtitle": "Empfohlen - zugänglich über die Datei-App",
"@setupAppDocumentsFolderSubtitle": { "@setupAppDocumentsFolderSubtitle": {
"description": "Subtitle for documents folder" "description": "Subtitle for documents folder"
}, },
"setupChooseFromFiles": "Choose from Files", "setupChooseFromFiles": "Aus Dateien auswählen",
"@setupChooseFromFiles": { "@setupChooseFromFiles": {
"description": "iOS file picker option" "description": "iOS file picker option"
}, },
"setupChooseFromFilesSubtitle": "Select iCloud or other location", "setupChooseFromFilesSubtitle": "Wählen Sie iCloud oder einen anderen Ort",
"@setupChooseFromFilesSubtitle": { "@setupChooseFromFilesSubtitle": {
"description": "Subtitle for file picker" "description": "Subtitle for file picker"
}, },
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", "setupIosEmptyFolderWarning": "iOS-Einschränkung: Leere Ordner können nicht ausgewählt werden. Wählen Sie einen Ordner mit mindestens einer Datei.",
"@setupIosEmptyFolderWarning": { "@setupIosEmptyFolderWarning": {
"description": "iOS folder selection warning" "description": "iOS folder selection warning"
}, },
"setupDownloadInFlac": "Download Spotify tracks in FLAC", "setupDownloadInFlac": "Spotify Titel in FLAC herunterladen",
"@setupDownloadInFlac": { "@setupDownloadInFlac": {
"description": "App tagline in setup" "description": "App tagline in setup"
}, },
"setupStepStorage": "Storage", "setupStepStorage": "Speicherort",
"@setupStepStorage": { "@setupStepStorage": {
"description": "Setup step indicator - storage" "description": "Setup step indicator - storage"
}, },
"setupStepNotification": "Notification", "setupStepNotification": "Benachrichtigung",
"@setupStepNotification": { "@setupStepNotification": {
"description": "Setup step indicator - notification" "description": "Setup step indicator - notification"
}, },
"setupStepFolder": "Folder", "setupStepFolder": "Ordner",
"@setupStepFolder": { "@setupStepFolder": {
"description": "Setup step indicator - folder" "description": "Setup step indicator - folder"
}, },
@@ -855,55 +875,55 @@
"@setupStepSpotify": { "@setupStepSpotify": {
"description": "Setup step indicator - Spotify API" "description": "Setup step indicator - Spotify API"
}, },
"setupStepPermission": "Permission", "setupStepPermission": "Berechtigung",
"@setupStepPermission": { "@setupStepPermission": {
"description": "Setup step indicator - permission" "description": "Setup step indicator - permission"
}, },
"setupStorageGranted": "Storage Permission Granted!", "setupStorageGranted": "Speicherberechtigung erlaubt!",
"@setupStorageGranted": { "@setupStorageGranted": {
"description": "Success message for storage permission" "description": "Success message for storage permission"
}, },
"setupStorageRequired": "Storage Permission Required", "setupStorageRequired": "Speicherzugriff erforderlich",
"@setupStorageRequired": { "@setupStorageRequired": {
"description": "Title when storage permission needed" "description": "Title when storage permission needed"
}, },
"setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.", "setupStorageDescription": "SpotiFLAC benötigt Speicherrechte, um die heruntergeladenen Musikdateien zu speichern.",
"@setupStorageDescription": { "@setupStorageDescription": {
"description": "Explanation for storage permission" "description": "Explanation for storage permission"
}, },
"setupNotificationGranted": "Notification Permission Granted!", "setupNotificationGranted": "Benachrichtigungs-Berechtigung erteilt",
"@setupNotificationGranted": { "@setupNotificationGranted": {
"description": "Success message for notification permission" "description": "Success message for notification permission"
}, },
"setupNotificationEnable": "Enable Notifications", "setupNotificationEnable": "Benachrichtigungen aktivieren",
"@setupNotificationEnable": { "@setupNotificationEnable": {
"description": "Button to enable notifications" "description": "Button to enable notifications"
}, },
"setupNotificationDescription": "Get notified when downloads complete or require attention.", "setupNotificationDescription": "Benachrichtigt werden, wenn Downloads abgeschlossen sind.",
"@setupNotificationDescription": { "@setupNotificationDescription": {
"description": "Explanation for notifications" "description": "Explanation for notifications"
}, },
"setupFolderSelected": "Download Folder Selected!", "setupFolderSelected": "Download Ordner ausgewählt!",
"@setupFolderSelected": { "@setupFolderSelected": {
"description": "Success message for folder selection" "description": "Success message for folder selection"
}, },
"setupFolderChoose": "Choose Download Folder", "setupFolderChoose": "Speicherort auwählen",
"@setupFolderChoose": { "@setupFolderChoose": {
"description": "Button to choose folder" "description": "Button to choose folder"
}, },
"setupFolderDescription": "Select a folder where your downloaded music will be saved.", "setupFolderDescription": "Wählen Sie einen Ordner, in dem Ihre heruntergeladene Musik gespeichert wird.",
"@setupFolderDescription": { "@setupFolderDescription": {
"description": "Explanation for folder selection" "description": "Explanation for folder selection"
}, },
"setupChangeFolder": "Change Folder", "setupChangeFolder": "Ordner ändern",
"@setupChangeFolder": { "@setupChangeFolder": {
"description": "Button to change selected folder" "description": "Button to change selected folder"
}, },
"setupSelectFolder": "Select Folder", "setupSelectFolder": "Ordner wählen",
"@setupSelectFolder": { "@setupSelectFolder": {
"description": "Button to select folder" "description": "Button to select folder"
}, },
"setupSpotifyApiOptional": "Spotify API (Optional)", "setupSpotifyApiOptional": "Spotify-API (optional)",
"@setupSpotifyApiOptional": { "@setupSpotifyApiOptional": {
"description": "Spotify API step title" "description": "Spotify API step title"
}, },
@@ -1122,6 +1142,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1880,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2062,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2094,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2417,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2625,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2689,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2738,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+613 -18
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",
@@ -334,6 +334,8 @@
"@aboutBinimumDesc": {"description": "Credit description for binimum"}, "@aboutBinimumDesc": {"description": "Credit description for binimum"},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"@aboutSachinsenalDesc": {"description": "Credit description for sachinsenal0x64"}, "@aboutSachinsenalDesc": {"description": "Credit description for sachinsenal0x64"},
"aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!",
"@aboutSjdonadoDesc": {"description": "Credit description for sjdonado"},
"aboutDoubleDouble": "DoubleDouble", "aboutDoubleDouble": "DoubleDouble",
"@aboutDoubleDouble": {"description": "Name of Amazon API service - DO NOT TRANSLATE"}, "@aboutDoubleDouble": {"description": "Name of Amazon API service - DO NOT TRANSLATE"},
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!",
@@ -342,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"},
@@ -479,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",
@@ -666,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",
@@ -1188,6 +1203,12 @@
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"}, "@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
"trackLyricsLoadFailed": "Failed to load lyrics", "trackLyricsLoadFailed": "Failed to load lyrics",
"@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"}, "@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"},
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {"description": "Action - embed lyrics into audio file"},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {"description": "Snackbar - lyrics saved to file"},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {"description": "Message when track is instrumental (no lyrics)"},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": {"description": "Snackbar - content copied"}, "@trackCopiedToClipboard": {"description": "Snackbar - content copied"},
"trackDeleteConfirmTitle": "Remove from device?", "trackDeleteConfirmTitle": "Remove from device?",
@@ -1367,18 +1388,30 @@
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"}, "@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz", "qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
"@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"}, "@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"},
"qualityMp3": "MP3", "qualityLossy": "Lossy",
"@qualityMp3": {"description": "Quality option - MP3 lossy format"}, "@qualityLossy": {"description": "Quality option - lossy format (MP3/Opus)"},
"qualityMp3Subtitle": "320kbps (converted from FLAC)", "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {"description": "Technical spec for MP3"}, "@qualityLossyMp3Subtitle": {"description": "Technical spec for lossy MP3"},
"enableMp3Option": "Enable MP3 Option", "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)",
"@enableMp3Option": {"description": "Setting - enable MP3 quality option"}, "@qualityLossyOpusSubtitle": {"description": "Technical spec for lossy Opus"},
"enableMp3OptionSubtitleOn": "MP3 quality option is available", "enableLossyOption": "Enable Lossy Option",
"@enableMp3OptionSubtitleOn": {"description": "Subtitle when MP3 is enabled"}, "@enableLossyOption": {"description": "Setting - enable lossy quality option"},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3", "enableLossyOptionSubtitleOn": "Lossy quality option is available",
"@enableMp3OptionSubtitleOff": {"description": "Subtitle when MP3 is disabled"}, "@enableLossyOptionSubtitleOn": {"description": "Subtitle when lossy is enabled"},
"enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format",
"@enableLossyOptionSubtitleOff": {"description": "Subtitle when lossy is disabled"},
"lossyFormat": "Lossy Format",
"@lossyFormat": {"description": "Setting - choose lossy format"},
"lossyFormatDescription": "Choose the lossy format for conversion",
"@lossyFormatDescription": {"description": "Description for lossy format picker"},
"lossyFormatMp3Subtitle": "320kbps, best compatibility",
"@lossyFormatMp3Subtitle": {"description": "MP3 format description"},
"lossyFormatOpusSubtitle": "128kbps, better quality at smaller size",
"@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"},
@@ -1388,6 +1421,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",
@@ -1440,10 +1485,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",
@@ -1477,6 +1544,10 @@
"@albumFolderYearAlbum": {"description": "Album folder option with year"}, "@albumFolderYearAlbum": {"description": "Album folder option with year"},
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", "albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
"@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"}, "@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"},
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {"description": "Album folder option with singles inside artist"},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {"description": "Folder structure example"},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"}, "@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"},
@@ -1535,6 +1606,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": {
@@ -1624,5 +1701,523 @@
"discographyNoAlbums": "No albums available", "discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {"description": "Error - no albums found for artist"}, "@discographyNoAlbums": {"description": "Error - no albums found for artist"},
"discographyFailedToFetch": "Failed to fetch some albums", "discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"} "@discographyFailedToFetch": {"description": "Error - some albums failed to load"},
"sectionStorageAccess": "Storage Access",
"@sectionStorageAccess": {"description": "Section header for storage access settings"},
"allFilesAccess": "All Files Access",
"@allFilesAccess": {"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"},
"allFilesAccessEnabledSubtitle": "Can write to any folder",
"@allFilesAccessEnabledSubtitle": {"description": "Subtitle when all files access is enabled"},
"allFilesAccessDisabledSubtitle": "Limited to media folders only",
"@allFilesAccessDisabledSubtitle": {"description": "Subtitle when all files access is disabled"},
"allFilesAccessDescription": "Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.",
"@allFilesAccessDescription": {"description": "Description explaining when to enable all files access"},
"allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.",
"@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"},
"allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage 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"
-8
View File
@@ -548,14 +548,6 @@
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
}, },
"aboutBuyMeCoffee": "Invítame a un café",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Apoyar el desarrollo en Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
"aboutApp": "Aplicación", "aboutApp": "Aplicación",
"@aboutApp": { "@aboutApp": {
"description": "Section for app info" "description": "Section for app info"
+253 -8
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,18 +552,30 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@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"
@@ -1122,6 +1142,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1880,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2062,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2094,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2417,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2625,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2689,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2738,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+261 -16
View File
@@ -1,23 +1,23 @@
{ {
"@@locale": "hi", "@@locale": "hi",
"@@last_modified": "2026-01-16", "@@last_modified": "2026-01-16",
"appName": "SpotiFLAC", "appName": "SpotiFlac",
"@appName": { "@appName": {
"description": "App name - DO NOT TRANSLATE" "description": "App name - DO NOT TRANSLATE"
}, },
"appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "appDescription": "स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।",
"@appDescription": { "@appDescription": {
"description": "App description shown in about page" "description": "App description shown in about page"
}, },
"navHome": "Home", "navHome": "होम",
"@navHome": { "@navHome": {
"description": "Bottom navigation - Home tab" "description": "Bottom navigation - Home tab"
}, },
"navHistory": "History", "navHistory": "इतिहास",
"@navHistory": { "@navHistory": {
"description": "Bottom navigation - History tab" "description": "Bottom navigation - History tab"
}, },
"navSettings": "Settings", "navSettings": "विकल्प",
"@navSettings": { "@navSettings": {
"description": "Bottom navigation - Settings tab" "description": "Bottom navigation - Settings tab"
}, },
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -219,7 +223,7 @@
"@quality128": { "@quality128": {
"description": "Audio quality option - 128kbps MP3" "description": "Audio quality option - 128kbps MP3"
}, },
"appearanceTitle": "Appearance", "appearanceTitle": "दिखावट",
"@appearanceTitle": { "@appearanceTitle": {
"description": "Appearance settings page title" "description": "Appearance settings page title"
}, },
@@ -239,11 +243,11 @@
"@appearanceThemeDark": { "@appearanceThemeDark": {
"description": "Dark theme" "description": "Dark theme"
}, },
"appearanceDynamicColor": "Dynamic Color", "appearanceDynamicColor": "डायनेमिक रंग",
"@appearanceDynamicColor": { "@appearanceDynamicColor": {
"description": "Material You dynamic colors" "description": "Material You dynamic colors"
}, },
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper", "appearanceDynamicColorSubtitle": "वॉलपेपर से रंग इस्तेमाल करें",
"@appearanceDynamicColorSubtitle": { "@appearanceDynamicColorSubtitle": {
"description": "Subtitle for dynamic color" "description": "Subtitle for dynamic color"
}, },
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,18 +552,30 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@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"
@@ -1122,6 +1142,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1880,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2062,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2094,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2417,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2625,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2689,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2738,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+3245 -705
View File
File diff suppressed because it is too large Load Diff
+634 -389
View File
File diff suppressed because it is too large Load Diff
+253 -8
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,18 +552,30 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@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"
@@ -1122,6 +1142,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1880,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2062,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2094,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2417,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2625,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2689,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2738,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+253 -8
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,18 +552,30 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@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"
@@ -1122,6 +1142,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1880,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2062,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2094,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2417,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2625,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2689,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2738,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
-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"
+141 -149
View File
@@ -548,14 +548,6 @@
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
}, },
"aboutBuyMeCoffee": "Compre-me um café",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Apoie o desenvolvimento na Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
"aboutApp": "Aplicativo", "aboutApp": "Aplicativo",
"@aboutApp": { "@aboutApp": {
"description": "Section for app info" "description": "Section for app info"
@@ -835,19 +827,19 @@
"@setupIosEmptyFolderWarning": { "@setupIosEmptyFolderWarning": {
"description": "iOS folder selection warning" "description": "iOS folder selection warning"
}, },
"setupDownloadInFlac": "Download Spotify tracks in FLAC", "setupDownloadInFlac": "Baixe faixas do Spotify em FLAC",
"@setupDownloadInFlac": { "@setupDownloadInFlac": {
"description": "App tagline in setup" "description": "App tagline in setup"
}, },
"setupStepStorage": "Storage", "setupStepStorage": "Armazenamento",
"@setupStepStorage": { "@setupStepStorage": {
"description": "Setup step indicator - storage" "description": "Setup step indicator - storage"
}, },
"setupStepNotification": "Notification", "setupStepNotification": "Notificação",
"@setupStepNotification": { "@setupStepNotification": {
"description": "Setup step indicator - notification" "description": "Setup step indicator - notification"
}, },
"setupStepFolder": "Folder", "setupStepFolder": "Pasta",
"@setupStepFolder": { "@setupStepFolder": {
"description": "Setup step indicator - folder" "description": "Setup step indicator - folder"
}, },
@@ -855,19 +847,19 @@
"@setupStepSpotify": { "@setupStepSpotify": {
"description": "Setup step indicator - Spotify API" "description": "Setup step indicator - Spotify API"
}, },
"setupStepPermission": "Permission", "setupStepPermission": "Permissão",
"@setupStepPermission": { "@setupStepPermission": {
"description": "Setup step indicator - permission" "description": "Setup step indicator - permission"
}, },
"setupStorageGranted": "Storage Permission Granted!", "setupStorageGranted": "Permissão de Armazenamento Concedida!",
"@setupStorageGranted": { "@setupStorageGranted": {
"description": "Success message for storage permission" "description": "Success message for storage permission"
}, },
"setupStorageRequired": "Storage Permission Required", "setupStorageRequired": "Permissão de Armazenamento Necessária",
"@setupStorageRequired": { "@setupStorageRequired": {
"description": "Title when storage permission needed" "description": "Title when storage permission needed"
}, },
"setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.", "setupStorageDescription": "O SpotiFLAC precisa de permissão de armazenamento para salvar os seus arquivos de música baixados.",
"@setupStorageDescription": { "@setupStorageDescription": {
"description": "Explanation for storage permission" "description": "Explanation for storage permission"
}, },
@@ -1071,23 +1063,23 @@
"@dialogClearAllDownloads": { "@dialogClearAllDownloads": {
"description": "Dialog message - clear downloads confirmation" "description": "Dialog message - clear downloads confirmation"
}, },
"dialogRemoveFromDevice": "Remove from device?", "dialogRemoveFromDevice": "Remover do dispositivo?",
"@dialogRemoveFromDevice": { "@dialogRemoveFromDevice": {
"description": "Dialog title - delete file confirmation" "description": "Dialog title - delete file confirmation"
}, },
"dialogRemoveExtension": "Remove Extension", "dialogRemoveExtension": "Remover Extensão",
"@dialogRemoveExtension": { "@dialogRemoveExtension": {
"description": "Dialog title - uninstall extension" "description": "Dialog title - uninstall extension"
}, },
"dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.", "dialogRemoveExtensionMessage": "Tem certeza de que deseja remover esta extensão? Isso não pode ser desfeito.",
"@dialogRemoveExtensionMessage": { "@dialogRemoveExtensionMessage": {
"description": "Dialog message - uninstall confirmation" "description": "Dialog message - uninstall confirmation"
}, },
"dialogUninstallExtension": "Uninstall Extension?", "dialogUninstallExtension": "Desinstalar Extensão?",
"@dialogUninstallExtension": { "@dialogUninstallExtension": {
"description": "Dialog title - uninstall extension" "description": "Dialog title - uninstall extension"
}, },
"dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?", "dialogUninstallExtensionMessage": "Tem certeza de que deseja remover {extensionName}?",
"@dialogUninstallExtensionMessage": { "@dialogUninstallExtensionMessage": {
"description": "Dialog message - uninstall specific extension", "description": "Dialog message - uninstall specific extension",
"placeholders": { "placeholders": {
@@ -1096,19 +1088,19 @@
} }
} }
}, },
"dialogClearHistoryTitle": "Clear History", "dialogClearHistoryTitle": "Limpar Histórico",
"@dialogClearHistoryTitle": { "@dialogClearHistoryTitle": {
"description": "Dialog title - clear download history" "description": "Dialog title - clear download history"
}, },
"dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.", "dialogClearHistoryMessage": "Tem certeza de que deseja limpar todo o histórico de downloads? Isso não pode ser desfeito.",
"@dialogClearHistoryMessage": { "@dialogClearHistoryMessage": {
"description": "Dialog message - clear history confirmation" "description": "Dialog message - clear history confirmation"
}, },
"dialogDeleteSelectedTitle": "Delete Selected", "dialogDeleteSelectedTitle": "Apagar Selecionados",
"@dialogDeleteSelectedTitle": { "@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items" "description": "Dialog title - delete selected items"
}, },
"dialogDeleteSelectedMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from history?\n\nThis will also delete the files from storage.", "dialogDeleteSelectedMessage": "Apagar {count} {count, plural, =1{faixa} other{faixas}} do histórico?\n\nIsso também apagará os arquivos do armazenamento.",
"@dialogDeleteSelectedMessage": { "@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks", "description": "Dialog message - delete selected tracks",
"placeholders": { "placeholders": {
@@ -1117,11 +1109,11 @@
} }
} }
}, },
"dialogImportPlaylistTitle": "Import Playlist", "dialogImportPlaylistTitle": "Importar Playlist",
"@dialogImportPlaylistTitle": { "@dialogImportPlaylistTitle": {
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Encontradas {count} faixas no CSV. Adicionar à fila de download?",
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1130,7 +1122,7 @@
} }
} }
}, },
"snackbarAddedToQueue": "Added \"{trackName}\" to queue", "snackbarAddedToQueue": "\"{trackName}\" adicionada à fila",
"@snackbarAddedToQueue": { "@snackbarAddedToQueue": {
"description": "Snackbar - track added to download queue", "description": "Snackbar - track added to download queue",
"placeholders": { "placeholders": {
@@ -1139,7 +1131,7 @@
} }
} }
}, },
"snackbarAddedTracksToQueue": "Added {count} tracks to queue", "snackbarAddedTracksToQueue": "{count} faixas adicionadas à fila",
"@snackbarAddedTracksToQueue": { "@snackbarAddedTracksToQueue": {
"description": "Snackbar - multiple tracks added to queue", "description": "Snackbar - multiple tracks added to queue",
"placeholders": { "placeholders": {
@@ -1148,7 +1140,7 @@
} }
} }
}, },
"snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded", "snackbarAlreadyDownloaded": "\"{trackName}\" já foi baixada",
"@snackbarAlreadyDownloaded": { "@snackbarAlreadyDownloaded": {
"description": "Snackbar - track already exists", "description": "Snackbar - track already exists",
"placeholders": { "placeholders": {
@@ -1157,19 +1149,19 @@
} }
} }
}, },
"snackbarHistoryCleared": "History cleared", "snackbarHistoryCleared": "Histórico limpo",
"@snackbarHistoryCleared": { "@snackbarHistoryCleared": {
"description": "Snackbar - history deleted" "description": "Snackbar - history deleted"
}, },
"snackbarCredentialsSaved": "Credentials saved", "snackbarCredentialsSaved": "Credenciais salvas",
"@snackbarCredentialsSaved": { "@snackbarCredentialsSaved": {
"description": "Snackbar - Spotify credentials saved" "description": "Snackbar - Spotify credentials saved"
}, },
"snackbarCredentialsCleared": "Credentials cleared", "snackbarCredentialsCleared": "Credenciais removidas",
"@snackbarCredentialsCleared": { "@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed" "description": "Snackbar - Spotify credentials removed"
}, },
"snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}", "snackbarDeletedTracks": "{count} {count, plural, =1{faixa apagada} other{faixas apagadas}}",
"@snackbarDeletedTracks": { "@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted", "description": "Snackbar - tracks deleted",
"placeholders": { "placeholders": {
@@ -1178,7 +1170,7 @@
} }
} }
}, },
"snackbarCannotOpenFile": "Cannot open file: {error}", "snackbarCannotOpenFile": "Não foi possível abrir o arquivo: {error}",
"@snackbarCannotOpenFile": { "@snackbarCannotOpenFile": {
"description": "Snackbar - file open error", "description": "Snackbar - file open error",
"placeholders": { "placeholders": {
@@ -1187,15 +1179,15 @@
} }
} }
}, },
"snackbarFillAllFields": "Please fill all fields", "snackbarFillAllFields": "Por favor, preencha todos os campos",
"@snackbarFillAllFields": { "@snackbarFillAllFields": {
"description": "Snackbar - validation error" "description": "Snackbar - validation error"
}, },
"snackbarViewQueue": "View Queue", "snackbarViewQueue": "Ver Fila",
"@snackbarViewQueue": { "@snackbarViewQueue": {
"description": "Snackbar action - view download queue" "description": "Snackbar action - view download queue"
}, },
"snackbarFailedToLoad": "Failed to load: {error}", "snackbarFailedToLoad": "Falha ao carregar: {error}",
"@snackbarFailedToLoad": { "@snackbarFailedToLoad": {
"description": "Snackbar - loading error", "description": "Snackbar - loading error",
"placeholders": { "placeholders": {
@@ -1204,7 +1196,7 @@
} }
} }
}, },
"snackbarUrlCopied": "{platform} URL copied to clipboard", "snackbarUrlCopied": "URL do {platform} copiada para a área de transferência",
"@snackbarUrlCopied": { "@snackbarUrlCopied": {
"description": "Snackbar - URL copied", "description": "Snackbar - URL copied",
"placeholders": { "placeholders": {
@@ -1214,23 +1206,23 @@
} }
} }
}, },
"snackbarFileNotFound": "File not found", "snackbarFileNotFound": "Arquivo não encontrado",
"@snackbarFileNotFound": { "@snackbarFileNotFound": {
"description": "Snackbar - file doesn't exist" "description": "Snackbar - file doesn't exist"
}, },
"snackbarSelectExtFile": "Please select a .spotiflac-ext file", "snackbarSelectExtFile": "Por favor, selecione um arquivo .spotiflac-ext",
"@snackbarSelectExtFile": { "@snackbarSelectExtFile": {
"description": "Snackbar - wrong file type selected" "description": "Snackbar - wrong file type selected"
}, },
"snackbarProviderPrioritySaved": "Provider priority saved", "snackbarProviderPrioritySaved": "Prioridade de provedor salva",
"@snackbarProviderPrioritySaved": { "@snackbarProviderPrioritySaved": {
"description": "Snackbar - provider order saved" "description": "Snackbar - provider order saved"
}, },
"snackbarMetadataProviderSaved": "Metadata provider priority saved", "snackbarMetadataProviderSaved": "Prioridade de provedor de metadados salva",
"@snackbarMetadataProviderSaved": { "@snackbarMetadataProviderSaved": {
"description": "Snackbar - metadata provider order saved" "description": "Snackbar - metadata provider order saved"
}, },
"snackbarExtensionInstalled": "{extensionName} installed.", "snackbarExtensionInstalled": "{extensionName} instalada.",
"@snackbarExtensionInstalled": { "@snackbarExtensionInstalled": {
"description": "Snackbar - extension installed successfully", "description": "Snackbar - extension installed successfully",
"placeholders": { "placeholders": {
@@ -1239,7 +1231,7 @@
} }
} }
}, },
"snackbarExtensionUpdated": "{extensionName} updated.", "snackbarExtensionUpdated": "{extensionName} atualizada.",
"@snackbarExtensionUpdated": { "@snackbarExtensionUpdated": {
"description": "Snackbar - extension updated successfully", "description": "Snackbar - extension updated successfully",
"placeholders": { "placeholders": {
@@ -1248,23 +1240,23 @@
} }
} }
}, },
"snackbarFailedToInstall": "Failed to install extension", "snackbarFailedToInstall": "Falha ao instalar extensão",
"@snackbarFailedToInstall": { "@snackbarFailedToInstall": {
"description": "Snackbar - extension install error" "description": "Snackbar - extension install error"
}, },
"snackbarFailedToUpdate": "Failed to update extension", "snackbarFailedToUpdate": "Falha ao atualizar extensão",
"@snackbarFailedToUpdate": { "@snackbarFailedToUpdate": {
"description": "Snackbar - extension update error" "description": "Snackbar - extension update error"
}, },
"errorRateLimited": "Rate Limited", "errorRateLimited": "Taxa Limitada",
"@errorRateLimited": { "@errorRateLimited": {
"description": "Error title - too many requests" "description": "Error title - too many requests"
}, },
"errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.", "errorRateLimitedMessage": "Muitas solicitações. Por favor, aguarde um momento antes de pesquisar novamente.",
"@errorRateLimitedMessage": { "@errorRateLimitedMessage": {
"description": "Error message - rate limit explanation" "description": "Error message - rate limit explanation"
}, },
"errorFailedToLoad": "Failed to load {item}", "errorFailedToLoad": "Falha ao carregar {item}",
"@errorFailedToLoad": { "@errorFailedToLoad": {
"description": "Error message - loading failed", "description": "Error message - loading failed",
"placeholders": { "placeholders": {
@@ -1274,11 +1266,11 @@
} }
} }
}, },
"errorNoTracksFound": "No tracks found", "errorNoTracksFound": "Nenhuma faixa encontrada",
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorMissingExtensionSource": "Cannot load {item}: missing extension source", "errorMissingExtensionSource": "Não foi possível carregar {item}: fonte de extensão ausente",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
"placeholders": { "placeholders": {
@@ -1287,23 +1279,23 @@
} }
} }
}, },
"statusQueued": "Queued", "statusQueued": "Na Fila",
"@statusQueued": { "@statusQueued": {
"description": "Download status - waiting in queue" "description": "Download status - waiting in queue"
}, },
"statusDownloading": "Downloading", "statusDownloading": "Baixando",
"@statusDownloading": { "@statusDownloading": {
"description": "Download status - in progress" "description": "Download status - in progress"
}, },
"statusFinalizing": "Finalizing", "statusFinalizing": "Finalizando",
"@statusFinalizing": { "@statusFinalizing": {
"description": "Download status - writing metadata" "description": "Download status - writing metadata"
}, },
"statusCompleted": "Completed", "statusCompleted": "Concluído",
"@statusCompleted": { "@statusCompleted": {
"description": "Download status - finished" "description": "Download status - finished"
}, },
"statusFailed": "Failed", "statusFailed": "Falhou",
"@statusFailed": { "@statusFailed": {
"description": "Download status - error occurred" "description": "Download status - error occurred"
}, },
@@ -1735,19 +1727,19 @@
"@logNetworkErrorDescription": { "@logNetworkErrorDescription": {
"description": "Network error explanation" "description": "Network error explanation"
}, },
"logNetworkErrorSuggestion": "Check your internet connection", "logNetworkErrorSuggestion": "Verifique a sua conexão com a internet",
"@logNetworkErrorSuggestion": { "@logNetworkErrorSuggestion": {
"description": "Network error fix suggestion" "description": "Network error fix suggestion"
}, },
"logTrackNotFoundDescription": "Some tracks could not be found on download services", "logTrackNotFoundDescription": "Algumas faixas não foram encontradas nos serviços de download",
"@logTrackNotFoundDescription": { "@logTrackNotFoundDescription": {
"description": "Track not found explanation" "description": "Track not found explanation"
}, },
"logTrackNotFoundSuggestion": "The track may not be available in lossless quality", "logTrackNotFoundSuggestion": "A faixa pode não estar disponível em qualidade lossless",
"@logTrackNotFoundSuggestion": { "@logTrackNotFoundSuggestion": {
"description": "Track not found explanation" "description": "Track not found explanation"
}, },
"logTotalErrors": "Total errors: {count}", "logTotalErrors": "Total de erros: {count}",
"@logTotalErrors": { "@logTotalErrors": {
"description": "Error count display", "description": "Error count display",
"placeholders": { "placeholders": {
@@ -1756,7 +1748,7 @@
} }
} }
}, },
"logAffected": "Affected: {domains}", "logAffected": "Afetados: {domains}",
"@logAffected": { "@logAffected": {
"description": "Affected domains display", "description": "Affected domains display",
"placeholders": { "placeholders": {
@@ -1765,7 +1757,7 @@
} }
} }
}, },
"logEntriesFiltered": "Entries ({count} filtered)", "logEntriesFiltered": "Entradas ({count} filtradas)",
"@logEntriesFiltered": { "@logEntriesFiltered": {
"description": "Log count with filter active", "description": "Log count with filter active",
"placeholders": { "placeholders": {
@@ -1774,7 +1766,7 @@
} }
} }
}, },
"logEntries": "Entries ({count})", "logEntries": "Entradas ({count})",
"@logEntries": { "@logEntries": {
"description": "Total log count", "description": "Total log count",
"placeholders": { "placeholders": {
@@ -1783,11 +1775,11 @@
} }
} }
}, },
"credentialsTitle": "Spotify Credentials", "credentialsTitle": "Credenciais do Spotify",
"@credentialsTitle": { "@credentialsTitle": {
"description": "Credentials dialog title" "description": "Credentials dialog title"
}, },
"credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.", "credentialsDescription": "Insira o seu Client ID e Secret para usar a sua própria cota de aplicativo do Spotify.",
"@credentialsDescription": { "@credentialsDescription": {
"description": "Credentials dialog explanation" "description": "Credentials dialog explanation"
}, },
@@ -2001,35 +1993,35 @@
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
}, },
"trackCopyLyrics": "Copy lyrics", "trackCopyLyrics": "Copiar letras",
"@trackCopyLyrics": { "@trackCopyLyrics": {
"description": "Action - copy lyrics to clipboard" "description": "Action - copy lyrics to clipboard"
}, },
"trackLyricsNotAvailable": "Lyrics not available for this track", "trackLyricsNotAvailable": "Letras não disponíveis para esta faixa",
"@trackLyricsNotAvailable": { "@trackLyricsNotAvailable": {
"description": "Message when lyrics not found" "description": "Message when lyrics not found"
}, },
"trackLyricsTimeout": "Request timed out. Try again later.", "trackLyricsTimeout": "A solicitação expirou. Tente novamente mais tarde.",
"@trackLyricsTimeout": { "@trackLyricsTimeout": {
"description": "Message when lyrics request times out" "description": "Message when lyrics request times out"
}, },
"trackLyricsLoadFailed": "Failed to load lyrics", "trackLyricsLoadFailed": "Falha ao carregar letras",
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copiado para a área de transferência",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
}, },
"trackDeleteConfirmTitle": "Remove from device?", "trackDeleteConfirmTitle": "Remover do dispositivo?",
"@trackDeleteConfirmTitle": { "@trackDeleteConfirmTitle": {
"description": "Delete confirmation title" "description": "Delete confirmation title"
}, },
"trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.", "trackDeleteConfirmMessage": "Isso apagará permanentemente o arquivo baixado e o removerá do seu histórico.",
"@trackDeleteConfirmMessage": { "@trackDeleteConfirmMessage": {
"description": "Delete confirmation message" "description": "Delete confirmation message"
}, },
"trackCannotOpen": "Cannot open: {message}", "trackCannotOpen": "Não foi possível abrir: {message}",
"@trackCannotOpen": { "@trackCannotOpen": {
"description": "Error opening file", "description": "Error opening file",
"placeholders": { "placeholders": {
@@ -2038,15 +2030,15 @@
} }
} }
}, },
"dateToday": "Today", "dateToday": "Hoje",
"@dateToday": { "@dateToday": {
"description": "Relative date - today" "description": "Relative date - today"
}, },
"dateYesterday": "Yesterday", "dateYesterday": "Ontem",
"@dateYesterday": { "@dateYesterday": {
"description": "Relative date - yesterday" "description": "Relative date - yesterday"
}, },
"dateDaysAgo": "{count} days ago", "dateDaysAgo": "{count} dias",
"@dateDaysAgo": { "@dateDaysAgo": {
"description": "Relative date - days ago", "description": "Relative date - days ago",
"placeholders": { "placeholders": {
@@ -2055,7 +2047,7 @@
} }
} }
}, },
"dateWeeksAgo": "{count} weeks ago", "dateWeeksAgo": "{count} semanas",
"@dateWeeksAgo": { "@dateWeeksAgo": {
"description": "Relative date - weeks ago", "description": "Relative date - weeks ago",
"placeholders": { "placeholders": {
@@ -2064,7 +2056,7 @@
} }
} }
}, },
"dateMonthsAgo": "{count} months ago", "dateMonthsAgo": "{count} meses",
"@dateMonthsAgo": { "@dateMonthsAgo": {
"description": "Relative date - months ago", "description": "Relative date - months ago",
"placeholders": { "placeholders": {
@@ -2073,27 +2065,27 @@
} }
} }
}, },
"concurrentSequential": "Sequential", "concurrentSequential": "Sequencial",
"@concurrentSequential": { "@concurrentSequential": {
"description": "Download mode - one at a time" "description": "Download mode - one at a time"
}, },
"concurrentParallel2": "2 Parallel", "concurrentParallel2": "2 Paralelos",
"@concurrentParallel2": { "@concurrentParallel2": {
"description": "Download mode - 2 simultaneous" "description": "Download mode - 2 simultaneous"
}, },
"concurrentParallel3": "3 Parallel", "concurrentParallel3": "3 Paralelos",
"@concurrentParallel3": { "@concurrentParallel3": {
"description": "Download mode - 3 simultaneous" "description": "Download mode - 3 simultaneous"
}, },
"tapToSeeError": "Tap to see error details", "tapToSeeError": "Toque para ver detalhes do erro",
"@tapToSeeError": { "@tapToSeeError": {
"description": "Tooltip for failed download" "description": "Tooltip for failed download"
}, },
"storeFilterAll": "All", "storeFilterAll": "Todos",
"@storeFilterAll": { "@storeFilterAll": {
"description": "Store filter - all extensions" "description": "Store filter - all extensions"
}, },
"storeFilterMetadata": "Metadata", "storeFilterMetadata": "Metadados",
"@storeFilterMetadata": { "@storeFilterMetadata": {
"description": "Store filter - metadata providers" "description": "Store filter - metadata providers"
}, },
@@ -2101,43 +2093,43 @@
"@storeFilterDownload": { "@storeFilterDownload": {
"description": "Store filter - download providers" "description": "Store filter - download providers"
}, },
"storeFilterUtility": "Utility", "storeFilterUtility": "Utilitário",
"@storeFilterUtility": { "@storeFilterUtility": {
"description": "Store filter - utility extensions" "description": "Store filter - utility extensions"
}, },
"storeFilterLyrics": "Lyrics", "storeFilterLyrics": "Letras",
"@storeFilterLyrics": { "@storeFilterLyrics": {
"description": "Store filter - lyrics providers" "description": "Store filter - lyrics providers"
}, },
"storeFilterIntegration": "Integration", "storeFilterIntegration": "Integração",
"@storeFilterIntegration": { "@storeFilterIntegration": {
"description": "Store filter - integrations" "description": "Store filter - integrations"
}, },
"storeClearFilters": "Clear filters", "storeClearFilters": "Limpar filtros",
"@storeClearFilters": { "@storeClearFilters": {
"description": "Button to clear all filters" "description": "Button to clear all filters"
}, },
"storeNoResults": "No extensions found", "storeNoResults": "Nenhuma extensão encontrada",
"@storeNoResults": { "@storeNoResults": {
"description": "Empty state when no extensions match filters" "description": "Empty state when no extensions match filters"
}, },
"extensionProviderPriority": "Provider Priority", "extensionProviderPriority": "Prioridade de Provedor",
"@extensionProviderPriority": { "@extensionProviderPriority": {
"description": "Extension capability - provider priority" "description": "Extension capability - provider priority"
}, },
"extensionInstallButton": "Install Extension", "extensionInstallButton": "Instalar Extensão",
"@extensionInstallButton": { "@extensionInstallButton": {
"description": "Button to install extension" "description": "Button to install extension"
}, },
"extensionDefaultProvider": "Default (Deezer/Spotify)", "extensionDefaultProvider": "Padrão (Deezer/Spotify)",
"@extensionDefaultProvider": { "@extensionDefaultProvider": {
"description": "Default search provider option" "description": "Default search provider option"
}, },
"extensionDefaultProviderSubtitle": "Use built-in search", "extensionDefaultProviderSubtitle": "Usar pesquisa integrada",
"@extensionDefaultProviderSubtitle": { "@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider" "description": "Subtitle for default provider"
}, },
"extensionAuthor": "Author", "extensionAuthor": "Autor",
"@extensionAuthor": { "@extensionAuthor": {
"description": "Extension detail - author" "description": "Extension detail - author"
}, },
@@ -2145,43 +2137,43 @@
"@extensionId": { "@extensionId": {
"description": "Extension detail - unique ID" "description": "Extension detail - unique ID"
}, },
"extensionError": "Error", "extensionError": "Erro",
"@extensionError": { "@extensionError": {
"description": "Extension detail - error message" "description": "Extension detail - error message"
}, },
"extensionCapabilities": "Capabilities", "extensionCapabilities": "Capacidades",
"@extensionCapabilities": { "@extensionCapabilities": {
"description": "Section header - extension features" "description": "Section header - extension features"
}, },
"extensionMetadataProvider": "Metadata Provider", "extensionMetadataProvider": "Provedor de Metadados",
"@extensionMetadataProvider": { "@extensionMetadataProvider": {
"description": "Capability - provides metadata" "description": "Capability - provides metadata"
}, },
"extensionDownloadProvider": "Download Provider", "extensionDownloadProvider": "Provedor de Download",
"@extensionDownloadProvider": { "@extensionDownloadProvider": {
"description": "Capability - provides downloads" "description": "Capability - provides downloads"
}, },
"extensionLyricsProvider": "Lyrics Provider", "extensionLyricsProvider": "Provedor de Letras",
"@extensionLyricsProvider": { "@extensionLyricsProvider": {
"description": "Capability - provides lyrics" "description": "Capability - provides lyrics"
}, },
"extensionUrlHandler": "URL Handler", "extensionUrlHandler": "Manipulador de URL",
"@extensionUrlHandler": { "@extensionUrlHandler": {
"description": "Capability - handles URLs" "description": "Capability - handles URLs"
}, },
"extensionQualityOptions": "Quality Options", "extensionQualityOptions": "Opções de Qualidade",
"@extensionQualityOptions": { "@extensionQualityOptions": {
"description": "Capability - quality selection" "description": "Capability - quality selection"
}, },
"extensionPostProcessingHooks": "Post-Processing Hooks", "extensionPostProcessingHooks": "Ganchos de Pós-Processamento",
"@extensionPostProcessingHooks": { "@extensionPostProcessingHooks": {
"description": "Capability - post-processing" "description": "Capability - post-processing"
}, },
"extensionPermissions": "Permissions", "extensionPermissions": "Permissões",
"@extensionPermissions": { "@extensionPermissions": {
"description": "Section header - required permissions" "description": "Section header - required permissions"
}, },
"extensionSettings": "Settings", "extensionSettings": "Configurações",
"@extensionSettings": { "@extensionSettings": {
"description": "Section header - extension settings" "description": "Section header - extension settings"
}, },
@@ -2376,31 +2368,31 @@
"@folderNone": { "@folderNone": {
"description": "Folder option - no organization" "description": "Folder option - no organization"
}, },
"folderNoneSubtitle": "Save all files directly to download folder", "folderNoneSubtitle": "Salvar todos os arquivos diretamente na pasta de download",
"@folderNoneSubtitle": { "@folderNoneSubtitle": {
"description": "Subtitle for no folder organization" "description": "Subtitle for no folder organization"
}, },
"folderArtist": "Artist", "folderArtist": "Artista",
"@folderArtist": { "@folderArtist": {
"description": "Folder option - by artist" "description": "Folder option - by artist"
}, },
"folderArtistSubtitle": "Artist Name/filename", "folderArtistSubtitle": "Nome do Artista/nome do arquivo",
"@folderArtistSubtitle": { "@folderArtistSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"folderAlbum": "Album", "folderAlbum": "Álbum",
"@folderAlbum": { "@folderAlbum": {
"description": "Folder option - by album" "description": "Folder option - by album"
}, },
"folderAlbumSubtitle": "Album Name/filename", "folderAlbumSubtitle": "Nome do Álbum/nome do arquivo",
"@folderAlbumSubtitle": { "@folderAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"folderArtistAlbum": "Artist/Album", "folderArtistAlbum": "Artista/Álbum",
"@folderArtistAlbum": { "@folderArtistAlbum": {
"description": "Folder option - nested" "description": "Folder option - nested"
}, },
"folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", "folderArtistAlbumSubtitle": "Nome do Artista/Nome do Álbum/nome do arquivo",
"@folderArtistAlbumSubtitle": { "@folderArtistAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
@@ -2424,103 +2416,103 @@
"@serviceSpotify": { "@serviceSpotify": {
"description": "Service name - DO NOT TRANSLATE" "description": "Service name - DO NOT TRANSLATE"
}, },
"appearanceAmoledDark": "AMOLED Dark", "appearanceAmoledDark": "AMOLED Escuro",
"@appearanceAmoledDark": { "@appearanceAmoledDark": {
"description": "Theme option - pure black" "description": "Theme option - pure black"
}, },
"appearanceAmoledDarkSubtitle": "Pure black background", "appearanceAmoledDarkSubtitle": "Fundo preto puro",
"@appearanceAmoledDarkSubtitle": { "@appearanceAmoledDarkSubtitle": {
"description": "Subtitle for AMOLED dark" "description": "Subtitle for AMOLED dark"
}, },
"appearanceChooseAccentColor": "Choose Accent Color", "appearanceChooseAccentColor": "Escolher Cor de Destaque",
"@appearanceChooseAccentColor": { "@appearanceChooseAccentColor": {
"description": "Color picker dialog title" "description": "Color picker dialog title"
}, },
"appearanceChooseTheme": "Theme Mode", "appearanceChooseTheme": "Modo de Tema",
"@appearanceChooseTheme": { "@appearanceChooseTheme": {
"description": "Theme picker dialog title" "description": "Theme picker dialog title"
}, },
"queueTitle": "Download Queue", "queueTitle": "Fila de Download",
"@queueTitle": { "@queueTitle": {
"description": "Queue screen title" "description": "Queue screen title"
}, },
"queueClearAll": "Clear All", "queueClearAll": "Limpar Tudo",
"@queueClearAll": { "@queueClearAll": {
"description": "Button - clear all queue items" "description": "Button - clear all queue items"
}, },
"queueClearAllMessage": "Are you sure you want to clear all downloads?", "queueClearAllMessage": "Tem certeza de que deseja limpar todos os downloads?",
"@queueClearAllMessage": { "@queueClearAllMessage": {
"description": "Clear queue confirmation" "description": "Clear queue confirmation"
}, },
"queueEmpty": "No downloads in queue", "queueEmpty": "Nenhum download na fila",
"@queueEmpty": { "@queueEmpty": {
"description": "Empty queue state title" "description": "Empty queue state title"
}, },
"queueEmptySubtitle": "Add tracks from the home screen", "queueEmptySubtitle": "Adicione faixas a partir da tela inicial",
"@queueEmptySubtitle": { "@queueEmptySubtitle": {
"description": "Empty queue state subtitle" "description": "Empty queue state subtitle"
}, },
"queueClearCompleted": "Clear completed", "queueClearCompleted": "Limpar concluídos",
"@queueClearCompleted": { "@queueClearCompleted": {
"description": "Button - clear finished downloads" "description": "Button - clear finished downloads"
}, },
"queueDownloadFailed": "Download Failed", "queueDownloadFailed": "Download Falhou",
"@queueDownloadFailed": { "@queueDownloadFailed": {
"description": "Error dialog title" "description": "Error dialog title"
}, },
"queueTrackLabel": "Track:", "queueTrackLabel": "Faixa:",
"@queueTrackLabel": { "@queueTrackLabel": {
"description": "Label in error dialog" "description": "Label in error dialog"
}, },
"queueArtistLabel": "Artist:", "queueArtistLabel": "Artista:",
"@queueArtistLabel": { "@queueArtistLabel": {
"description": "Label in error dialog" "description": "Label in error dialog"
}, },
"queueErrorLabel": "Error:", "queueErrorLabel": "Erro:",
"@queueErrorLabel": { "@queueErrorLabel": {
"description": "Label in error dialog" "description": "Label in error dialog"
}, },
"queueUnknownError": "Unknown error", "queueUnknownError": "Erro desconhecido",
"@queueUnknownError": { "@queueUnknownError": {
"description": "Fallback error message" "description": "Fallback error message"
}, },
"albumFolderArtistAlbum": "Artist / Album", "albumFolderArtistAlbum": "Artista / Álbum",
"@albumFolderArtistAlbum": { "@albumFolderArtistAlbum": {
"description": "Album folder option" "description": "Album folder option"
}, },
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/", "albumFolderArtistAlbumSubtitle": "Álbuns/Nome do Artista/Nome do Álbum/",
"@albumFolderArtistAlbumSubtitle": { "@albumFolderArtistAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistYearAlbum": "Artist / [Year] Album", "albumFolderArtistYearAlbum": "Artista / [Ano] Álbum",
"@albumFolderArtistYearAlbum": { "@albumFolderArtistYearAlbum": {
"description": "Album folder option with year" "description": "Album folder option with year"
}, },
"albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/", "albumFolderArtistYearAlbumSubtitle": "Álbuns/Nome do Artista/[2005] Nome do Álbum/",
"@albumFolderArtistYearAlbumSubtitle": { "@albumFolderArtistYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderAlbumOnly": "Album Only", "albumFolderAlbumOnly": "Apenas Álbum",
"@albumFolderAlbumOnly": { "@albumFolderAlbumOnly": {
"description": "Album folder option" "description": "Album folder option"
}, },
"albumFolderAlbumOnlySubtitle": "Albums/Album Name/", "albumFolderAlbumOnlySubtitle": "Álbuns/Nome do Álbum/",
"@albumFolderAlbumOnlySubtitle": { "@albumFolderAlbumOnlySubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderYearAlbum": "[Year] Album", "albumFolderYearAlbum": "[Ano] Álbum",
"@albumFolderYearAlbum": { "@albumFolderYearAlbum": {
"description": "Album folder option with year" "description": "Album folder option with year"
}, },
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", "albumFolderYearAlbumSubtitle": "Álbuns/[2005] Nome do Álbum/",
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Apagar Selecionados",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
}, },
"downloadedAlbumDeleteMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.", "downloadedAlbumDeleteMessage": "Apagar {count} {count, plural, =1{faixa} other{faixas}} deste álbum?\n\nIsso também apagará os arquivos do armazenamento.",
"@downloadedAlbumDeleteMessage": { "@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count", "description": "Delete confirmation with count",
"placeholders": { "placeholders": {
@@ -2529,11 +2521,11 @@
} }
} }
}, },
"downloadedAlbumTracksHeader": "Tracks", "downloadedAlbumTracksHeader": "Faixas",
"@downloadedAlbumTracksHeader": { "@downloadedAlbumTracksHeader": {
"description": "Section header for tracks" "description": "Section header for tracks"
}, },
"downloadedAlbumDownloadedCount": "{count} downloaded", "downloadedAlbumDownloadedCount": "{count} baixadas",
"@downloadedAlbumDownloadedCount": { "@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge", "description": "Downloaded tracks count badge",
"placeholders": { "placeholders": {
@@ -2542,7 +2534,7 @@
} }
} }
}, },
"downloadedAlbumSelectedCount": "{count} selected", "downloadedAlbumSelectedCount": "{count} selecionadas",
"@downloadedAlbumSelectedCount": { "@downloadedAlbumSelectedCount": {
"description": "Selection count indicator", "description": "Selection count indicator",
"placeholders": { "placeholders": {
@@ -2551,15 +2543,15 @@
} }
} }
}, },
"downloadedAlbumAllSelected": "All tracks selected", "downloadedAlbumAllSelected": "Todas as faixas selecionadas",
"@downloadedAlbumAllSelected": { "@downloadedAlbumAllSelected": {
"description": "Status - all items selected" "description": "Status - all items selected"
}, },
"downloadedAlbumTapToSelect": "Tap tracks to select", "downloadedAlbumTapToSelect": "Toque nas faixas para selecionar",
"@downloadedAlbumTapToSelect": { "@downloadedAlbumTapToSelect": {
"description": "Selection hint" "description": "Selection hint"
}, },
"downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}", "downloadedAlbumDeleteCount": "Apagar {count} {count, plural, =1{faixa} other{faixas}}",
"@downloadedAlbumDeleteCount": { "@downloadedAlbumDeleteCount": {
"description": "Delete button text with count", "description": "Delete button text with count",
"placeholders": { "placeholders": {
@@ -2568,23 +2560,23 @@
} }
} }
}, },
"downloadedAlbumSelectToDelete": "Select tracks to delete", "downloadedAlbumSelectToDelete": "Selecione faixas para apagar",
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"utilityFunctions": "Utility Functions", "utilityFunctions": "Funções Utilitárias",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
}, },
"recentTypeArtist": "Artist", "recentTypeArtist": "Artista",
"@recentTypeArtist": { "@recentTypeArtist": {
"description": "Recent access item type - artist" "description": "Recent access item type - artist"
}, },
"recentTypeAlbum": "Album", "recentTypeAlbum": "Álbum",
"@recentTypeAlbum": { "@recentTypeAlbum": {
"description": "Recent access item type - album" "description": "Recent access item type - album"
}, },
"recentTypeSong": "Song", "recentTypeSong": "Música",
"@recentTypeSong": { "@recentTypeSong": {
"description": "Recent access item type - song/track" "description": "Recent access item type - song/track"
}, },
@@ -2602,7 +2594,7 @@
} }
} }
}, },
"errorGeneric": "Error: {message}", "errorGeneric": "Erro: {message}",
"@errorGeneric": { "@errorGeneric": {
"description": "Generic error message format", "description": "Generic error message format",
"placeholders": { "placeholders": {
+258 -13
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Поиск в истории...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Настройки", "settingsTitle": "Настройки",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Переводчики",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Особая благодарность", "aboutSpecialThanks": "Особая благодарность",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,18 +552,30 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram канал",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Объявления и обновления",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Сообщество в Telegram",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Чат с другими пользователями",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Соцсети",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Поддержка", "aboutSupport": "Поддержка",
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
}, },
"aboutBuyMeCoffee": "Купить мне кофе",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Поддержать разработку на Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
"aboutApp": "Приложение", "aboutApp": "Приложение",
"@aboutApp": { "@aboutApp": {
"description": "Section for app info" "description": "Section for app info"
@@ -1108,7 +1128,7 @@
"@dialogDeleteSelectedTitle": { "@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items" "description": "Dialog title - delete selected items"
}, },
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.", "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"@dialogDeleteSelectedMessage": { "@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks", "description": "Dialog message - delete selected tracks",
"placeholders": { "placeholders": {
@@ -1122,6 +1142,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?", "dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?",
"csvImportTracks": "{count} треков из CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1169,7 +1198,7 @@
"@snackbarCredentialsCleared": { "@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed" "description": "Snackbar - Spotify credentials removed"
}, },
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
"@snackbarDeletedTracks": { "@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted", "description": "Snackbar - tracks deleted",
"placeholders": { "placeholders": {
@@ -1376,7 +1405,7 @@
"@selectionTapToSelect": { "@selectionTapToSelect": {
"description": "Hint - how to select items" "description": "Hint - how to select items"
}, },
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
"@selectionDeleteTracks": { "@selectionDeleteTracks": {
"description": "Delete button with count", "description": "Delete button with count",
"placeholders": { "placeholders": {
@@ -1851,6 +1880,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Тексты песен",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Режим текстов песен",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Выберите как сохранить тексты песен при скачивании",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Вставить в файл",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Встроить текст в метаданные FLAC",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "Внешний файл .lrc",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Отдельный файл .lrc для плееров, таких, как Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Оба варианта",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Вставить и сохранить файл .lrc",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Цвет", "sectionColor": "Цвет",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2062,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Жанр",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Заголовок",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Авторские права",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Скачано", "trackDownloaded": "Скачано",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2094,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Вставить текст песни",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Текст успешно добавлен",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Инструментальный трек",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Скопировано в буфер обмена", "trackCopiedToClipboard": "Скопировано в буфер обмена",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2417,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320 кбит/с (Конвертировано из FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Скачивние в MP3",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 качество доступно",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Скачивать FLAC и конвертировать в MP3 320 кбит/с",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Фактическое качество зависит от доступности треков в сервисе", "qualityNote": "Фактическое качество зависит от доступности треков в сервисе",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,11 +2625,19 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Исполнитель / Альбом + Синглы",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Исполнитель/Альбом и Исполнитель/Сингл/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Удалить выбранные", "downloadedAlbumDeleteSelected": "Удалить выбранные",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
}, },
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.", "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"@downloadedAlbumDeleteMessage": { "@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count", "description": "Delete confirmation with count",
"placeholders": { "placeholders": {
@@ -2559,7 +2676,7 @@
"@downloadedAlbumTapToSelect": { "@downloadedAlbumTapToSelect": {
"description": "Selection hint" "description": "Selection hint"
}, },
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
"@downloadedAlbumDeleteCount": { "@downloadedAlbumDeleteCount": {
"description": "Delete button text with count", "description": "Delete button text with count",
"placeholders": { "placeholders": {
@@ -2572,6 +2689,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Диск {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Функции утилиты", "utilityFunctions": "Функции утилиты",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2738,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Скачать дискографию",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Скачать всё",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} треков из {albumCount} релизов",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Только альбомы",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} треков из {albumCount} альбомов",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Только синглы и EP",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} треков из {albumCount} синглов",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Выбрать альбомы...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Выберите конкретные альбомы или синглы",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Получение треков...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Получение {current} из {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} выбрано",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Скачать выбранное",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Добавлено {count} треков в очередь",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} добавлено, {skipped} уже скачано",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "Нет доступных альбомов",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Не удалось получить некоторые альбомы",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+2857 -4
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"
+253 -8
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,18 +552,30 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@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"
@@ -1122,6 +1142,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1880,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2062,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2094,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2417,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2625,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2689,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2738,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+253 -8
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,18 +552,30 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@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"
@@ -1122,6 +1142,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1880,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2062,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2094,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2417,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2625,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2689,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2738,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+4
View File
@@ -18,6 +18,8 @@ const List<Locale> filteredSupportedLocales = <Locale>[
Locale('es', 'ES'), Locale('es', 'ES'),
Locale('id'), Locale('id'),
Locale('pt', 'PT'), Locale('pt', 'PT'),
Locale('ja'),
Locale('tr'),
]; ];
/// Set of locale codes for quick lookup. /// Set of locale codes for quick lookup.
@@ -27,4 +29,6 @@ const Set<String> filteredLocaleCodes = <String>{
'es_ES', 'es_ES',
'id', 'id',
'pt_PT', 'pt_PT',
'ja',
'tr',
}; };
+32 -19
View File
@@ -11,38 +11,50 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
_configureImageCache();
await CoverCacheManager.initialize();
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
runApp( runApp(
ProviderScope( ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
child: const _EagerInitialization(
child: SpotiFLACApp(),
),
),
); );
} }
void _configureImageCache() {
final imageCache = PaintingBinding.instance.imageCache;
// Keep memory cache bounded so cover-heavy pages don't retain too many
// full-resolution images simultaneously.
imageCache.maximumSize = 240;
imageCache.maximumSizeBytes = 60 << 20; // 60 MiB
}
/// Widget to eagerly initialize providers that need to load data on startup /// Widget to eagerly initialize providers that need to load data on startup
class _EagerInitialization extends ConsumerStatefulWidget { class _EagerInitialization extends ConsumerStatefulWidget {
const _EagerInitialization({required this.child}); const _EagerInitialization({required this.child});
final Widget child; final Widget child;
@override @override
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState(); ConsumerState<_EagerInitialization> createState() =>
_EagerInitializationState();
} }
class _EagerInitializationState extends ConsumerState<_EagerInitialization> { class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initializeAppServices();
_initializeExtensions(); _initializeExtensions();
ref.read(downloadHistoryProvider);
}
Future<void> _initializeAppServices() async {
try {
await CoverCacheManager.initialize();
await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
} catch (e) {
debugPrint('Failed to initialize app services: $e');
}
} }
Future<void> _initializeExtensions() async { Future<void> _initializeExtensions() async {
@@ -50,11 +62,13 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions'; final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data'; final dataDir = '${appDir.path}/extension_data';
await Directory(extensionsDir).create(recursive: true); await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true); await Directory(dataDir).create(recursive: true);
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir); await ref
.read(extensionProvider.notifier)
.initialize(extensionsDir, dataDir);
} catch (e) { } catch (e) {
debugPrint('Failed to initialize extensions: $e'); debugPrint('Failed to initialize extensions: $e');
} }
@@ -62,7 +76,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
ref.watch(downloadHistoryProvider);
return widget.child; return widget.child;
} }
} }

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