Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98abaf6635 | |||
| 4747119a7f | |||
| bfd769b349 | |||
| 40c3c73bfd | |||
| 96d11b1d7d | |||
| b3771f3488 | |||
| a07c125454 | |||
| 54a7b6b568 | |||
| 77d0ac4fce | |||
| bddd733466 | |||
| e6ffb08954 | |||
| 2fe8f659bc | |||
| ab26d84632 | |||
| c89600591c | |||
| f1d57d89c7 | |||
| 83124875d3 | |||
| 9460e9faae | |||
| 882afd938b | |||
| ab72a10578 | |||
| e39756fa3f | |||
| 8e794e1ef1 | |||
| caf68c8137 | |||
| 5161ac8f77 | |||
| 4df96db809 | |||
| 5605930aef | |||
| cdc5836785 | |||
| 813ed79073 | |||
| 537bab69ab | |||
| b0871ad94b | |||
| 0bd7574ab2 | |||
| c3f8b48bf7 | |||
| 90f731ac1e | |||
| 8e6cbcbc2a | |||
| 3ac9ff1dd7 | |||
| 3e90b29d2b | |||
| b74186464b | |||
| f4934dcb28 | |||
| 30973a8e78 | |||
| 9b89625660 | |||
| c70ba5962e | |||
| 8c722b0a18 | |||
| 3ece6770e1 |
@@ -0,0 +1,20 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows scripts
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.jar binary
|
||||
*.aar binary
|
||||
*.keystore binary
|
||||
*.jks binary
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: site
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26"
|
||||
go-version: "1.25.7"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26"
|
||||
go-version: "1.25.7"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
|
||||
@@ -1,5 +1,169 @@
|
||||
# Changelog
|
||||
|
||||
## [3.7.0] - 2026-03-04
|
||||
|
||||
Hey everyone, thank you so much for sticking with SpotiFLAC Mobile.
|
||||
|
||||
Starting from this release, we're rolling the version back from **v4.x to v3.x**.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Internal Audio Player** — Removed `just_audio`, `audio_service`, and `audio_session` dependencies entirely. The internal playback engine (smart queue, media notification, shuffle/repeat, lyrics sync, prefetch, playback state persistence) has been completely removed. Playback now delegates to the system's external player.
|
||||
- **PlaybackItem Model** — No longer needed without internal playback.
|
||||
- **MiniPlayerBar Widget** — Removed the in-app mini player UI.
|
||||
- **Media Notification Controls** — Removed notification drawables (`ic_stat_favorite`, `ic_stat_favorite_border`) and the `keep.xml` resource file.
|
||||
- **Player Mode Setting** — The `playerMode` setting has been removed since external player is now the only mode.
|
||||
- **Online Playback Feature** — Online streaming mode, DASH pipeline, and related components introduced in v4.0.0 are gone from the main branch.
|
||||
|
||||
### Changed
|
||||
|
||||
- **MainActivity** now extends `FlutterFragmentActivity` directly (previously `AudioServiceFragmentActivity`).
|
||||
- **PlaybackController** simplified from ~1200 lines to ~87 lines — now only resolves local file paths and opens them via external player.
|
||||
- **ProGuard rules** cleaned up — removed audio_service/just_audio/audio_session rules.
|
||||
- **Qobuz** migrated to MusicDL API (Thanks @Ruubiiiii for Hosting the API).
|
||||
|
||||
### Note
|
||||
There are three main reasons behind this decision:
|
||||
|
||||
1. **Respecting the API providers** — After giving it some thought, we realized that the streaming feature was indirectly hurting the API providers who have been generous enough to make their services available. They already offer streaming directly on their own websites, and it only feels right to direct streaming usage back to their platforms.
|
||||
|
||||
2. **Long-term sustainability** — We want SpotiFLAC to be around for as long as possible. Keeping certain features in the app could attract unwanted attention and put the project's continued existence at risk. Removing them is a proactive step to keep things running smoothly for everyone.
|
||||
|
||||
**Still want online playback? Check out these services:**
|
||||
- [DabMusic](https://dabmusic.xyz)
|
||||
- [SquidWTF](https://tidal.squid.wtf)
|
||||
|
||||
Thank you for your understanding and continued support. This decision was made to ensure the long-term sustainability of the app and to respect the ecosystem that has been supporting SpotiFLAC all along. You guys are the best, and we truly appreciate each and every one of you!
|
||||
|
||||
---
|
||||
|
||||
## [3.6.0] - 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Library Tab Redesign**: Wishlist, Loved, and individual Playlist collections now appear as unified list/grid items in the "All" tab alongside tracks, replacing the old "My Folders" horizontal card section
|
||||
- **Drag-and-Drop Track Categorization**: Long-press-drag tracks onto playlist items to add them to that playlist; when multiple tracks are selected and one is dragged, all selected tracks are added to the target playlist
|
||||
- Drag feedback widget displays multi-select count badge
|
||||
- **Playlist Multi-Select Deletion**: Long-press playlists to enter selection mode, select multiple playlists, and batch-delete all selected at once via a dedicated selection bottom bar
|
||||
- **Track Categorization System**: Tracks added to any playlist are automatically hidden from the main tracks list; removing a track from a playlist or deleting the playlist makes the track reappear — no actual file deletion ever occurs
|
||||
- **Create Playlist Button**: New "+" `TextButton.icon` in Library tab header with dynamic theme colors, replacing the old "Select" button
|
||||
- **Track Options Bottom Sheet**: Rewrote `TrackCollectionQuickActions` from inline action buttons to a single styled bottom sheet with track header (cover, title, artist), divider, and option tiles matching `DownloadServicePicker` visual style
|
||||
- **Library Tracks Folder SliverAppBar**: Wishlist, Loved, and Playlist detail screens now feature a collapsible SliverAppBar with cover art (45% viewport height, parallax, gradient overlay), mode-specific icons (bookmark/heart/queue_music), title, and track count badge
|
||||
- **Custom Playlist Cover Images**: Users can set custom cover images for playlists via long-press menu or camera icon in SliverAppBar
|
||||
- Covers stored locally in app support directory with priority: custom cover > first track URL > icon fallback
|
||||
- Cover options bottom sheet with change/remove actions
|
||||
- Playlist list screen shows cover thumbnails
|
||||
- **Long-Press Context Menus**: Track tiles in library folders and playlist list items now use long-press for styled bottom sheet context menus instead of trailing icon buttons, matching platform conventions
|
||||
- **Wishlist Quick Download**: Tapping a track in Wishlist opens quality picker (respects "Ask quality before download" setting) and starts download
|
||||
- **Playlist Track Playback**: Tapping a downloaded track in a Playlist opens it in the device's external music player via `openFile()` with file existence check
|
||||
- **Collapsible AppBar on Playlist List Screen**: Playlist list screen now uses a collapsible SliverAppBar matching Settings sub-page style (animated title size 20→28px, animated left padding 56→24px) for visual consistency
|
||||
- **`UnifiedLibraryItem.collectionKey` Getter**: Efficient playlist membership checking without constructing a full `Track` object
|
||||
- **Multi-select Share**: Share multiple downloaded/local tracks at once from the selection bottom bar
|
||||
- Supports SAF content URIs via native `ACTION_SEND_MULTIPLE` intent
|
||||
- Supports regular file paths via SharePlus
|
||||
- Available in Downloaded Album, Local Album, and Queue tab screens
|
||||
- **Multi-select Batch Convert**: Convert multiple selected tracks to MP3 or Opus in one operation
|
||||
- Bottom sheet UI with format (MP3 / Opus) and bitrate (128k / 192k / 256k / 320k) selection
|
||||
- Full SAF support: copies to temp, converts, writes back, deletes original, updates history
|
||||
- Progress and result snackbar feedback during conversion
|
||||
- Available in Downloaded Album, Local Album, and Queue tab screens
|
||||
- **Native `shareMultipleContentUris`**: New Android `ACTION_SEND_MULTIPLE` handler in `MainActivity` for sharing multiple SAF content URIs
|
||||
- **Localization**: Added selection share/convert strings to all 13 supported locales (`selectionShareCount`, `selectionShareNoFiles`, `selectionConvertCount`, `selectionConvertNoConvertible`, `selectionBatchConvertConfirmTitle`, `selectionBatchConvertConfirmMessage`, `selectionBatchConvertProgress`, `selectionBatchConvertSuccess`)
|
||||
- **Localization**: Added library collection l10n keys (`trackOptionAddToLoved`, `trackOptionRemoveFromLoved`, `trackOptionAddToWishlist`, `trackOptionRemoveFromWishlist`, `libraryTracksUnit`, `collectionPlaylistChangeCover`, `collectionPlaylistRemoveCover`)
|
||||
- **Global Network Compatibility Mode**: New Download settings toggle to help restricted/ISP-filtered networks
|
||||
- Applies to backend API requests (not SongLink-only)
|
||||
- Enables HTTP scheme fallback and optional insecure TLS behavior in one switch
|
||||
- Synced end-to-end across Flutter settings, platform channel (Android/iOS), and Go backend
|
||||
|
||||
### Changed
|
||||
|
||||
- **Removed "My Folders" Section**: Horizontal card section removed from Library tab header; collections are now inline items in the unified main list/grid
|
||||
- **Playlist Subtitle Simplified**: Playlist items now show "N tracks" instead of "Playlist • N tracks"
|
||||
- **Pinned App Bar on All Detail Screens**: `SliverAppBar` changed from `pinned: false` to `pinned: true` in 6 detail screens (album, downloaded album, local album, playlist, track metadata, library tracks folder) so the app bar stays visible when scrolling
|
||||
- **Local Album Multi-select Action Updated**: Replaced batch `Share` action with batch `Re-enrich`
|
||||
- Local album selection bar now uses `Re-enrich` + `Convert` actions
|
||||
- Added batch re-enrich processing for local tracks (FLAC native path and MP3/Opus FFmpeg path, including SAF write-back flow)
|
||||
- After batch re-enrich completes, local library is refreshed via incremental scan so updated metadata appears in UI immediately
|
||||
- **Queue Multi-select Local Action Updated**: Queue selection bar now switches the first action to `Re-enrich` when selected items are local-only
|
||||
- If selection contains downloaded or mixed items, action remains `Share`
|
||||
- Local-only selection now supports batch re-enrich with the same native/FFmpeg + SAF flow and auto-refreshes local library metadata after completion
|
||||
- **SongLink Network Option Scope Expanded**: The previous SongLink compatibility path now routes through global network compatibility controls so all supported backend API clients can benefit under problematic networks
|
||||
- **Removed Per-Track Action Buttons**: Album, playlist, home, artist, and search screens no longer show individual download/add buttons on each track tile; all actions accessed via `TrackCollectionQuickActions` bottom sheet
|
||||
- **Loved SliverAppBar Always Shows Heart Icon**: Loved tracks folder always displays the heart icon as cover, never uses first track's cover art (like Spotify's Liked Songs)
|
||||
- **Wishlist Long-Press Menu Conditional Actions**: "Add to Playlist" option only appears when the track is already downloaded
|
||||
- **Loved Track Tap Disabled**: Tapping a track in the Loved folder performs no action (long-press for options only)
|
||||
- **Removed Duplicate Create Playlist Button**: Removed `+` IconButton from playlist list screen AppBar since the FAB already serves the same purpose
|
||||
- **`coverImagePath` Field on `UserPlaylistCollection`**: Model now supports nullable custom cover path with `copyWith` using `String? Function()?` pattern for explicit null assignment
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Local Cover Path Handling**: All cover image renderers (Library tab, playlist detail screen hero cover, per-track tiles, options bottom sheet) now detect whether `coverUrl` is a URL or local file path and use `Image.file` for local paths instead of `CachedNetworkImage`
|
||||
- **Empty Playlists Now Clickable**: Empty playlist items in Library tab can now be tapped to navigate to their detail screen
|
||||
- **RenderFlex Overflow**: Fixed overflow in unified library item Row layout when track metadata text was too long
|
||||
- **SAF FD Permission Denied on Tidal Downloads**: Fixed `failed to create file: open /proc/self/fd/*: permission denied` on some devices/providers
|
||||
- Android SAF bridge now hands off detached raw FD (`output_fd`) to Go instead of forcing procfs path reopen
|
||||
- Go output writer includes safer procfs fallback behavior for providers that reject truncate semantics
|
||||
- **Batch Convert Lyrics Embedding Gap**: Batch convert in Downloaded Album, Local Album, and Queue now preserves/adds lyrics consistently like single convert
|
||||
- Reuses embedded lyrics when available
|
||||
- Falls back to sidecar `.lrc` when present
|
||||
- Falls back to online lyrics fetch and injects into conversion metadata when embedding is enabled
|
||||
|
||||
---
|
||||
|
||||
## [3.6.9] - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- **YouTube Bitrate Presets**: YouTube bitrate selection now uses supported presets only
|
||||
- Opus: 128 / 256 kbps
|
||||
- MP3: 128 / 256 / 320 kbps
|
||||
- **Go Test Coverage for YouTube Quality Parsing**: Added tests for supported-bitrate normalization behavior
|
||||
- **Localization for YouTube Bitrate UI**: Added localized strings (EN/ID) for YouTube bitrate titles and labels
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cover Image Cache Clear Not Working**: Clearing "Cover image cache" now performs a full on-disk wipe, clears in-memory image cache, and reinitializes cache manager state
|
||||
- Prevents stale/orphaned cache files from keeping the same storage usage after clear
|
||||
- **YouTube Queue Fallback Quality Mismatch**: Queue fallback now normalizes YouTube quality IDs so conversion paths use valid bitrate format IDs
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default Lyrics Behavior**: `Apple/QQ Multi-Person Word-by-Word` is now OFF by default for new installs
|
||||
- **Removed Dynamic YouTube Bitrate Mode**: Arbitrary values are now normalized to nearest supported Spotube preset across settings, picker, queue fallback, and Go backend parser
|
||||
- **Lyrics Embedding Control**: Users can now disable the embedded-lyrics process from settings (`Embed Lyrics` off)
|
||||
|
||||
---
|
||||
|
||||
## [3.6.8] - 2026-02-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Lyrics Source Tracking**: Track Metadata screen now displays the source of loaded lyrics (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music, Embedded, or Extension)
|
||||
- New `getLyricsLRCWithSource` API returns lyrics with source metadata
|
||||
- Source badge appears below lyrics section in Track Metadata screen
|
||||
- **Dedicated Lyrics Provider Priority Page**: Lyrics providers can now be configured from a dedicated settings page with full-screen reorderable list
|
||||
- Replaced inline bottom sheet with `LyricsProviderPriorityPage`
|
||||
- Cleaner UI with provider descriptions and priority ordering
|
||||
- **Paxsenix Integration**: Added Paxsenix API as official lyrics proxy partner for Apple Music, QQ Music, Musixmatch, and Netease sources
|
||||
- Listed in About page and Partners page on project site
|
||||
- README updated with partner attribution
|
||||
|
||||
### Fixed
|
||||
|
||||
- **LRC Background Vocal Preservation**: Apple Music/QQ Music `[bg:...]` background vocal tags are now preserved during LRC parsing instead of being stripped
|
||||
- Background vocals attach to the previous timed line in exported LRC files
|
||||
- **LRC Display Improvements**:
|
||||
- Inline word-by-word timestamps (`<mm:ss.xx>`) are stripped from lyrics display
|
||||
- Speaker prefixes (`v1:`, `v2:`) are removed for cleaner display
|
||||
- Multi-line background vocals converted to readable secondary vocal lines
|
||||
- **Apple Music Lyrics Case Sensitivity**: Fixed `lyricsType` comparison to use case-insensitive matching for "Syllable" type
|
||||
|
||||
### Changed
|
||||
|
||||
- Track Metadata lyrics fetching now uses `getLyricsLRCWithSource` for consistent source attribution across embedded and online lyrics
|
||||
|
||||
---
|
||||
|
||||
## [3.6.7] - 2026-02-13
|
||||
|
||||
### Added
|
||||
@@ -16,6 +180,20 @@
|
||||
- Project website with GitHub Pages deployment workflow
|
||||
- Mobile burger menu navigation for all site pages
|
||||
- Go filename template test suite
|
||||
- "Lyrics Provider" extension type - extensions can now provide lyrics (synced or plain text) via `fetchLyrics()` function
|
||||
- Lyrics provider extensions are called before built-in providers, giving extensions highest priority
|
||||
- New `lyrics_provider` manifest type alongside `metadata_provider` and `download_provider`
|
||||
- Shows "Lyrics Provider" capability badge on extension detail page
|
||||
- "Lyrics Providers" settings - configurable provider cascade order and per-provider options
|
||||
- Reorderable provider list: LRCLIB, Musixmatch, Netease, Apple Music, QQ Music
|
||||
- Netease: toggle translated/romanized lyrics appending
|
||||
- Apple Music / QQ Music: multi-person word-by-word speaker tags
|
||||
- Musixmatch: selectable language code for localized lyrics
|
||||
- "Documentation Search" - global search modal on all site pages
|
||||
- Opens with Ctrl+K / Cmd+K / `/` keyboard shortcuts on every page
|
||||
- Search button with bordered pill styling in desktop nav and mobile hamburger menu
|
||||
- On non-docs pages, search results navigate to the docs page at the matching section
|
||||
- Full keyboard navigation: arrow keys, Enter to select, Esc to close
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
@@ -94,12 +94,7 @@ The software is provided "as is", without warranty of any kind. The author assum
|
||||
|
||||
## 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)
|
||||
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||
|
||||
|
||||
> [!TIP]
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
@@ -21,6 +22,7 @@
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:localeConfig="@xml/locale_config">
|
||||
|
||||
@@ -92,6 +94,24 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Audio playback service for media notification / background audio -->
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- flutter_local_notifications receivers -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
|
||||
@@ -4,21 +4,25 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode
|
||||
import io.flutter.embedding.android.FlutterFragment
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.android.RenderMode
|
||||
import io.flutter.embedding.android.TransparencyMode
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterShellArgs
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import gobackend.Gobackend
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
@@ -30,11 +34,23 @@ import java.util.Locale
|
||||
|
||||
class MainActivity: FlutterFragmentActivity() {
|
||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
|
||||
"com.zarz.spotiflac/download_progress_stream"
|
||||
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
|
||||
"com.zarz.spotiflac/library_scan_progress_stream"
|
||||
private val STREAM_POLLING_INTERVAL_MS = 800L
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||
private val safScanLock = Any()
|
||||
private val safDirLock = Any()
|
||||
private var safScanProgress = SafScanProgress()
|
||||
private var downloadProgressStreamJob: Job? = null
|
||||
private var downloadProgressEventSink: EventChannel.EventSink? = null
|
||||
private var lastDownloadProgressPayload: String? = null
|
||||
private var libraryScanProgressStreamJob: Job? = null
|
||||
private var libraryScanProgressEventSink: EventChannel.EventSink? = null
|
||||
private var lastLibraryScanProgressPayload: String? = null
|
||||
private var flutterBackCallback: OnBackPressedCallback? = null
|
||||
@Volatile private var safScanCancel = false
|
||||
@Volatile private var safScanActive = false
|
||||
private val safTreeLauncher = registerForActivityResult(
|
||||
@@ -381,6 +397,78 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
private fun readLibraryScanProgressJsonForStream(): String {
|
||||
return if (safScanActive) {
|
||||
safProgressToJson()
|
||||
} else {
|
||||
Gobackend.getLibraryScanProgressJSON()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
|
||||
stopDownloadProgressStream()
|
||||
downloadProgressEventSink = sink
|
||||
lastDownloadProgressPayload = null
|
||||
downloadProgressStreamJob = scope.launch {
|
||||
while (isActive && downloadProgressEventSink === sink) {
|
||||
try {
|
||||
val payload = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAllDownloadProgress()
|
||||
}
|
||||
if (payload != lastDownloadProgressPayload) {
|
||||
lastDownloadProgressPayload = payload
|
||||
sink.success(payload)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Download progress stream poll failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
delay(STREAM_POLLING_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopDownloadProgressStream() {
|
||||
downloadProgressStreamJob?.cancel()
|
||||
downloadProgressStreamJob = null
|
||||
downloadProgressEventSink = null
|
||||
lastDownloadProgressPayload = null
|
||||
}
|
||||
|
||||
private fun startLibraryScanProgressStream(sink: EventChannel.EventSink) {
|
||||
stopLibraryScanProgressStream()
|
||||
libraryScanProgressEventSink = sink
|
||||
lastLibraryScanProgressPayload = null
|
||||
libraryScanProgressStreamJob = scope.launch {
|
||||
while (isActive && libraryScanProgressEventSink === sink) {
|
||||
try {
|
||||
val payload = withContext(Dispatchers.IO) {
|
||||
readLibraryScanProgressJsonForStream()
|
||||
}
|
||||
if (payload != lastLibraryScanProgressPayload) {
|
||||
lastLibraryScanProgressPayload = payload
|
||||
sink.success(payload)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Library scan progress stream poll failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
delay(STREAM_POLLING_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLibraryScanProgressStream() {
|
||||
libraryScanProgressStreamJob?.cancel()
|
||||
libraryScanProgressStreamJob = null
|
||||
libraryScanProgressEventSink = null
|
||||
lastLibraryScanProgressPayload = null
|
||||
}
|
||||
|
||||
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
|
||||
val obj = JSONObject()
|
||||
if (treeUriStr.isBlank() || fileName.isBlank()) {
|
||||
@@ -666,21 +754,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
?: return errorJson("Failed to open SAF file")
|
||||
var fdHandedOffToGo = false
|
||||
|
||||
var detachedFd: Int? = null
|
||||
try {
|
||||
// Keep the original PFD open so the document provider receives close signaling.
|
||||
// Pass a duplicated FD to Go and detach only the duplicate.
|
||||
val writerPfd = ParcelFileDescriptor.dup(pfd.fileDescriptor)
|
||||
val detachedFd = writerPfd.detachFd()
|
||||
try {
|
||||
writerPfd.close()
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// After detach, ownership is intended for Go. Kotlin must never close this FD,
|
||||
// otherwise Android fdsan may abort on double-close during cancellation races.
|
||||
fdHandedOffToGo = true
|
||||
req.put("output_path", "/proc/self/fd/$detachedFd")
|
||||
// Prefer handing off a detached FD directly to Go.
|
||||
// Some devices/providers reject re-opening /proc/self/fd/* with permission denied.
|
||||
detachedFd = pfd.detachFd()
|
||||
req.put("output_path", "")
|
||||
req.put("output_fd", detachedFd)
|
||||
req.put("output_ext", outputExt)
|
||||
val response = downloader(req.toString())
|
||||
@@ -696,12 +776,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
document.delete()
|
||||
return errorJson("SAF download failed: ${e.message}")
|
||||
} finally {
|
||||
if (!fdHandedOffToGo) {
|
||||
android.util.Log.w("SpotiFLAC", "SAF writer FD was not handed off to Go")
|
||||
// If detachFd() failed before handoff, close original ParcelFileDescriptor.
|
||||
// Otherwise Go owns the detached raw FD and is responsible for closing it.
|
||||
if (detachedFd == null) {
|
||||
try {
|
||||
pfd.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
try {
|
||||
pfd.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1260,19 +1341,92 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return respObj.toString()
|
||||
}
|
||||
|
||||
// Disable Flutter's built-in deep linking so that incoming ACTION_VIEW URLs
|
||||
// (Spotify, Deezer, Tidal, YouTube Music) are NOT forwarded to GoRouter.
|
||||
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
|
||||
override fun shouldHandleDeeplinking(): Boolean = false
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Update the intent so receive_sharing_intent can access the new data
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
Gobackend.cleanupExtensions()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Failed to cleanup extensions on destroy: ${e.message}")
|
||||
}
|
||||
stopDownloadProgressStream()
|
||||
stopLibraryScanProgressStream()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
// Always-enabled back callback to ensure back presses reach Flutter.
|
||||
// Nested tab navigators can incorrectly set frameworkHandlesBack(false),
|
||||
// which disables Flutter's own OnBackPressedCallback and causes the
|
||||
// system default (finish activity) to run. This callback guarantees
|
||||
// popRoute is always forwarded to Flutter, where PopScope handles it.
|
||||
flutterBackCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
flutterEngine.navigationChannel.popRoute()
|
||||
}
|
||||
}
|
||||
onBackPressedDispatcher.addCallback(this, flutterBackCallback!!)
|
||||
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
|
||||
EventChannel(
|
||||
messenger,
|
||||
DOWNLOAD_PROGRESS_STREAM_CHANNEL,
|
||||
).setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
if (events != null) {
|
||||
startDownloadProgressStream(events)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
stopDownloadProgressStream()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
EventChannel(
|
||||
messenger,
|
||||
LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL,
|
||||
).setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
if (events != null) {
|
||||
startLibraryScanProgressStream(events)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
stopLibraryScanProgressStream()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
scope.launch {
|
||||
try {
|
||||
when (call.method) {
|
||||
"exitApp" -> {
|
||||
flutterBackCallback?.isEnabled = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
finishAndRemoveTask()
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"parseSpotifyUrl" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -1304,6 +1458,14 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getSpotifyRelatedArtists" -> {
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 12
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getSpotifyRelatedArtists(artistId, limit.toLong())
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkAvailability" -> {
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
val isrc = call.argument<String>("isrc") ?: ""
|
||||
@@ -1368,6 +1530,14 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"setNetworkCompatibilityOptions", "setSongLinkNetworkOptions" -> {
|
||||
val allowHttp = call.argument<Boolean>("allow_http") ?: false
|
||||
val insecureTls = call.argument<Boolean>("insecure_tls") ?: false
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setNetworkCompatibilityOptions(allowHttp, insecureTls)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"checkDuplicate" -> {
|
||||
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||
val isrc = call.argument<String>("isrc") ?: ""
|
||||
@@ -1546,6 +1716,28 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
result.error("share_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
"shareMultipleContentUris" -> {
|
||||
val uriStrings = call.argument<List<String>>("uris") ?: emptyList()
|
||||
val title = call.argument<String>("title") ?: ""
|
||||
try {
|
||||
val uris = ArrayList<Uri>(uriStrings.size)
|
||||
for (s in uriStrings) {
|
||||
uris.add(Uri.parse(s))
|
||||
}
|
||||
val shareIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||
setType("audio/*")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
if (title.isNotBlank()) {
|
||||
putExtra(Intent.EXTRA_SUBJECT, title)
|
||||
}
|
||||
}
|
||||
startActivity(Intent.createChooser(shareIntent, title.ifBlank { "Share" }))
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error("share_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
"fetchLyrics" -> {
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
val trackName = call.argument<String>("track_name") ?: ""
|
||||
@@ -1582,6 +1774,32 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getLyricsLRCWithSource" -> {
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
val trackName = call.argument<String>("track_name") ?: ""
|
||||
val artistName = call.argument<String>("artist_name") ?: ""
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
if (filePath.startsWith("content://")) {
|
||||
val tempPath = copyUriToTemp(Uri.parse(filePath))
|
||||
if (tempPath == null) {
|
||||
"""{"lyrics":"","source":"","sync_type":"","instrumental":false}"""
|
||||
} else {
|
||||
try {
|
||||
Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, tempPath, durationMs)
|
||||
} finally {
|
||||
try {
|
||||
File(tempPath).delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs)
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"embedLyricsToFile" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val lyrics = call.argument<String>("lyrics") ?: ""
|
||||
@@ -1756,6 +1974,60 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"setLyricsProviders" -> {
|
||||
val providersJson = call.argument<String>("providers_json") ?: "[]"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.setLyricsProvidersJSON(providersJson)
|
||||
"""{"success":true}"""
|
||||
} catch (e: Exception) {
|
||||
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getLyricsProviders" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.getLyricsProvidersJSON()
|
||||
} catch (e: Exception) {
|
||||
"[]"
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getAvailableLyricsProviders" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.getAvailableLyricsProvidersJSON()
|
||||
} catch (e: Exception) {
|
||||
"[]"
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"setLyricsFetchOptions" -> {
|
||||
val optionsJson = call.argument<String>("options_json") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.setLyricsFetchOptionsJSON(optionsJson)
|
||||
"""{"success":true}"""
|
||||
} catch (e: Exception) {
|
||||
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getLyricsFetchOptions" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.getLyricsFetchOptionsJSON()
|
||||
} catch (e: Exception) {
|
||||
"{}"
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"reEnrichFile" -> {
|
||||
val requestJson = call.argument<String>("request_json") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -1871,6 +2143,14 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerRelatedArtists" -> {
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 12
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getDeezerRelatedArtists(artistId, limit.toLong())
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerMetadata" -> {
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 954 B |
|
Before Width: | Height: | Size: 651 B After Width: | Height: | Size: 647 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1a1a2e</color>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
|
||||
<!-- Allow local loopback cleartext for FFmpeg live decrypt tunnel only. -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -45,7 +45,7 @@ type AfkarXYZResponse struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin}
|
||||
// AmazonStreamResponse is the new response format from amzn.afkarxyz.fun/api/track/{asin}
|
||||
type AmazonStreamResponse struct {
|
||||
StreamURL string `json:"streamUrl"`
|
||||
DecryptionKey string `json:"decryptionKey"`
|
||||
@@ -179,7 +179,7 @@ func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, st
|
||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||
defer cancel()
|
||||
|
||||
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
||||
apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -193,13 +193,13 @@ func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, st
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return "", "", "", fmt.Errorf("failed to read response: %w", readErr)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to read response: %w", err)
|
||||
if resp.StatusCode != 200 {
|
||||
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp AmazonStreamResponse
|
||||
@@ -219,7 +219,7 @@ func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, st
|
||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||
defer cancel()
|
||||
|
||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||
apiURL := "https://amzn.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
|
||||
@@ -375,6 +375,57 @@ type AmazonDownloadResult struct {
|
||||
DecryptionKey string
|
||||
}
|
||||
|
||||
func resolveAmazonURLForRequest(req DownloadRequest, logPrefix string) (string, error) {
|
||||
if strings.TrimSpace(logPrefix) == "" {
|
||||
logPrefix = "Amazon"
|
||||
}
|
||||
|
||||
amazonURL := ""
|
||||
if req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
|
||||
amazonURL = cached.AmazonURL
|
||||
GoLog("[%s] Cache hit! Using cached Amazon URL for ISRC %s\n", logPrefix, req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
if amazonURL != "" {
|
||||
return amazonURL, nil
|
||||
}
|
||||
|
||||
songlink := NewSongLinkClient()
|
||||
var availability *TrackAvailability
|
||||
var err error
|
||||
|
||||
deezerID := strings.TrimSpace(req.DeezerID)
|
||||
if prefixedDeezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found && strings.TrimSpace(prefixedDeezerID) != "" {
|
||||
deezerID = strings.TrimSpace(prefixedDeezerID)
|
||||
}
|
||||
|
||||
if deezerID != "" {
|
||||
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
} else {
|
||||
return "", fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if availability == nil || !availability.Amazon || availability.AmazonURL == "" {
|
||||
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
amazonURL = availability.AmazonURL
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
|
||||
}
|
||||
|
||||
return amazonURL, nil
|
||||
}
|
||||
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
@@ -385,40 +436,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
amazonURL := ""
|
||||
if req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
|
||||
amazonURL = cached.AmazonURL
|
||||
GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
songlink := NewSongLinkClient()
|
||||
var availability *TrackAvailability
|
||||
var err error
|
||||
|
||||
if amazonURL == "" {
|
||||
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
} else {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
amazonURL = availability.AmazonURL
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
|
||||
}
|
||||
amazonURL, err := resolveAmazonURLForRequest(req, "Amazon")
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, err
|
||||
}
|
||||
|
||||
if !isSafOutput && req.OutputDir != "." {
|
||||
@@ -467,13 +487,19 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
embedLyrics = false
|
||||
}
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
req.CoverURL,
|
||||
coverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.EmbedLyrics,
|
||||
embedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}()
|
||||
@@ -560,8 +586,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if isSafOutput || needsDecryption {
|
||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
if isSafOutput || needsDecryption || !req.EmbedMetadata {
|
||||
if !req.EmbedMetadata {
|
||||
GoLog("[Amazon] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
||||
} else {
|
||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
}
|
||||
} else {
|
||||
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
||||
if isFlacOutput {
|
||||
@@ -641,7 +671,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -1127,17 +1128,33 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
// Opus always uses 48kHz granule position internally
|
||||
totalSamples := granule - int64(preSkip)
|
||||
if totalSamples > 0 {
|
||||
quality.Duration = int(totalSamples / 48000)
|
||||
durationSec := float64(totalSamples) / 48000.0
|
||||
if durationSec > 0 {
|
||||
quality.Duration = int(math.Round(durationSec))
|
||||
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
||||
}
|
||||
}
|
||||
} else if quality.SampleRate > 0 {
|
||||
quality.Duration = int(granule / int64(quality.SampleRate))
|
||||
durationSec := float64(granule) / float64(quality.SampleRate)
|
||||
if durationSec > 0 {
|
||||
quality.Duration = int(math.Round(durationSec))
|
||||
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average bitrate from file size and actual duration
|
||||
if quality.Duration > 0 {
|
||||
// Fallback bitrate estimate if duration exists but bitrate couldn't be derived.
|
||||
if quality.Bitrate <= 0 && quality.Duration > 0 {
|
||||
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
||||
}
|
||||
// Guard against obviously invalid values from corrupted/unreliable granule reads.
|
||||
if quality.Duration > 24*60*60 {
|
||||
quality.Duration = 0
|
||||
quality.Bitrate = 0
|
||||
}
|
||||
if quality.Bitrate > 0 && quality.Bitrate < 8000 {
|
||||
quality.Bitrate = 0
|
||||
}
|
||||
|
||||
return quality, nil
|
||||
}
|
||||
@@ -1162,21 +1179,35 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||
}
|
||||
buf = buf[:n]
|
||||
|
||||
// Scan backwards for "OggS" magic
|
||||
lastPageOffset := -1
|
||||
for i := n - 4; i >= 0; i-- {
|
||||
if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' {
|
||||
lastPageOffset = i
|
||||
break
|
||||
if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' {
|
||||
continue
|
||||
}
|
||||
if i+27 > n {
|
||||
continue
|
||||
}
|
||||
// Validate minimal header fields to avoid false positives inside payload bytes.
|
||||
version := buf[i+4]
|
||||
headerType := buf[i+5]
|
||||
if version != 0 || headerType > 0x07 {
|
||||
continue
|
||||
}
|
||||
segmentCount := int(buf[i+26])
|
||||
headerLen := 27 + segmentCount
|
||||
if i+headerLen > n {
|
||||
continue
|
||||
}
|
||||
payloadLen := 0
|
||||
for s := 0; s < segmentCount; s++ {
|
||||
payloadLen += int(buf[i+27+s])
|
||||
}
|
||||
if i+headerLen+payloadLen > n {
|
||||
continue
|
||||
}
|
||||
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64).
|
||||
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
|
||||
}
|
||||
|
||||
if lastPageOffset < 0 || lastPageOffset+14 > n {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64)
|
||||
return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14]))
|
||||
return 0
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -13,12 +13,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
deezerBaseURL = "https://api.deezer.com/2.0"
|
||||
deezerSearchURL = deezerBaseURL + "/search"
|
||||
deezerTrackURL = deezerBaseURL + "/track/%s"
|
||||
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||
deezerBaseURL = "https://api.deezer.com/2.0"
|
||||
deezerSearchURL = deezerBaseURL + "/search"
|
||||
deezerTrackURL = deezerBaseURL + "/track/%s"
|
||||
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||
deezerArtistRelatedURL = deezerBaseURL + "/artist/%s/related"
|
||||
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||
|
||||
deezerCacheTTL = 10 * time.Minute
|
||||
|
||||
@@ -234,6 +235,8 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
DiscNumber: track.DiskNumber,
|
||||
ExternalURL: track.Link,
|
||||
ISRC: track.ISRC,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
||||
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,6 +759,66 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
|
||||
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:"))
|
||||
if normalizedArtistID == "" {
|
||||
return nil, fmt.Errorf("invalid Deezer artist ID")
|
||||
}
|
||||
|
||||
effectiveLimit := limit
|
||||
if effectiveLimit <= 0 {
|
||||
effectiveLimit = 12
|
||||
}
|
||||
|
||||
relatedURL := fmt.Sprintf("%s?limit=%d", fmt.Sprintf(deezerArtistRelatedURL, normalizedArtistID), effectiveLimit)
|
||||
var relatedResp struct {
|
||||
Data []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
PictureMedium string `json:"picture_medium"`
|
||||
PictureBig string `json:"picture_big"`
|
||||
PictureXL string `json:"picture_xl"`
|
||||
NbFan int `json:"nb_fan"`
|
||||
} `json:"data"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, relatedURL, &relatedResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if relatedResp.Error != nil {
|
||||
return nil, fmt.Errorf("deezer related artists error: %s", relatedResp.Error.Message)
|
||||
}
|
||||
|
||||
result := make([]SearchArtistResult, 0, len(relatedResp.Data))
|
||||
for _, artist := range relatedResp.Data {
|
||||
imageURL := artist.PictureXL
|
||||
if imageURL == "" {
|
||||
imageURL = artist.PictureBig
|
||||
}
|
||||
if imageURL == "" {
|
||||
imageURL = artist.PictureMedium
|
||||
}
|
||||
if imageURL == "" {
|
||||
imageURL = artist.Picture
|
||||
}
|
||||
|
||||
result = append(result, SearchArtistResult{
|
||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||
Name: artist.Name,
|
||||
Images: imageURL,
|
||||
Followers: artist.NbFan,
|
||||
Popularity: 0,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
|
||||
|
||||
type YoinkifyRequest struct {
|
||||
URL string `json:"url"`
|
||||
Format string `json:"format"`
|
||||
GenreSource string `json:"genreSource"`
|
||||
}
|
||||
|
||||
type DeezerDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) {
|
||||
rawSpotify := strings.TrimSpace(req.SpotifyID)
|
||||
if rawSpotify != "" {
|
||||
if isLikelySpotifyTrackID(rawSpotify) {
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil
|
||||
}
|
||||
|
||||
if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" {
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil
|
||||
}
|
||||
}
|
||||
|
||||
deezerID := strings.TrimSpace(req.DeezerID)
|
||||
if deezerID == "" {
|
||||
if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found {
|
||||
deezerID = strings.TrimSpace(prefixed)
|
||||
}
|
||||
}
|
||||
|
||||
if deezerID != "" {
|
||||
songlink := NewSongLinkClient()
|
||||
spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err)
|
||||
}
|
||||
spotifyID = strings.TrimSpace(spotifyID)
|
||||
if spotifyID == "" {
|
||||
return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID)
|
||||
}
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify")
|
||||
}
|
||||
|
||||
func isLikelySpotifyTrackID(value string) bool {
|
||||
if len(value) != 22 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error {
|
||||
payload := YoinkifyRequest{
|
||||
URL: spotifyURL,
|
||||
Format: "flac",
|
||||
GenreSource: "spotify",
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode Yoinkify request: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Yoinkify request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("failed to call Yoinkify: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
bodyText := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyText != "" {
|
||||
return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText)
|
||||
}
|
||||
return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if strings.Contains(contentType, "application/json") {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
bodyText := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyText == "" {
|
||||
bodyText = "empty JSON payload"
|
||||
}
|
||||
return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
deezerClient := GetDeezerClient()
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
||||
if err != nil {
|
||||
return DeezerDownloadResult{}, err
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
embedLyrics = false
|
||||
}
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
coverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
embedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}()
|
||||
|
||||
if err := deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return DeezerDownloadResult{}, fmt.Errorf("deezer yoinkify failed: %w", err)
|
||||
}
|
||||
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
}
|
||||
|
||||
if isSafOutput || !req.EmbedMetadata {
|
||||
if !req.EmbedMetadata {
|
||||
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
||||
} else {
|
||||
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
}
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
}
|
||||
|
||||
bitDepth, sampleRate := 0, 0
|
||||
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return DeezerDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Package gobackend provides exported functions for gomobile binding
|
||||
// These functions are the bridge between Flutter and Go backend
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -125,6 +123,35 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetSpotifyRelatedArtists(artistID string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := NewSpotifyMetadataClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "spotify:"))
|
||||
if normalizedArtistID == "" {
|
||||
return "", fmt.Errorf("invalid Spotify artist ID")
|
||||
}
|
||||
|
||||
artists, err := client.GetRelatedArtists(ctx, normalizedArtistID, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"artists": artists,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
@@ -140,6 +167,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetSongLinkNetworkOptions is kept for backward compatibility.
|
||||
// It now applies global network compatibility options for all backend API requests.
|
||||
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
|
||||
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
||||
}
|
||||
|
||||
type DownloadRequest struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
@@ -155,6 +188,7 @@ type DownloadRequest struct {
|
||||
OutputExt string `json:"output_ext,omitempty"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
Quality string `json:"quality"`
|
||||
EmbedMetadata bool `json:"embed_metadata"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
@@ -173,6 +207,7 @@ type DownloadRequest struct {
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
UseExtensions bool `json:"use_extensions,omitempty"`
|
||||
UseFallback bool `json:"use_fallback,omitempty"`
|
||||
SongLinkRegion string `json:"songlink_region,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResponse struct {
|
||||
@@ -374,11 +409,20 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
func applySongLinkRegionFromRequest(req *DownloadRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
SetSongLinkRegion(req.SongLinkRegion)
|
||||
}
|
||||
|
||||
func DownloadTrack(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
applySongLinkRegionFromRequest(&req)
|
||||
defer closeOwnedOutputFD(req.OutputFD)
|
||||
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
@@ -453,13 +497,31 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
}
|
||||
}
|
||||
err = amazonErr
|
||||
case "deezer":
|
||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||
if deezerErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: deezerResult.FilePath,
|
||||
BitDepth: deezerResult.BitDepth,
|
||||
SampleRate: deezerResult.SampleRate,
|
||||
Title: deezerResult.Title,
|
||||
Artist: deezerResult.Artist,
|
||||
Album: deezerResult.Album,
|
||||
ReleaseDate: deezerResult.ReleaseDate,
|
||||
TrackNumber: deezerResult.TrackNumber,
|
||||
DiscNumber: deezerResult.DiscNumber,
|
||||
ISRC: deezerResult.ISRC,
|
||||
LyricsLRC: deezerResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = deezerErr
|
||||
case "youtube":
|
||||
youtubeResult, youtubeErr := downloadFromYouTube(req)
|
||||
if youtubeErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: youtubeResult.FilePath,
|
||||
BitDepth: 0, // Lossy format, no bit depth
|
||||
SampleRate: 0, // Lossy format
|
||||
BitDepth: 0,
|
||||
SampleRate: 0,
|
||||
Title: youtubeResult.Title,
|
||||
Artist: youtubeResult.Artist,
|
||||
Album: youtubeResult.Album,
|
||||
@@ -537,6 +599,11 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
||||
}
|
||||
|
||||
if req.UseExtensions {
|
||||
// Respect strict mode when auto fallback is disabled:
|
||||
// for built-in providers, route directly to selected service only.
|
||||
if !req.UseFallback && isBuiltInProvider(serviceNormalized) {
|
||||
return DownloadTrack(normalizedJSON)
|
||||
}
|
||||
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
|
||||
if err != nil {
|
||||
return errorResponse(err.Error())
|
||||
@@ -556,6 +623,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
applySongLinkRegionFromRequest(&req)
|
||||
defer closeOwnedOutputFD(req.OutputFD)
|
||||
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
@@ -571,7 +640,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
|
||||
enrichRequestExtendedMetadata(&req)
|
||||
|
||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||
allServices := []string{"tidal", "qobuz", "amazon", "deezer"}
|
||||
preferredService := req.Service
|
||||
if preferredService == "" {
|
||||
preferredService = "tidal"
|
||||
@@ -659,6 +728,26 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||
}
|
||||
err = amazonErr
|
||||
case "deezer":
|
||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||
if deezerErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: deezerResult.FilePath,
|
||||
BitDepth: deezerResult.BitDepth,
|
||||
SampleRate: deezerResult.SampleRate,
|
||||
Title: deezerResult.Title,
|
||||
Artist: deezerResult.Artist,
|
||||
Album: deezerResult.Album,
|
||||
ReleaseDate: deezerResult.ReleaseDate,
|
||||
TrackNumber: deezerResult.TrackNumber,
|
||||
DiscNumber: deezerResult.DiscNumber,
|
||||
ISRC: deezerResult.ISRC,
|
||||
LyricsLRC: deezerResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(deezerErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Deezer error: %v\n", deezerErr)
|
||||
}
|
||||
err = deezerErr
|
||||
}
|
||||
|
||||
if err != nil && errors.Is(err, ErrDownloadCancelled) {
|
||||
@@ -910,7 +999,6 @@ func SetDownloadDirectory(path string) error {
|
||||
return setDownloadDir(path)
|
||||
}
|
||||
|
||||
// AllowDownloadDir adds a directory to the extension file sandbox allowlist.
|
||||
func AllowDownloadDir(path string) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return
|
||||
@@ -1008,6 +1096,64 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
|
||||
return lrcContent, nil
|
||||
}
|
||||
|
||||
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && lyrics != "" {
|
||||
result := map[string]interface{}{
|
||||
"lyrics": lyrics,
|
||||
"source": "Embedded",
|
||||
"sync_type": "EMBEDDED",
|
||||
"instrumental": false,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"lyrics": "",
|
||||
"source": "",
|
||||
"sync_type": "",
|
||||
"instrumental": false,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
lrcContent := ""
|
||||
if lyricsData.Instrumental {
|
||||
lrcContent = "[instrumental:true]"
|
||||
} else {
|
||||
lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"lyrics": lrcContent,
|
||||
"source": lyricsData.Source,
|
||||
"sync_type": lyricsData.SyncType,
|
||||
"instrumental": lyricsData.Instrumental,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||
err := EmbedLyrics(filePath, lyrics)
|
||||
if err != nil {
|
||||
@@ -1084,6 +1230,26 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
artists, err := client.GetRelatedArtists(ctx, artistID, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"artists": artists,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -1460,16 +1626,13 @@ func errorResponse(msg string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ====================
|
||||
|
||||
// DownloadFromYouTube downloads a track from YouTube via Cobalt API
|
||||
// This is a lossy-only provider (Opus 256kbps or MP3 320kbps)
|
||||
// It does NOT participate in the lossless fallback chain
|
||||
func DownloadFromYouTube(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
applySongLinkRegionFromRequest(&req)
|
||||
defer closeOwnedOutputFD(req.OutputFD)
|
||||
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
@@ -1511,20 +1674,14 @@ func DownloadFromYouTube(requestJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// IsYouTubeURLExport checks if a URL is a YouTube URL (exported for Flutter)
|
||||
func IsYouTubeURLExport(urlStr string) bool {
|
||||
return IsYouTubeURL(urlStr)
|
||||
}
|
||||
|
||||
// ExtractYouTubeVideoIDExport extracts video ID from YouTube URL (exported for Flutter)
|
||||
func ExtractYouTubeVideoIDExport(urlStr string) (string, error) {
|
||||
return ExtractYouTubeVideoID(urlStr)
|
||||
}
|
||||
|
||||
// ==================== COVER & LYRICS SAVE ====================
|
||||
|
||||
// DownloadCoverToFile downloads cover art from URL and saves to outputPath.
|
||||
// If maxQuality is true, upgrades to highest available resolution.
|
||||
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
@@ -1543,7 +1700,6 @@ func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtractCoverToFile extracts embedded cover art from audio file and saves to outputPath.
|
||||
func ExtractCoverToFile(audioPath string, outputPath string) error {
|
||||
lower := strings.ToLower(audioPath)
|
||||
|
||||
@@ -1572,7 +1728,6 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchAndSaveLyrics fetches lyrics from lrclib and saves as .lrc file.
|
||||
func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string) error {
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
@@ -1599,6 +1754,55 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetLyricsProvidersJSON(providersJSON string) error {
|
||||
var providers []string
|
||||
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
SetLyricsProviderOrder(providers)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLyricsProvidersJSON() (string, error) {
|
||||
providers := GetLyricsProviderOrder()
|
||||
jsonBytes, err := json.Marshal(providers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetAvailableLyricsProvidersJSON() (string, error) {
|
||||
providers := GetAvailableLyricsProviders()
|
||||
jsonBytes, err := json.Marshal(providers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
|
||||
opts := GetLyricsFetchOptions()
|
||||
if strings.TrimSpace(optionsJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
SetLyricsFetchOptions(opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLyricsFetchOptionsJSON() (string, error) {
|
||||
opts := GetLyricsFetchOptions()
|
||||
jsonBytes, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
|
||||
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
|
||||
// complete metadata from the internet before embedding.
|
||||
@@ -2146,6 +2350,8 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return "", fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
applySongLinkRegionFromRequest(&req)
|
||||
defer closeOwnedOutputFD(req.OutputFD)
|
||||
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
@@ -3027,7 +3233,10 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
|
||||
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
@@ -3038,7 +3247,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
||||
})()
|
||||
`, functionName, functionName)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout)
|
||||
result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
||||
}
|
||||
|
||||
@@ -48,11 +48,12 @@ type LoadedExtension struct {
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
runtime *ExtensionRuntime
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
}
|
||||
|
||||
type ExtensionManager struct {
|
||||
@@ -243,6 +244,7 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
ext.runtime = runtime
|
||||
runtime.RegisterAPIs(vm)
|
||||
runtime.RegisterGoBackendAPIs(vm)
|
||||
|
||||
@@ -295,6 +297,13 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
GoLog("[Extension] Cleanup called for %s\n", extensionID)
|
||||
}
|
||||
}
|
||||
if ext.runtime != nil {
|
||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
|
||||
}
|
||||
ext.runtime.closeStorageFlusher()
|
||||
ext.runtime = nil
|
||||
}
|
||||
|
||||
delete(m.extensions, extensionID)
|
||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||
@@ -536,7 +545,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
extDir := existing.SourceDir
|
||||
wasEnabled := existing.Enabled
|
||||
|
||||
m.CleanupExtension(existing.ID)
|
||||
m.UnloadExtension(existing.ID)
|
||||
|
||||
if extDir != "" {
|
||||
@@ -713,6 +721,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
Permissions []string `json:"permissions"`
|
||||
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||
HasDownloadProvider bool `json:"has_download_provider"`
|
||||
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||
@@ -770,6 +779,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
Permissions: permissions,
|
||||
HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
|
||||
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
||||
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||
TrackMatching: ext.Manifest.TrackMatching,
|
||||
@@ -907,7 +917,6 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, id := range extensionIDs {
|
||||
m.CleanupExtension(id)
|
||||
m.UnloadExtension(id)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ type ExtensionType string
|
||||
const (
|
||||
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
|
||||
)
|
||||
|
||||
type SettingType string
|
||||
@@ -167,10 +168,10 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
|
||||
for _, t := range m.Types {
|
||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
|
||||
return &ManifestValidationError{
|
||||
Field: "type",
|
||||
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
|
||||
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +227,10 @@ func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||
return m.HasType(ExtensionTypeDownloadProvider)
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsLyricsProvider() bool {
|
||||
return m.HasType(ExtensionTypeLyricsProvider)
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
for _, allowed := range m.Permissions.Network {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension provider interfaces
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -7,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -14,9 +14,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Metadata Types ====================
|
||||
|
||||
// ExtTrackMetadata represents track metadata from an extension
|
||||
type ExtTrackMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -634,7 +631,7 @@ func GetProviderPriority() []string {
|
||||
defer providerPriorityMu.RUnlock()
|
||||
|
||||
if len(providerPriority) == 0 {
|
||||
return []string{"tidal", "qobuz", "amazon"}
|
||||
return []string{"tidal", "qobuz", "amazon", "deezer"}
|
||||
}
|
||||
|
||||
result := make([]string, len(providerPriority))
|
||||
@@ -674,8 +671,20 @@ func isBuiltInProvider(providerID string) bool {
|
||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||
priority := GetProviderPriority()
|
||||
extManager := GetExtensionManager()
|
||||
strictMode := !req.UseFallback
|
||||
selectedProvider := strings.TrimSpace(req.Service)
|
||||
|
||||
if req.Service != "" && isBuiltInProvider(req.Service) {
|
||||
if strictMode {
|
||||
if selectedProvider == "" {
|
||||
selectedProvider = strings.TrimSpace(req.Source)
|
||||
}
|
||||
if selectedProvider != "" {
|
||||
priority = []string{selectedProvider}
|
||||
GoLog("[DownloadWithExtensionFallback] Strict mode enabled, provider locked to: %s\n", selectedProvider)
|
||||
}
|
||||
}
|
||||
|
||||
if !strictMode && req.Service != "" && isBuiltInProvider(strings.ToLower(req.Service)) {
|
||||
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
|
||||
newPriority := []string{req.Service}
|
||||
for _, p := range priority {
|
||||
@@ -690,7 +699,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
var lastErr error
|
||||
var skipBuiltIn bool
|
||||
|
||||
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
||||
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) {
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
|
||||
@@ -753,7 +762,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
||||
if req.Source != "" &&
|
||||
!isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
|
||||
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
|
||||
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
@@ -767,12 +778,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||
|
||||
outputPath := buildOutputPath(req)
|
||||
if req.ItemID != "" {
|
||||
StartItemProgress(req.ItemID)
|
||||
}
|
||||
|
||||
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
||||
normalized := float64(percent) / 100.0
|
||||
if normalized < 0 {
|
||||
normalized = 0
|
||||
}
|
||||
if normalized > 1 {
|
||||
normalized = 1
|
||||
}
|
||||
SetItemProgress(req.ItemID, normalized, 0, 0)
|
||||
}
|
||||
})
|
||||
if req.ItemID != "" {
|
||||
if err == nil && result != nil && result.Success {
|
||||
CompleteItemProgress(req.ItemID)
|
||||
} else {
|
||||
RemoveItemProgress(req.ItemID)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil && result.Success {
|
||||
resp := &DownloadResponse{
|
||||
@@ -787,7 +815,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||
} else {
|
||||
@@ -859,18 +887,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
for _, providerID := range priority {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
providerIDNormalized := strings.ToLower(providerID)
|
||||
if providerID == req.Source {
|
||||
continue
|
||||
}
|
||||
|
||||
if skipBuiltIn && isBuiltInProvider(providerID) {
|
||||
if skipBuiltIn && isBuiltInProvider(providerIDNormalized) {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
|
||||
continue
|
||||
}
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||
|
||||
if isBuiltInProvider(providerID) {
|
||||
if isBuiltInProvider(providerIDNormalized) {
|
||||
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -891,9 +924,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
result, err := tryBuiltInProvider(providerID, req)
|
||||
result, err := tryBuiltInProvider(providerIDNormalized, req)
|
||||
if err == nil && result.Success {
|
||||
result.Service = providerID
|
||||
result.Service = providerIDNormalized
|
||||
if req.Label != "" {
|
||||
result.Label = req.Label
|
||||
}
|
||||
@@ -914,11 +947,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
Success: false,
|
||||
Error: "Download cancelled",
|
||||
ErrorType: "cancelled",
|
||||
Service: providerID,
|
||||
Service: providerIDNormalized,
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
|
||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerIDNormalized, err)
|
||||
}
|
||||
} else {
|
||||
ext, err := extManager.GetExtension(providerID)
|
||||
@@ -943,12 +976,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
outputPath := buildOutputPath(req)
|
||||
if req.ItemID != "" {
|
||||
StartItemProgress(req.ItemID)
|
||||
}
|
||||
|
||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
||||
normalized := float64(percent) / 100.0
|
||||
if normalized < 0 {
|
||||
normalized = 0
|
||||
}
|
||||
if normalized > 1 {
|
||||
normalized = 1
|
||||
}
|
||||
SetItemProgress(req.ItemID, normalized, 0, 0)
|
||||
}
|
||||
})
|
||||
if req.ItemID != "" {
|
||||
if err == nil && result != nil && result.Success {
|
||||
CompleteItemProgress(req.ItemID)
|
||||
} else {
|
||||
RemoveItemProgress(req.ItemID)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil && result.Success {
|
||||
resp := &DownloadResponse{
|
||||
@@ -963,7 +1013,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||
} else {
|
||||
@@ -1097,6 +1147,24 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
}
|
||||
}
|
||||
err = amazonErr
|
||||
case "deezer":
|
||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||
if deezerErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: deezerResult.FilePath,
|
||||
BitDepth: deezerResult.BitDepth,
|
||||
SampleRate: deezerResult.SampleRate,
|
||||
Title: deezerResult.Title,
|
||||
Artist: deezerResult.Artist,
|
||||
Album: deezerResult.Album,
|
||||
ReleaseDate: deezerResult.ReleaseDate,
|
||||
TrackNumber: deezerResult.TrackNumber,
|
||||
DiscNumber: deezerResult.DiscNumber,
|
||||
ISRC: deezerResult.ISRC,
|
||||
LyricsLRC: deezerResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = deezerErr
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown built-in provider: %s", providerID)
|
||||
}
|
||||
@@ -1699,3 +1767,140 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata
|
||||
|
||||
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
||||
}
|
||||
|
||||
// ==================== Lyrics Provider ====================
|
||||
|
||||
// ExtLyricsResult represents lyrics data returned from an extension
|
||||
type ExtLyricsResult struct {
|
||||
Lines []ExtLyricsLine `json:"lines"`
|
||||
SyncType string `json:"syncType"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
PlainLyrics string `json:"plainLyrics"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
type ExtLyricsLine struct {
|
||||
StartTimeMs int64 `json:"startTimeMs"`
|
||||
Words string `json:"words"`
|
||||
EndTimeMs int64 `json:"endTimeMs"`
|
||||
}
|
||||
|
||||
// FetchLyrics calls the extension's fetchLyrics function
|
||||
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
||||
if !p.extension.Manifest.IsLyricsProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
||||
const trackVar = "__sf_lyrics_track"
|
||||
const artistVar = "__sf_lyrics_artist"
|
||||
const albumVar = "__sf_lyrics_album"
|
||||
const durationVar = "__sf_lyrics_duration"
|
||||
global := p.vm.GlobalObject()
|
||||
_ = global.Set(trackVar, trackName)
|
||||
_ = global.Set(artistVar, artistName)
|
||||
_ = global.Set(albumVar, albumName)
|
||||
_ = global.Set(durationVar, durationSec)
|
||||
defer func() {
|
||||
global.Delete(trackVar)
|
||||
global.Delete(artistVar)
|
||||
global.Delete(albumVar)
|
||||
global.Delete(durationVar)
|
||||
}()
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.fetchLyrics === 'function') {
|
||||
return extension.fetchLyrics(__sf_lyrics_track, __sf_lyrics_artist, __sf_lyrics_album, __sf_lyrics_duration);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
if IsTimeoutError(err) {
|
||||
return nil, fmt.Errorf("fetchLyrics timeout: extension took too long to respond")
|
||||
}
|
||||
return nil, fmt.Errorf("fetchLyrics failed: %w", err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("fetchLyrics returned null")
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal lyrics result: %w", err)
|
||||
}
|
||||
|
||||
var extResult ExtLyricsResult
|
||||
if err := json.Unmarshal(jsonBytes, &extResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse lyrics result: %w", err)
|
||||
}
|
||||
|
||||
// Convert ExtLyricsResult to LyricsResponse
|
||||
response := &LyricsResponse{
|
||||
SyncType: extResult.SyncType,
|
||||
Instrumental: extResult.Instrumental,
|
||||
PlainLyrics: extResult.PlainLyrics,
|
||||
Provider: extResult.Provider,
|
||||
Source: "Extension: " + p.extension.ID,
|
||||
}
|
||||
|
||||
if response.Provider == "" {
|
||||
response.Provider = p.extension.Manifest.DisplayName
|
||||
}
|
||||
|
||||
for _, line := range extResult.Lines {
|
||||
response.Lines = append(response.Lines, LyricsLine{
|
||||
StartTimeMs: line.StartTimeMs,
|
||||
Words: line.Words,
|
||||
EndTimeMs: line.EndTimeMs,
|
||||
})
|
||||
}
|
||||
|
||||
// If the extension provided plainLyrics but no lines, parse them as unsynced
|
||||
if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental {
|
||||
response.SyncType = "UNSYNCED"
|
||||
for _, line := range strings.Split(response.PlainLyrics, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
response.Lines = append(response.Lines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: line,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetLyricsProviders returns all enabled extensions that provide lyrics
|
||||
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
|
||||
// Keep a deterministic order so provider selection is stable across runs.
|
||||
sort.Slice(providers, func(i, j int) bool {
|
||||
return providers[i].extension.ID < providers[j].extension.ID
|
||||
})
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
@@ -88,47 +88,82 @@ type ExtensionRuntime struct {
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
storageLoaded bool
|
||||
storageDirty bool
|
||||
storageClosed bool
|
||||
storageTimer *time.Timer
|
||||
storageWriteMu sync.Mutex
|
||||
|
||||
credentialsMu sync.RWMutex
|
||||
credentialsCache map[string]interface{}
|
||||
credentialsLoaded bool
|
||||
storageFlushDelay time.Duration
|
||||
}
|
||||
|
||||
type privateIPCacheEntry struct {
|
||||
isPrivate bool
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
privateIPCacheTTL = 5 * time.Minute
|
||||
privateIPErrorCacheTTL = 30 * time.Second
|
||||
maxPrivateIPCacheSize = 1024
|
||||
)
|
||||
|
||||
var (
|
||||
privateIPCache = make(map[string]privateIPCacheEntry)
|
||||
privateIPCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
runtime := &ExtensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
cookieJar: jar,
|
||||
dataDir: ext.DataDir,
|
||||
vm: ext.VM,
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
cookieJar: jar,
|
||||
dataDir: ext.DataDir,
|
||||
vm: ext.VM,
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||
}
|
||||
Transport: sharedTransport,
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||
}
|
||||
|
||||
domain := req.URL.Hostname()
|
||||
if domain == "" {
|
||||
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
|
||||
return fmt.Errorf("redirect blocked: hostname is required")
|
||||
}
|
||||
if !ext.Manifest.IsDomainAllowed(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain}
|
||||
}
|
||||
if isPrivateIP(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
},
|
||||
domain := req.URL.Hostname()
|
||||
if domain == "" {
|
||||
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
|
||||
return fmt.Errorf("redirect blocked: hostname is required")
|
||||
}
|
||||
if !ext.Manifest.IsDomainAllowed(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain}
|
||||
}
|
||||
if isPrivateIP(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
}
|
||||
runtime.httpClient = client
|
||||
|
||||
@@ -147,7 +182,6 @@ func (e *RedirectBlockedError) Error() string {
|
||||
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
|
||||
}
|
||||
|
||||
// isPrivateIP checks if a hostname resolves to a private/local IP address
|
||||
func isPrivateIP(host string) bool {
|
||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||
if hostLower == "" {
|
||||
@@ -162,18 +196,68 @@ func isPrivateIP(host string) bool {
|
||||
return isPrivateIPAddr(ip)
|
||||
}
|
||||
|
||||
if cached, ok := getPrivateIPCache(hostLower); ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(hostLower)
|
||||
if err != nil {
|
||||
setPrivateIPCache(hostLower, false, privateIPErrorCacheTTL)
|
||||
return false
|
||||
}
|
||||
|
||||
isPrivate := false
|
||||
for _, ip := range ips {
|
||||
if isPrivateIPAddr(ip) {
|
||||
return true
|
||||
isPrivate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
setPrivateIPCache(hostLower, isPrivate, privateIPCacheTTL)
|
||||
return isPrivate
|
||||
}
|
||||
|
||||
func getPrivateIPCache(host string) (bool, bool) {
|
||||
now := time.Now()
|
||||
|
||||
privateIPCacheMu.RLock()
|
||||
entry, exists := privateIPCache[host]
|
||||
privateIPCacheMu.RUnlock()
|
||||
if !exists {
|
||||
return false, false
|
||||
}
|
||||
|
||||
if now.Before(entry.expiresAt) {
|
||||
return entry.isPrivate, true
|
||||
}
|
||||
|
||||
privateIPCacheMu.Lock()
|
||||
delete(privateIPCache, host)
|
||||
privateIPCacheMu.Unlock()
|
||||
return false, false
|
||||
}
|
||||
|
||||
func setPrivateIPCache(host string, isPrivate bool, ttl time.Duration) {
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
|
||||
privateIPCacheMu.Lock()
|
||||
if len(privateIPCache) >= maxPrivateIPCacheSize {
|
||||
now := time.Now()
|
||||
for key, entry := range privateIPCache {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(privateIPCache, key)
|
||||
}
|
||||
}
|
||||
if len(privateIPCache) >= maxPrivateIPCacheSize {
|
||||
privateIPCache = make(map[string]privateIPCacheEntry)
|
||||
}
|
||||
}
|
||||
privateIPCache[host] = privateIPCacheEntry{
|
||||
isPrivate: isPrivate,
|
||||
expiresAt: expiresAt,
|
||||
}
|
||||
privateIPCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func isPrivateIPAddr(ip net.IP) bool {
|
||||
|
||||
@@ -396,13 +396,14 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fullSrc)
|
||||
srcFile, err := os.Open(fullSrc)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read source: %v", err),
|
||||
})
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
@@ -412,10 +413,26 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
||||
dstFile, err := os.OpenFile(fullDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write destination: %v", err),
|
||||
"error": fmt.Sprintf("failed to open destination: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
_ = dstFile.Close()
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to copy file: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if err := dstFile.Close(); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to finalize destination: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,42 +11,164 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Storage API ====================
|
||||
|
||||
const (
|
||||
defaultStorageFlushDelay = 400 * time.Millisecond
|
||||
storageFlushRetryDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) getStoragePath() string {
|
||||
return filepath.Join(r.dataDir, "storage.json")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
|
||||
if len(src) == 0 {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
dst := make(map[string]interface{}, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
||||
r.storageMu.RLock()
|
||||
if r.storageLoaded {
|
||||
r.storageMu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
r.storageMu.RUnlock()
|
||||
|
||||
r.storageMu.Lock()
|
||||
defer r.storageMu.Unlock()
|
||||
if r.storageLoaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
r.storageCache = make(map[string]interface{})
|
||||
r.storageLoaded = true
|
||||
return nil
|
||||
}
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
var storage map[string]interface{}
|
||||
if err := json.Unmarshal(data, &storage); err != nil {
|
||||
return err
|
||||
}
|
||||
if storage == nil {
|
||||
storage = make(map[string]interface{})
|
||||
}
|
||||
|
||||
r.storageCache = storage
|
||||
r.storageLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
r.storageMu.RLock()
|
||||
defer r.storageMu.RUnlock()
|
||||
return cloneInterfaceMap(r.storageCache), nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := json.MarshalIndent(storage, "", " ")
|
||||
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
if r.storageClosed {
|
||||
return
|
||||
}
|
||||
if r.storageTimer != nil {
|
||||
return
|
||||
}
|
||||
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
||||
data, err := json.Marshal(storage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(storagePath, data, 0600)
|
||||
r.storageWriteMu.Lock()
|
||||
defer r.storageWriteMu.Unlock()
|
||||
|
||||
return os.WriteFile(r.getStoragePath(), data, 0600)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
|
||||
if err := r.flushStorageDirty(); err != nil {
|
||||
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageDirty() error {
|
||||
r.storageMu.Lock()
|
||||
if r.storageClosed {
|
||||
r.storageTimer = nil
|
||||
r.storageMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if !r.storageDirty {
|
||||
r.storageTimer = nil
|
||||
r.storageMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
snapshot := cloneInterfaceMap(r.storageCache)
|
||||
r.storageDirty = false
|
||||
r.storageTimer = nil
|
||||
r.storageMu.Unlock()
|
||||
|
||||
if err := r.persistStorageSnapshot(snapshot); err != nil {
|
||||
r.storageMu.Lock()
|
||||
r.storageDirty = true
|
||||
r.queueStorageFlushLocked(storageFlushRetryDelay)
|
||||
r.storageMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageNow() error {
|
||||
r.storageMu.Lock()
|
||||
if r.storageTimer != nil {
|
||||
r.storageTimer.Stop()
|
||||
r.storageTimer = nil
|
||||
}
|
||||
if !r.storageLoaded || r.storageClosed {
|
||||
r.storageMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
snapshot := cloneInterfaceMap(r.storageCache)
|
||||
r.storageDirty = false
|
||||
r.storageMu.Unlock()
|
||||
|
||||
return r.persistStorageSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) closeStorageFlusher() {
|
||||
r.storageMu.Lock()
|
||||
r.storageClosed = true
|
||||
r.storageDirty = false
|
||||
if r.storageTimer != nil {
|
||||
r.storageTimer.Stop()
|
||||
r.storageTimer = nil
|
||||
}
|
||||
r.storageMu.Unlock()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
@@ -56,13 +178,14 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
value, exists := storage[key]
|
||||
r.storageMu.RLock()
|
||||
value, exists := r.storageCache[key]
|
||||
r.storageMu.RUnlock()
|
||||
if !exists {
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
@@ -81,18 +204,26 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
storage[key] = value
|
||||
|
||||
if err := r.saveStorage(storage); err != nil {
|
||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||
r.storageMu.Lock()
|
||||
if r.storageClosed {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
if existing, exists := r.storageCache[key]; exists {
|
||||
if reflect.DeepEqual(existing, value) {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
}
|
||||
r.storageCache[key] = value
|
||||
r.storageDirty = true
|
||||
r.queueStorageFlushLocked(r.storageFlushDelay)
|
||||
r.storageMu.Unlock()
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
@@ -104,18 +235,24 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
delete(storage, key)
|
||||
|
||||
if err := r.saveStorage(storage); err != nil {
|
||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||
r.storageMu.Lock()
|
||||
if r.storageClosed {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
if _, exists := r.storageCache[key]; !exists {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
delete(r.storageCache, key)
|
||||
r.storageDirty = true
|
||||
r.queueStorageFlushLocked(r.storageFlushDelay)
|
||||
r.storageMu.Unlock()
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
@@ -159,31 +296,61 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
||||
r.credentialsMu.RLock()
|
||||
if r.credentialsLoaded {
|
||||
r.credentialsMu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
r.credentialsMu.RUnlock()
|
||||
|
||||
r.credentialsMu.Lock()
|
||||
defer r.credentialsMu.Unlock()
|
||||
if r.credentialsLoaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
credPath := r.getCredentialsPath()
|
||||
data, err := os.ReadFile(credPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
r.credentialsCache = make(map[string]interface{})
|
||||
r.credentialsLoaded = true
|
||||
return nil
|
||||
}
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||
}
|
||||
decrypted, err := decryptAES(data, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||
return fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
var creds map[string]interface{}
|
||||
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
||||
return err
|
||||
}
|
||||
if creds == nil {
|
||||
creds = make(map[string]interface{})
|
||||
}
|
||||
|
||||
r.credentialsCache = creds
|
||||
r.credentialsLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
r.credentialsMu.RLock()
|
||||
defer r.credentialsMu.RUnlock()
|
||||
return cloneInterfaceMap(r.credentialsCache), nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
@@ -202,7 +369,15 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
}
|
||||
|
||||
credPath := r.getCredentialsPath()
|
||||
return os.WriteFile(credPath, encrypted, 0600)
|
||||
if err := os.WriteFile(credPath, encrypted, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.credentialsMu.Lock()
|
||||
r.credentialsCache = cloneInterfaceMap(creds)
|
||||
r.credentialsLoaded = true
|
||||
r.credentialsMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
@@ -216,8 +391,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -225,9 +399,12 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
creds[key] = value
|
||||
r.credentialsMu.RLock()
|
||||
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
||||
r.credentialsMu.RUnlock()
|
||||
nextCreds[key] = value
|
||||
|
||||
if err := r.saveCredentials(creds); err != nil {
|
||||
if err := r.saveCredentials(nextCreds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -247,13 +424,14 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
value, exists := creds[key]
|
||||
r.credentialsMu.RLock()
|
||||
value, exists := r.credentialsCache[key]
|
||||
r.credentialsMu.RUnlock()
|
||||
if !exists {
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
@@ -271,15 +449,17 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
delete(creds, key)
|
||||
r.credentialsMu.RLock()
|
||||
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
||||
r.credentialsMu.RUnlock()
|
||||
delete(nextCreds, key)
|
||||
|
||||
if err := r.saveCredentials(creds); err != nil {
|
||||
if err := r.saveCredentials(nextCreds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -294,12 +474,13 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
_, exists := creds[key]
|
||||
r.credentialsMu.RLock()
|
||||
_, exists := r.credentialsCache[key]
|
||||
r.credentialsMu.RUnlock()
|
||||
return r.vm.ToValue(exists)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) {
|
||||
t.Helper()
|
||||
result := runtime.storageSet(goja.FunctionCall{
|
||||
Arguments: []goja.Value{
|
||||
runtime.vm.ToValue(key),
|
||||
runtime.vm.ToValue(value),
|
||||
},
|
||||
})
|
||||
if !result.ToBoolean() {
|
||||
t.Fatalf("storage.set(%q) returned false", key)
|
||||
}
|
||||
}
|
||||
|
||||
func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read storage file: %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal storage file: %v", err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "storage-test",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "storage-test",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = 25 * time.Millisecond
|
||||
runtime.RegisterAPIs(goja.New())
|
||||
|
||||
setStorageValue(t, runtime, "k1", "v1")
|
||||
setStorageValue(t, runtime, "k2", 2)
|
||||
|
||||
storagePath := filepath.Join(ext.DataDir, "storage.json")
|
||||
deadline := time.Now().Add(1500 * time.Millisecond)
|
||||
|
||||
var raw []byte
|
||||
for time.Now().Before(deadline) {
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err == nil {
|
||||
raw = data
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
t.Fatalf("storage.json was not written within timeout")
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal storage file: %v", err)
|
||||
}
|
||||
if parsed["k1"] != "v1" {
|
||||
t.Fatalf("expected k1=v1, got %v", parsed["k1"])
|
||||
}
|
||||
if parsed["k2"] != float64(2) {
|
||||
t.Fatalf("expected k2=2, got %v", parsed["k2"])
|
||||
}
|
||||
if bytes.Contains(raw, []byte("\n")) {
|
||||
t.Fatalf("expected compact JSON without indentation, got: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "unload-storage-test",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "unload-storage-test",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
VM: goja.New(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = time.Hour
|
||||
runtime.RegisterAPIs(ext.VM)
|
||||
ext.runtime = runtime
|
||||
|
||||
manager := &ExtensionManager{
|
||||
extensions: map[string]*LoadedExtension{
|
||||
ext.ID: ext,
|
||||
},
|
||||
}
|
||||
|
||||
setStorageValue(t, runtime, "persist_on_unload", true)
|
||||
|
||||
if err := manager.UnloadExtension(ext.ID); err != nil {
|
||||
t.Fatalf("UnloadExtension failed: %v", err)
|
||||
}
|
||||
|
||||
storagePath := filepath.Join(ext.DataDir, "storage.json")
|
||||
parsed := readStorageMap(t, storagePath)
|
||||
if parsed["persist_on_unload"] != true {
|
||||
t.Fatalf("expected pending storage value to be flushed on unload, got %v", parsed["persist_on_unload"])
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,6 @@ type StoreRegistry struct {
|
||||
Extensions []StoreExtension `json:"extensions"`
|
||||
}
|
||||
|
||||
// StoreExtensionResponse is the normalized response sent to Flutter
|
||||
type StoreExtensionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -218,7 +217,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
|
||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||
resp, err := client.Get(s.registryURL)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
@@ -310,7 +309,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
||||
resp, err := client.Get(ext.getDownloadURL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
@@ -421,7 +420,6 @@ func (s *ExtensionStore) ClearCache() {
|
||||
LogInfo("ExtensionStore", "Cache cleared")
|
||||
}
|
||||
|
||||
// Helper: case-insensitive contains
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
return containsStr(toLower(s), substr)
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.0
|
||||
toolchain go1.25.7
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
||||
golang.org/x/net v0.50.0
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||
@@ -36,6 +38,8 @@ golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBr
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
@@ -37,6 +38,16 @@ const (
|
||||
Second = time.Second
|
||||
)
|
||||
|
||||
type NetworkCompatibilityOptions struct {
|
||||
AllowHTTP bool
|
||||
InsecureTLS bool
|
||||
}
|
||||
|
||||
var (
|
||||
networkCompatibilityMu sync.RWMutex
|
||||
networkCompatibilityOptions NetworkCompatibilityOptions
|
||||
)
|
||||
|
||||
var sharedTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -77,18 +88,18 @@ var metadataTransport = &http.Transport{
|
||||
}
|
||||
|
||||
var sharedClient = &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Transport: newCompatibilityTransport(sharedTransport),
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
|
||||
var downloadClient = &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Transport: newCompatibilityTransport(sharedTransport),
|
||||
Timeout: DownloadTimeout,
|
||||
}
|
||||
|
||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Transport: newCompatibilityTransport(sharedTransport),
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
@@ -97,7 +108,7 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
// 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,
|
||||
Transport: newCompatibilityTransport(metadataTransport),
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
@@ -115,6 +126,109 @@ func CloseIdleConnections() {
|
||||
metadataTransport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
|
||||
networkCompatibilityMu.Lock()
|
||||
networkCompatibilityOptions = NetworkCompatibilityOptions{
|
||||
AllowHTTP: allowHTTP,
|
||||
InsecureTLS: insecureTLS,
|
||||
}
|
||||
networkCompatibilityMu.Unlock()
|
||||
|
||||
applyTLSCompatibility(sharedTransport, insecureTLS)
|
||||
applyTLSCompatibility(metadataTransport, insecureTLS)
|
||||
CloseIdleConnections()
|
||||
|
||||
GoLog("[HTTP] Network compatibility options updated: allow_http=%v insecure_tls=%v\n", allowHTTP, insecureTLS)
|
||||
}
|
||||
|
||||
func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
|
||||
networkCompatibilityMu.RLock()
|
||||
defer networkCompatibilityMu.RUnlock()
|
||||
return networkCompatibilityOptions
|
||||
}
|
||||
|
||||
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
|
||||
if insecureTLS {
|
||||
cfg := &tls.Config{InsecureSkipVerify: true}
|
||||
if transport.TLSClientConfig != nil {
|
||||
cfg = transport.TLSClientConfig.Clone()
|
||||
cfg.InsecureSkipVerify = true
|
||||
}
|
||||
transport.TLSClientConfig = cfg
|
||||
return
|
||||
}
|
||||
|
||||
transport.TLSClientConfig = nil
|
||||
}
|
||||
|
||||
type compatibilityTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func newCompatibilityTransport(base http.RoundTripper) http.RoundTripper {
|
||||
return &compatibilityTransport{base: base}
|
||||
}
|
||||
|
||||
func (t *compatibilityTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
opts := GetNetworkCompatibilityOptions()
|
||||
if !opts.AllowHTTP || req.URL.Scheme != "https" {
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// Compatibility mode should prefer HTTPS and only fallback to HTTP on
|
||||
// transport-level failures. Forcing HTTP unconditionally can trigger
|
||||
// redirect loops (http -> https) on providers that enforce HTTPS.
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if !canFallbackToHTTP(req) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fallbackReq, cloneErr := cloneRequestWithHTTPScheme(req, "http")
|
||||
if cloneErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
GoLog("[HTTP] HTTPS request failed for %s, retrying over HTTP: %v\n", req.URL.Host, err)
|
||||
return t.base.RoundTrip(fallbackReq)
|
||||
}
|
||||
|
||||
func canFallbackToHTTP(req *http.Request) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.ToUpper(req.Method) {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodDelete:
|
||||
return true
|
||||
default:
|
||||
return req.GetBody != nil
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request, error) {
|
||||
reqCopy := req.Clone(req.Context())
|
||||
if req.Body != nil && req.GetBody != nil {
|
||||
bodyCopy, err := req.GetBody()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqCopy.Body = bodyCopy
|
||||
}
|
||||
|
||||
urlCopy := *req.URL
|
||||
urlCopy.Scheme = scheme
|
||||
reqCopy.URL = &urlCopy
|
||||
return reqCopy, nil
|
||||
}
|
||||
|
||||
// Also checks for ISP blocking on errors
|
||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
@@ -145,7 +259,6 @@ func DefaultRetryConfig() RetryConfig {
|
||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||
var lastErr error
|
||||
delay := config.InitialDelay
|
||||
requestURL := req.URL.String()
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
reqCopy := req.Clone(req.Context())
|
||||
@@ -155,8 +268,8 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
|
||||
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||
if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") {
|
||||
return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP")
|
||||
}
|
||||
|
||||
if attempt < config.MaxRetries {
|
||||
|
||||
@@ -67,6 +67,48 @@ var supportedAudioFormats = map[string]bool{
|
||||
".ogg": true,
|
||||
}
|
||||
|
||||
type libraryAudioFileInfo struct {
|
||||
path string
|
||||
modTime int64
|
||||
}
|
||||
|
||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||
var files []libraryAudioFileInfo
|
||||
|
||||
err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if !supportedAudioFormats[ext] {
|
||||
return nil
|
||||
}
|
||||
|
||||
files = append(files, libraryAudioFileInfo{
|
||||
path: path,
|
||||
modTime: info.ModTime().UnixMilli(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func SetLibraryCoverCacheDir(cacheDir string) {
|
||||
libraryCoverCacheMu.Lock()
|
||||
libraryCoverCacheDir = cacheDir
|
||||
@@ -98,31 +140,16 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
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
|
||||
})
|
||||
|
||||
audioFileInfos, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
|
||||
audioFiles := make([]string, 0, len(audioFileInfos))
|
||||
for _, fileInfo := range audioFileInfos {
|
||||
audioFiles = append(audioFiles, fileInfo.path)
|
||||
}
|
||||
|
||||
totalFiles := len(audioFiles)
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
@@ -218,6 +245,18 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
@@ -243,15 +282,7 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -297,15 +328,7 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -337,15 +360,7 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -476,40 +491,14 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
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
|
||||
})
|
||||
|
||||
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
||||
if err != nil {
|
||||
return "{}", err
|
||||
}
|
||||
currentPathSet := make(map[string]bool, len(currentFiles))
|
||||
for _, fileInfo := range currentFiles {
|
||||
currentPathSet[fileInfo.path] = true
|
||||
}
|
||||
|
||||
totalFiles := len(currentFiles)
|
||||
libraryScanProgressMu.Lock()
|
||||
@@ -517,7 +506,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Find files to scan (new or modified)
|
||||
var filesToScan []fileInfo
|
||||
var filesToScan []libraryAudioFileInfo
|
||||
skippedCount := 0
|
||||
|
||||
for _, f := range currentFiles {
|
||||
|
||||
@@ -20,6 +20,149 @@ const (
|
||||
durationToleranceSec = 10.0
|
||||
)
|
||||
|
||||
// Lyrics provider names (used in settings and cascade ordering)
|
||||
const (
|
||||
LyricsProviderSpotifyAPI = "spotify_api"
|
||||
LyricsProviderLRCLIB = "lrclib"
|
||||
LyricsProviderNetease = "netease"
|
||||
LyricsProviderMusixmatch = "musixmatch"
|
||||
LyricsProviderAppleMusic = "apple_music"
|
||||
LyricsProviderQQMusic = "qqmusic"
|
||||
)
|
||||
|
||||
// DefaultLyricsProviders is the default cascade order for lyrics fetching.
|
||||
// LRCLIB first (no proxy dependency), then the others.
|
||||
var DefaultLyricsProviders = []string{
|
||||
LyricsProviderLRCLIB,
|
||||
LyricsProviderSpotifyAPI,
|
||||
LyricsProviderMusixmatch,
|
||||
LyricsProviderNetease,
|
||||
LyricsProviderAppleMusic,
|
||||
LyricsProviderQQMusic,
|
||||
}
|
||||
|
||||
// Global lyrics provider configuration
|
||||
var (
|
||||
lyricsProvidersMu sync.RWMutex
|
||||
lyricsProviders []string // ordered list of enabled providers
|
||||
)
|
||||
|
||||
var (
|
||||
spotifyLyricsRateLimitMu sync.RWMutex
|
||||
spotifyLyricsRateLimitedTil time.Time
|
||||
)
|
||||
|
||||
// LyricsFetchOptions controls optional provider-specific enhancements.
|
||||
type LyricsFetchOptions struct {
|
||||
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
|
||||
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
|
||||
}
|
||||
|
||||
var defaultLyricsFetchOptions = LyricsFetchOptions{
|
||||
IncludeTranslationNetease: false,
|
||||
IncludeRomanizationNetease: false,
|
||||
MultiPersonWordByWord: true,
|
||||
MusixmatchLanguage: "",
|
||||
}
|
||||
|
||||
var (
|
||||
lyricsFetchOptionsMu sync.RWMutex
|
||||
lyricsFetchOptions = defaultLyricsFetchOptions
|
||||
)
|
||||
|
||||
// SetLyricsProviderOrder sets the ordered list of lyrics providers to try.
|
||||
// Providers not in the list are disabled. An empty list resets to defaults.
|
||||
func SetLyricsProviderOrder(providers []string) {
|
||||
lyricsProvidersMu.Lock()
|
||||
defer lyricsProvidersMu.Unlock()
|
||||
|
||||
if len(providers) == 0 {
|
||||
lyricsProviders = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Validate provider names
|
||||
validNames := map[string]bool{
|
||||
LyricsProviderSpotifyAPI: true,
|
||||
LyricsProviderLRCLIB: true,
|
||||
LyricsProviderNetease: true,
|
||||
LyricsProviderMusixmatch: true,
|
||||
LyricsProviderAppleMusic: true,
|
||||
LyricsProviderQQMusic: true,
|
||||
}
|
||||
|
||||
var valid []string
|
||||
for _, p := range providers {
|
||||
normalized := strings.ToLower(strings.TrimSpace(p))
|
||||
if validNames[normalized] {
|
||||
valid = append(valid, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
lyricsProviders = valid
|
||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||
}
|
||||
|
||||
// GetLyricsProviderOrder returns the current lyrics provider order.
|
||||
func GetLyricsProviderOrder() []string {
|
||||
lyricsProvidersMu.RLock()
|
||||
defer lyricsProvidersMu.RUnlock()
|
||||
|
||||
if len(lyricsProviders) == 0 {
|
||||
return DefaultLyricsProviders
|
||||
}
|
||||
|
||||
result := make([]string, len(lyricsProviders))
|
||||
copy(result, lyricsProviders)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"},
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
|
||||
opts.MusixmatchLanguage = strings.ToLower(strings.TrimSpace(opts.MusixmatchLanguage))
|
||||
opts.MusixmatchLanguage = regexp.MustCompile(`[^a-z0-9\-_]`).ReplaceAllString(opts.MusixmatchLanguage, "")
|
||||
if len(opts.MusixmatchLanguage) > 16 {
|
||||
opts.MusixmatchLanguage = opts.MusixmatchLanguage[:16]
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
|
||||
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||
normalized := normalizeLyricsFetchOptions(opts)
|
||||
|
||||
lyricsFetchOptionsMu.Lock()
|
||||
defer lyricsFetchOptionsMu.Unlock()
|
||||
lyricsFetchOptions = normalized
|
||||
|
||||
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
|
||||
normalized.IncludeTranslationNetease,
|
||||
normalized.IncludeRomanizationNetease,
|
||||
normalized.MultiPersonWordByWord,
|
||||
normalized.MusixmatchLanguage,
|
||||
)
|
||||
}
|
||||
|
||||
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
|
||||
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||
lyricsFetchOptionsMu.RLock()
|
||||
defer lyricsFetchOptionsMu.RUnlock()
|
||||
return lyricsFetchOptions
|
||||
}
|
||||
|
||||
type lyricsCacheEntry struct {
|
||||
response *LyricsResponse
|
||||
expiresAt time.Time
|
||||
@@ -90,6 +233,15 @@ func (c *lyricsCache) Size() int {
|
||||
return len(c.cache)
|
||||
}
|
||||
|
||||
func (c *lyricsCache) ClearAll() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
cleared := len(c.cache)
|
||||
c.cache = make(map[string]*lyricsCacheEntry)
|
||||
return cleared
|
||||
}
|
||||
|
||||
type LRCLibResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -102,6 +254,18 @@ type LRCLibResponse struct {
|
||||
SyncedLyrics string `json:"syncedLyrics"`
|
||||
}
|
||||
|
||||
type SpotifyLyricsLine struct {
|
||||
TimeTag string `json:"timeTag"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
type SpotifyLyricsAPIResponse struct {
|
||||
Error bool `json:"error"`
|
||||
Message string `json:"message"`
|
||||
SyncType string `json:"syncType"`
|
||||
Lines []SpotifyLyricsLine `json:"lines"`
|
||||
}
|
||||
|
||||
type LyricsLine struct {
|
||||
StartTimeMs int64 `json:"startTimeMs"`
|
||||
Words string `json:"words"`
|
||||
@@ -139,7 +303,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -174,7 +338,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -209,6 +373,172 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
||||
return c.parseLRCLibResponse(&results[0]), nil
|
||||
}
|
||||
|
||||
func parseSpotifyLyricsTimeTagToMs(tag string) int64 {
|
||||
raw := strings.TrimSpace(tag)
|
||||
raw = strings.TrimPrefix(raw, "[")
|
||||
raw = strings.TrimSuffix(raw, "]")
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return ms
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^(\d{1,2}):(\d{2})\.(\d{1,3})$`)
|
||||
matches := re.FindStringSubmatch(raw)
|
||||
if len(matches) != 4 {
|
||||
return 0
|
||||
}
|
||||
|
||||
minutes, _ := strconv.ParseInt(matches[1], 10, 64)
|
||||
seconds, _ := strconv.ParseInt(matches[2], 10, 64)
|
||||
fraction := matches[3]
|
||||
fractionInt, _ := strconv.ParseInt(fraction, 10, 64)
|
||||
if len(fraction) == 2 {
|
||||
fractionInt *= 10
|
||||
} else if len(fraction) == 1 {
|
||||
fractionInt *= 100
|
||||
}
|
||||
return minutes*60*1000 + seconds*1000 + fractionInt
|
||||
}
|
||||
|
||||
func getSpotifyLyricsRateLimitUntil() time.Time {
|
||||
spotifyLyricsRateLimitMu.RLock()
|
||||
defer spotifyLyricsRateLimitMu.RUnlock()
|
||||
return spotifyLyricsRateLimitedTil
|
||||
}
|
||||
|
||||
func setSpotifyLyricsRateLimitUntil(until time.Time) {
|
||||
spotifyLyricsRateLimitMu.Lock()
|
||||
spotifyLyricsRateLimitedTil = until
|
||||
spotifyLyricsRateLimitMu.Unlock()
|
||||
}
|
||||
|
||||
func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
|
||||
raw := strings.TrimSpace(retryAfter)
|
||||
if raw == "" {
|
||||
return now.Add(10 * time.Minute)
|
||||
}
|
||||
|
||||
if sec, err := strconv.Atoi(raw); err == nil && sec > 0 {
|
||||
return now.Add(time.Duration(sec) * time.Second)
|
||||
}
|
||||
|
||||
if when, err := http.ParseTime(raw); err == nil && when.After(now) {
|
||||
return when
|
||||
}
|
||||
|
||||
return now.Add(10 * time.Minute)
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
|
||||
now := time.Now()
|
||||
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
|
||||
waitFor := int(math.Ceil(limitedUntil.Sub(now).Seconds()))
|
||||
return nil, fmt.Errorf(
|
||||
"Spotify Lyrics API cooldown active (%ds remaining after previous 429)",
|
||||
waitFor,
|
||||
)
|
||||
}
|
||||
|
||||
spotifyID = strings.TrimSpace(spotifyID)
|
||||
if spotifyID == "" {
|
||||
return nil, fmt.Errorf("spotify ID is empty")
|
||||
}
|
||||
if parsed, err := parseSpotifyURI(spotifyID); err == nil && parsed.Type == "track" && parsed.ID != "" {
|
||||
spotifyID = parsed.ID
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
|
||||
setSpotifyLyricsRateLimitUntil(retryUntil)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
|
||||
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||
}
|
||||
if msg, ok := payload["error"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
result := &LyricsResponse{
|
||||
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
|
||||
SyncType: apiResp.SyncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: "",
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}
|
||||
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
result.Lines = append(result.Lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if len(result.Lines) > 1 {
|
||||
for i := 0; i < len(result.Lines)-1; i++ {
|
||||
nextStart := result.Lines[i+1].StartTimeMs
|
||||
if nextStart > result.Lines[i].StartTimeMs {
|
||||
result.Lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
last := len(result.Lines) - 1
|
||||
if result.Lines[last].EndTimeMs == 0 {
|
||||
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
|
||||
if result.SyncType == "" {
|
||||
result.SyncType = "LINE_SYNCED"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
var bestSynced *LRCLibResponse
|
||||
var bestPlain *LRCLibResponse
|
||||
@@ -240,68 +570,206 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
|
||||
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
fetchOptions := GetLyricsFetchOptions()
|
||||
|
||||
extManager := GetExtensionManager()
|
||||
var extensionProviders []*ExtensionProviderWrapper
|
||||
if extManager != nil {
|
||||
extensionProviders = extManager.GetLyricsProviders()
|
||||
}
|
||||
|
||||
var cachedNonExtension *LyricsResponse
|
||||
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||
cachedCopy := *cached
|
||||
cachedCopy.Source = cached.Source + " (cached)"
|
||||
isExtensionCache := strings.HasPrefix(cached.Source, "Extension:")
|
||||
if len(extensionProviders) == 0 || isExtensionCache {
|
||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||
cachedCopy := *cached
|
||||
cachedCopy.Source = cached.Source + " (cached)"
|
||||
return &cachedCopy, nil
|
||||
}
|
||||
|
||||
// If extension providers are currently enabled, don't let stale built-in cache
|
||||
// mask newly installed/activated extensions.
|
||||
cachedNonExtension = cached
|
||||
GoLog("[Lyrics] Ignoring cached non-extension lyrics because extension providers are available\n")
|
||||
}
|
||||
|
||||
isValidResult := func(l *LyricsResponse) bool {
|
||||
return lyricsHasUsableText(l)
|
||||
}
|
||||
|
||||
// Try extension lyrics providers first
|
||||
if len(extensionProviders) > 0 {
|
||||
for _, provider := range extensionProviders {
|
||||
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
||||
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
if err != nil {
|
||||
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cachedNonExtension != nil {
|
||||
cachedCopy := *cachedNonExtension
|
||||
cachedCopy.Source = cachedNonExtension.Source + " (cached fallback)"
|
||||
GoLog("[Lyrics] Extension providers unavailable for this track, using cached built-in lyrics\n")
|
||||
return &cachedCopy, nil
|
||||
}
|
||||
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
isValidResult := func(l *LyricsResponse) bool {
|
||||
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
||||
}
|
||||
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
if primaryArtist != artistName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get configured provider order
|
||||
providerOrder := GetLyricsProviderOrder()
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||
|
||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||
|
||||
// Cascade through all configured built-in providers
|
||||
for _, providerName := range providerOrder {
|
||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
switch providerName {
|
||||
case LyricsProviderSpotifyAPI:
|
||||
lyrics, err = c.FetchLyricsFromSpotifyAPI(spotifyID)
|
||||
|
||||
case LyricsProviderLRCLIB:
|
||||
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
|
||||
|
||||
case LyricsProviderNetease:
|
||||
neteaseClient := NewNeteaseClient()
|
||||
lyrics, err = neteaseClient.FetchLyrics(
|
||||
trackName,
|
||||
primaryArtist,
|
||||
durationSec,
|
||||
fetchOptions.IncludeTranslationNetease,
|
||||
fetchOptions.IncludeRomanizationNetease,
|
||||
)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = neteaseClient.FetchLyrics(
|
||||
trackName,
|
||||
artistName,
|
||||
durationSec,
|
||||
fetchOptions.IncludeTranslationNetease,
|
||||
fetchOptions.IncludeRomanizationNetease,
|
||||
)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = neteaseClient.FetchLyrics(
|
||||
simplifiedTrack,
|
||||
primaryArtist,
|
||||
durationSec,
|
||||
fetchOptions.IncludeTranslationNetease,
|
||||
fetchOptions.IncludeRomanizationNetease,
|
||||
)
|
||||
}
|
||||
|
||||
case LyricsProviderMusixmatch:
|
||||
musixmatchClient := NewMusixmatchClient()
|
||||
lyrics, err = musixmatchClient.FetchLyrics(
|
||||
trackName,
|
||||
primaryArtist,
|
||||
durationSec,
|
||||
fetchOptions.MusixmatchLanguage,
|
||||
)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = musixmatchClient.FetchLyrics(
|
||||
trackName,
|
||||
artistName,
|
||||
durationSec,
|
||||
fetchOptions.MusixmatchLanguage,
|
||||
)
|
||||
}
|
||||
|
||||
case LyricsProviderAppleMusic:
|
||||
appleClient := NewAppleMusicClient()
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
}
|
||||
|
||||
case LyricsProviderQQMusic:
|
||||
qqClient := NewQQMusicClient()
|
||||
lyrics, err = qqClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
}
|
||||
|
||||
default:
|
||||
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
||||
continue
|
||||
}
|
||||
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB (simplified)"
|
||||
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
query := primaryArtist + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB Search"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
if simplifiedTrack != trackName {
|
||||
query = primaryArtist + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB Search (simplified)"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
if err != nil {
|
||||
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("lyrics not found from any source")
|
||||
}
|
||||
|
||||
// tryLRCLIB attempts all LRCLIB search strategies (exact match, simplified, search).
|
||||
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
// 1. Exact match with primary artist
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// 2. Exact match with full artist name
|
||||
if primaryArtist != artistName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Simplified track name
|
||||
if simplifiedTrack != trackName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB (simplified)"
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Search by query
|
||||
query := primaryArtist + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB Search"
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// 5. Search with simplified track name
|
||||
if simplifiedTrack != trackName {
|
||||
query = primaryArtist + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB Search (simplified)"
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("LRCLIB: no lyrics found")
|
||||
}
|
||||
|
||||
func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse {
|
||||
result := &LyricsResponse{
|
||||
Instrumental: resp.Instrumental,
|
||||
@@ -339,10 +807,20 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve Apple/QQ background vocal tags by attaching them to
|
||||
// the previous timed line. This keeps [bg:...] in final exported LRC.
|
||||
if strings.HasPrefix(line, "[bg:") && len(lines) > 0 {
|
||||
lines[len(lines)-1].Words = strings.TrimSpace(lines[len(lines)-1].Words + "\n" + line)
|
||||
continue
|
||||
}
|
||||
|
||||
matches := lrcPattern.FindStringSubmatch(line)
|
||||
if len(matches) == 5 {
|
||||
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
|
||||
words := strings.TrimSpace(matches[4])
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
@@ -363,6 +841,79 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
||||
return lines
|
||||
}
|
||||
|
||||
func plainTextLyricsLines(rawLyrics string) []LyricsLine {
|
||||
var lines []LyricsLine
|
||||
for _, line := range strings.Split(rawLyrics, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: trimmed,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func lyricsHasUsableText(lyrics *LyricsResponse) bool {
|
||||
if lyrics == nil {
|
||||
return false
|
||||
}
|
||||
if lyrics.Instrumental {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(lyrics.PlainLyrics) != "" {
|
||||
return true
|
||||
}
|
||||
for _, line := range lyrics.Lines {
|
||||
if strings.TrimSpace(line.Words) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectLyricsErrorPayload extracts human-readable error messages from
|
||||
// JSON payloads returned by lyrics proxies when no lyric is available.
|
||||
func detectLyricsErrorPayload(raw string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
lyricsKeys := []string{"lyrics", "lyric", "lrc", "content", "lines", "syncedLyrics", "unsyncedLyrics"}
|
||||
hasLyricsKey := false
|
||||
for _, key := range lyricsKeys {
|
||||
if _, ok := payload[key]; ok {
|
||||
hasLyricsKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
errorKeys := []string{"message", "error", "detail", "reason"}
|
||||
for _, key := range errorKeys {
|
||||
if msg, ok := payload[key].(string); ok {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg != "" && !hasLyricsKey {
|
||||
return msg, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
|
||||
return "request unsuccessful", true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
|
||||
min, _ := strconv.ParseInt(minutes, 10, 64)
|
||||
sec, _ := strconv.ParseInt(seconds, 10, 64)
|
||||
@@ -376,12 +927,16 @@ func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
|
||||
}
|
||||
|
||||
func msToLRCTimestamp(ms int64) string {
|
||||
return fmt.Sprintf("[%s]", msToLRCTimestampInline(ms))
|
||||
}
|
||||
|
||||
func msToLRCTimestampInline(ms int64) string {
|
||||
totalSeconds := ms / 1000
|
||||
minutes := totalSeconds / 60
|
||||
seconds := totalSeconds % 60
|
||||
centiseconds := (ms % 1000) / 10
|
||||
|
||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||
return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
@@ -441,8 +996,18 @@ func simplifyTrackName(name string) string {
|
||||
re := regexp.MustCompile("(?i)" + pattern)
|
||||
result = re.ReplaceAllString(result, "")
|
||||
}
|
||||
result = strings.TrimSpace(result)
|
||||
if result == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
// Add a loose fallback form for provider queries where punctuation
|
||||
// and separators differ (e.g. "/" vs "_" vs spaces).
|
||||
if loose := normalizeLooseTitle(result); loose != "" {
|
||||
return loose
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeArtistName(name string) string {
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppleMusicClient fetches lyrics from Apple Music.
|
||||
// Uses a scraped JWT token for search and a proxy for lyrics.
|
||||
type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Apple Music token manager — singleton with mutex for thread safety
|
||||
type appleTokenManager struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
var globalAppleTokenManager = &appleTokenManager{}
|
||||
|
||||
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.token != "" {
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
// Step 1: Fetch the Apple Music beta page
|
||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Find the index JS file URL
|
||||
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
||||
match := indexJsRegex.Find(body)
|
||||
if match == nil {
|
||||
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
||||
}
|
||||
|
||||
indexJsURL := "https://beta.music.apple.com" + string(match)
|
||||
|
||||
// Step 3: Fetch the JS file
|
||||
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create JS request: %w", err)
|
||||
}
|
||||
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
jsResp, err := client.Do(jsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
||||
}
|
||||
defer jsResp.Body.Close()
|
||||
|
||||
jsBody, err := io.ReadAll(jsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Extract JWT token (starts with eyJh)
|
||||
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
||||
tokenMatch := tokenRegex.Find(jsBody)
|
||||
if tokenMatch == nil {
|
||||
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
||||
}
|
||||
|
||||
m.token = string(tokenMatch)
|
||||
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
func (m *appleTokenManager) clearToken() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.token = ""
|
||||
}
|
||||
|
||||
// Apple Music API response models
|
||||
type appleMusicSearchResponse struct {
|
||||
Results struct {
|
||||
Songs *struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
} `json:"data"`
|
||||
} `json:"songs"`
|
||||
} `json:"results"`
|
||||
Resources *struct {
|
||||
Songs map[string]struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
URL string `json:"url"`
|
||||
Artwork struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"artwork"`
|
||||
} `json:"attributes"`
|
||||
} `json:"songs"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
|
||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
||||
type paxResponse struct {
|
||||
Type string `json:"type"` // "Syllable" or "Line"
|
||||
Content []paxLyrics `json:"content"` // List of lyric lines
|
||||
}
|
||||
|
||||
type paxLyrics struct {
|
||||
Text []paxLyricDetail `json:"text"`
|
||||
Timestamp int `json:"timestamp"`
|
||||
OppositeTurn bool `json:"oppositeTurn"`
|
||||
Background bool `json:"background"`
|
||||
BackgroundText []paxLyricDetail `json:"backgroundText"`
|
||||
EndTime int `json:"endtime"`
|
||||
}
|
||||
|
||||
type paxLyricDetail struct {
|
||||
Text string `json:"text"`
|
||||
Part bool `json:"part"`
|
||||
Timestamp *int `json:"timestamp"`
|
||||
EndTime *int `json:"endtime"`
|
||||
}
|
||||
|
||||
func NewAppleMusicClient() *AppleMusicClient {
|
||||
return &AppleMusicClient{
|
||||
httpClient: NewMetadataHTTPClient(20 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
token, err := globalAppleTokenManager.getToken(c.httpClient)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music token error: %w", err)
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
||||
encodedQuery,
|
||||
)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Origin", "https://music.apple.com")
|
||||
req.Header.Set("Referer", "https://music.apple.com/")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
globalAppleTokenManager.clearToken()
|
||||
return "", fmt.Errorf("apple music token expired")
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp appleMusicSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
||||
return "", fmt.Errorf("no songs found on apple music")
|
||||
}
|
||||
|
||||
return searchResp.Results.Songs.Data[0].ID, nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
|
||||
|
||||
req, err := http.NewRequest("GET", lyricsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music lyrics fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music lyrics proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read lyrics response: %w", err)
|
||||
}
|
||||
|
||||
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyStr == "" {
|
||||
return "", fmt.Errorf("empty lyrics response from apple music")
|
||||
}
|
||||
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format.
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
// Try to parse as PaxResponse first
|
||||
var paxResp paxResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
// Try to parse as a direct list of PaxLyrics
|
||||
var directLyrics []paxLyrics
|
||||
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to parse pax lyrics response")
|
||||
}
|
||||
|
||||
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
|
||||
lastStart := ""
|
||||
|
||||
for _, syllable := range details {
|
||||
if syllable.Timestamp != nil {
|
||||
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
|
||||
if start != lastStart {
|
||||
builder.WriteString(start)
|
||||
lastStart = start
|
||||
}
|
||||
}
|
||||
|
||||
builder.WriteString(syllable.Text)
|
||||
if !syllable.Part {
|
||||
builder.WriteString(" ")
|
||||
}
|
||||
|
||||
if syllable.EndTime != nil {
|
||||
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
for i, line := range content {
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
timestamp := msToLRCTimestamp(int64(line.Timestamp))
|
||||
|
||||
if strings.EqualFold(lyricsType, "Syllable") {
|
||||
sb.WriteString(timestamp)
|
||||
if multiPersonWordByWord {
|
||||
if line.OppositeTurn {
|
||||
sb.WriteString("v2:")
|
||||
} else {
|
||||
sb.WriteString("v1:")
|
||||
}
|
||||
}
|
||||
|
||||
appendPaxLyricDetail(&sb, line.Text)
|
||||
|
||||
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
|
||||
sb.WriteString("\n[bg:")
|
||||
appendPaxLyricDetail(&sb, line.BackgroundText)
|
||||
sb.WriteString("]")
|
||||
}
|
||||
} else {
|
||||
if len(line.Text) > 0 {
|
||||
sb.WriteString(timestamp)
|
||||
sb.WriteString(line.Text[0].Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// FetchLyrics searches Apple Music and returns parsed LyricsResponse.
|
||||
func (c *AppleMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawLyrics, err := c.FetchLyricsByID(songID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
|
||||
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to parse as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Apple Music",
|
||||
Source: "Apple Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fall back to plain text if no timestamps found
|
||||
resultLines := plainTextLyricsLines(lrcText)
|
||||
|
||||
if len(resultLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: resultLines,
|
||||
SyncType: "UNSYNCED",
|
||||
Provider: "Apple Music",
|
||||
Source: "Apple Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on apple music")
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MusixmatchClient fetches lyrics from Musixmatch via a proxy server.
|
||||
// The proxy handles Musixmatch authentication internally.
|
||||
type MusixmatchClient struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// Musixmatch proxy response models
|
||||
type musixmatchSearchResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Artwork string `json:"artwork"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
Duration int `json:"duration"`
|
||||
URL string `json:"url"`
|
||||
AlbumID int64 `json:"albumId"`
|
||||
HasSyncedLyrics bool `json:"hasSyncedLyrics"`
|
||||
HasUnsyncedLyrics bool `json:"hasUnsyncedLyrics"`
|
||||
AvailableLanguages []string `json:"availableLanguages"`
|
||||
OriginalLanguage string `json:"originalLanguage"`
|
||||
SyncedLyrics *musixmatchLyricsResponse `json:"syncedLyrics"`
|
||||
UnsyncedLyrics *musixmatchLyricsResponse `json:"unsyncedLyrics"`
|
||||
}
|
||||
|
||||
type musixmatchLyricsResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Duration int `json:"duration"`
|
||||
Language string `json:"language"`
|
||||
UpdatedTime string `json:"updatedTime"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
}
|
||||
|
||||
func NewMusixmatchClient() *MusixmatchClient {
|
||||
return &MusixmatchClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
baseURL: "http://158.180.60.95",
|
||||
}
|
||||
}
|
||||
|
||||
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
||||
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
||||
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||
return nil, fmt.Errorf("empty track or artist name")
|
||||
}
|
||||
|
||||
encodedArtist := url.QueryEscape(artistName)
|
||||
encodedTrack := url.QueryEscape(trackName)
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if songID <= 0 || lang == "" {
|
||||
return nil, fmt.Errorf("invalid song id or language")
|
||||
}
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||
}
|
||||
|
||||
// Prefer synced lyrics for selected language
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to unsynced lyrics for selected language
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||
}
|
||||
|
||||
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||
result, err := c.searchAndGetLyrics(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
||||
if localizedErr == nil {
|
||||
return localized, nil
|
||||
}
|
||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||
}
|
||||
|
||||
// Prefer synced lyrics
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to unsynced lyrics
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
||||
// This is a direct public API — no proxy dependency.
|
||||
type NeteaseClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Netease API response models
|
||||
type neteaseSearchResponse struct {
|
||||
Result struct {
|
||||
Songs []struct {
|
||||
Name string `json:"name"`
|
||||
ID int64 `json:"id"`
|
||||
Artists []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"artists"`
|
||||
} `json:"songs"`
|
||||
SongCount int `json:"songCount"`
|
||||
} `json:"result"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type neteaseLyricsResponse struct {
|
||||
LRC *neteaseLyricField `json:"lrc"`
|
||||
TLyric *neteaseLyricField `json:"tlyric"`
|
||||
RomaLRC *neteaseLyricField `json:"romalrc"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type neteaseLyricField struct {
|
||||
Lyric string `json:"lyric"`
|
||||
}
|
||||
|
||||
var neteaseHeaders = map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "max-age=0",
|
||||
}
|
||||
|
||||
func NewNeteaseClient() *NeteaseClient {
|
||||
return &NeteaseClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Netease and returns the song ID.
|
||||
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return 0, fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
searchURL := "http://music.163.com/api/search/pc"
|
||||
params := url.Values{}
|
||||
params.Set("s", query)
|
||||
params.Set("type", "1")
|
||||
params.Set("limit", "1")
|
||||
params.Set("offset", "0")
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("netease search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return 0, fmt.Errorf("netease search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp neteaseSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return 0, fmt.Errorf("failed to decode netease search: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
|
||||
return 0, fmt.Errorf("no songs found on netease")
|
||||
}
|
||||
|
||||
return searchResp.Result.Songs[0].ID, nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||
lyricsURL := "http://music.163.com/api/song/lyric"
|
||||
params := url.Values{}
|
||||
params.Set("id", fmt.Sprintf("%d", songID))
|
||||
params.Set("lv", "1")
|
||||
params.Set("tv", "1")
|
||||
params.Set("rv", "1")
|
||||
|
||||
fullURL := lyricsURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("netease lyrics fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("netease lyrics returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var lyricsResp neteaseLyricsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lyricsResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode netease lyrics: %w", err)
|
||||
}
|
||||
|
||||
if lyricsResp.LRC == nil || strings.TrimSpace(lyricsResp.LRC.Lyric) == "" {
|
||||
return "", fmt.Errorf("no lyrics available on netease")
|
||||
}
|
||||
|
||||
lyric := lyricsResp.LRC.Lyric
|
||||
|
||||
if includeTranslation && lyricsResp.TLyric != nil && strings.TrimSpace(lyricsResp.TLyric.Lyric) != "" {
|
||||
lyric += "\n\n" + lyricsResp.TLyric.Lyric
|
||||
}
|
||||
|
||||
if includeRomanization && lyricsResp.RomaLRC != nil && strings.TrimSpace(lyricsResp.RomaLRC.Lyric) != "" {
|
||||
lyric += "\n\n" + lyricsResp.RomaLRC.Lyric
|
||||
}
|
||||
|
||||
return lyric, nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches for a track and returns parsed LyricsResponse.
|
||||
func (c *NeteaseClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
includeTranslation,
|
||||
includeRomanization bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lrcText, err := c.FetchLyricsByID(songID, includeTranslation, includeRomanization)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the LRC text into LyricsResponse
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) == 0 {
|
||||
// May be plain text lyrics without timestamps
|
||||
plainLines := strings.Split(lrcText, "\n")
|
||||
for _, line := range plainLines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: trimmed,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("netease returned empty lyrics")
|
||||
}
|
||||
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
Provider: "Netease",
|
||||
Source: "Netease",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Netease",
|
||||
Source: "Netease",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QQMusicClient fetches lyrics from QQ Music.
|
||||
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
||||
type QQMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// QQ Music search response models
|
||||
type qqMusicSearchResponse struct {
|
||||
Data struct {
|
||||
Song struct {
|
||||
List []struct {
|
||||
Title string `json:"title"`
|
||||
Singer []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"singer"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"list"`
|
||||
} `json:"song"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// QQ Music lyrics request payload for paxsenix proxy
|
||||
type qqLyricsPayload struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func NewQQMusicClient() *QQMusicClient {
|
||||
return &QQMusicClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
||||
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
||||
params := url.Values{}
|
||||
params.Set("format", "json")
|
||||
params.Set("inCharset", "utf8")
|
||||
params.Set("outCharset", "utf8")
|
||||
params.Set("platform", "yqq.json")
|
||||
params.Set("new_json", "1")
|
||||
params.Set("w", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp qqMusicSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
||||
}
|
||||
|
||||
if len(searchResp.Data.Song.List) == 0 {
|
||||
return nil, fmt.Errorf("no songs found on qqmusic")
|
||||
}
|
||||
|
||||
song := searchResp.Data.Song.List[0]
|
||||
|
||||
var artists []string
|
||||
for _, singer := range song.Singer {
|
||||
artists = append(artists, singer.Name)
|
||||
}
|
||||
|
||||
return &qqLyricsPayload{
|
||||
Artist: artists,
|
||||
Album: song.Album.Name,
|
||||
ID: song.ID,
|
||||
Title: song.Title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
||||
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
||||
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qqmusic lyrics fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("qqmusic lyrics proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read lyrics response: %w", err)
|
||||
}
|
||||
|
||||
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyStr == "" {
|
||||
return "", fmt.Errorf("empty lyrics response from qqmusic")
|
||||
}
|
||||
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
payload, err := c.searchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
|
||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to use as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "QQ Music",
|
||||
Source: "QQ Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fall back to plain text
|
||||
resultLines := plainTextLyricsLines(lrcText)
|
||||
|
||||
if len(resultLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: resultLines,
|
||||
SyncType: "UNSYNCED",
|
||||
Provider: "QQ Music",
|
||||
Source: "QQ Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on qqmusic")
|
||||
}
|
||||
@@ -545,38 +545,60 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
lower := strings.ToLower(filePath)
|
||||
|
||||
if strings.HasSuffix(lower, ".flac") {
|
||||
return extractLyricsFromFlac(filePath)
|
||||
lyrics, err := extractLyricsFromFlac(filePath)
|
||||
if err == nil && strings.TrimSpace(lyrics) != "" {
|
||||
return lyrics, nil
|
||||
}
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".mp3") {
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err != nil || meta == nil {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
if err == nil && meta != nil {
|
||||
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||
return meta.Lyrics, nil
|
||||
}
|
||||
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||
return meta.Comment, nil
|
||||
}
|
||||
}
|
||||
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 extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
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
|
||||
if err == nil && meta != nil {
|
||||
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||
return meta.Lyrics, nil
|
||||
}
|
||||
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||
return meta.Comment, nil
|
||||
}
|
||||
}
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||
ext := filepath.Ext(filePath)
|
||||
base := strings.TrimSuffix(filePath, ext)
|
||||
if strings.TrimSpace(base) == "" {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported file format for lyrics extraction")
|
||||
lrcPath := base + ".lrc"
|
||||
data, err := os.ReadFile(lrcPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
lyrics := strings.TrimSpace(string(data))
|
||||
if lyrics == "" {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||
|
||||
@@ -12,11 +12,68 @@ func isFDOutput(outputFD int) bool {
|
||||
|
||||
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
||||
if isFDOutput(outputFD) {
|
||||
return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil
|
||||
// Never hand the original detached FD directly to a provider attempt.
|
||||
// Fallback chains may retry with another provider after a failure.
|
||||
// If the first attempt closes the original FD, its numeric ID can be
|
||||
// reused by unrelated resources and a later close may trigger fdsan abort.
|
||||
dupFD, err := dupOutputFD(outputFD)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to duplicate output fd %d: %w", outputFD, err)
|
||||
}
|
||||
if err := prepareDupFDForWrite(dupFD, outputFD); err != nil {
|
||||
_ = closeFD(dupFD)
|
||||
return nil, err
|
||||
}
|
||||
return os.NewFile(uintptr(dupFD), fmt.Sprintf("saf_fd_%d_dup_%d", outputFD, dupFD)), nil
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(outputPath)
|
||||
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
// Re-open procfs fd path instead of taking ownership of raw detached fd.
|
||||
// Some SAF providers reject O_TRUNC on these descriptors with EACCES/EPERM.
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0)
|
||||
if err == nil {
|
||||
return file, nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "permission denied") {
|
||||
return os.OpenFile(path, os.O_WRONLY, 0)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return os.Create(outputPath)
|
||||
}
|
||||
|
||||
func prepareDupFDForWrite(dupFD, originalFD int) error {
|
||||
// Best-effort reset so retries start writing from byte 0.
|
||||
if err := truncateFD(dupFD); err != nil {
|
||||
if isBestEffortTruncateError(err) {
|
||||
GoLog("[OutputFD] truncate not supported on fd %d (dup of %d): %v\n", dupFD, originalFD, err)
|
||||
} else {
|
||||
return fmt.Errorf("failed to truncate output fd %d (dup of %d): %w", dupFD, originalFD, err)
|
||||
}
|
||||
}
|
||||
if err := seekFDStart(dupFD); err != nil {
|
||||
GoLog("[OutputFD] seek reset failed on fd %d (dup of %d): %v\n", dupFD, originalFD, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeOwnedOutputFD(outputFD int) {
|
||||
if !isFDOutput(outputFD) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := closeFD(outputFD); err != nil {
|
||||
if !isBadFD(err) {
|
||||
GoLog("[OutputFD] failed to close detached fd %d: %v\n", outputFD, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
GoLog("[OutputFD] closed detached fd %d\n", outputFD)
|
||||
}
|
||||
|
||||
func cleanupOutputOnError(outputPath string, outputFD int) {
|
||||
if isFDOutput(outputFD) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build !windows
|
||||
|
||||
package gobackend
|
||||
|
||||
import "syscall"
|
||||
|
||||
func dupOutputFD(fd int) (int, error) {
|
||||
return syscall.Dup(fd)
|
||||
}
|
||||
|
||||
func truncateFD(fd int) error {
|
||||
return syscall.Ftruncate(fd, 0)
|
||||
}
|
||||
|
||||
func seekFDStart(fd int) error {
|
||||
_, err := syscall.Seek(fd, 0, 0)
|
||||
return err
|
||||
}
|
||||
|
||||
func closeFD(fd int) error {
|
||||
return syscall.Close(fd)
|
||||
}
|
||||
|
||||
func isBestEffortTruncateError(err error) bool {
|
||||
switch err {
|
||||
case syscall.EPERM, syscall.EACCES, syscall.EINVAL, syscall.ESPIPE, syscall.ENOSYS:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isBadFD(err error) bool {
|
||||
return err == syscall.EBADF
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build windows
|
||||
|
||||
package gobackend
|
||||
|
||||
func dupOutputFD(fd int) (int, error) {
|
||||
// Windows build is primarily for local tooling/tests.
|
||||
// Android runtime uses the !windows implementation.
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
func truncateFD(fd int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func seekFDStart(fd int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeFD(fd int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isBestEffortTruncateError(err error) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isBadFD(err error) bool {
|
||||
return false
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -28,6 +28,24 @@ var (
|
||||
qobuzDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
const (
|
||||
qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id="
|
||||
qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query="
|
||||
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
|
||||
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
||||
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
||||
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
||||
qobuzDebugKeyXORMask = byte(0x5A)
|
||||
)
|
||||
|
||||
var qobuzDebugKeyObfuscated = []byte{
|
||||
0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b,
|
||||
0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b,
|
||||
0x34, 0x3e, 0x34, 0x35, 0x35, 0x34, 0x3f, 0x39, 0x35, 0x37,
|
||||
0x3f, 0x29, 0x3f, 0x2c, 0x3f, 0x34, 0x39, 0x36, 0x35, 0x29,
|
||||
0x3f,
|
||||
}
|
||||
|
||||
type QobuzTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -174,6 +192,32 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
looseExpected := normalizeLooseTitle(normExpected)
|
||||
looseFound := normalizeLooseTitle(normFound)
|
||||
if looseExpected != "" && looseFound != "" {
|
||||
if looseExpected == looseFound {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji/symbol-only titles must be matched strictly to avoid false positives
|
||||
// like mapping "🪐" to unrelated textual tracks.
|
||||
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
|
||||
strings.TrimSpace(expectedTitle) != "" &&
|
||||
strings.TrimSpace(foundTitle) != "" {
|
||||
expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle)
|
||||
foundSymbols := normalizeSymbolOnlyTitle(foundTitle)
|
||||
if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols {
|
||||
GoLog("[Qobuz] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||
return true
|
||||
}
|
||||
GoLog("[Qobuz] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||
return false
|
||||
}
|
||||
|
||||
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
||||
foundLatin := qobuzIsLatinScript(foundTitle)
|
||||
if expectedLatin != foundLatin {
|
||||
@@ -311,8 +355,7 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||
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", qobuzTrackGetBaseURL, trackID, q.appID)
|
||||
|
||||
req, err := http.NewRequest("GET", trackURL, nil)
|
||||
if err != nil {
|
||||
@@ -338,145 +381,186 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
encodedAPIs := []string{
|
||||
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==",
|
||||
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=",
|
||||
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=",
|
||||
}
|
||||
|
||||
var apis []string
|
||||
for _, encoded := range encodedAPIs {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
apis = append(apis, "https://"+string(decoded))
|
||||
}
|
||||
|
||||
return apis
|
||||
}
|
||||
|
||||
func mapJumoQuality(quality string) int {
|
||||
switch quality {
|
||||
case "6":
|
||||
return 6
|
||||
case "7":
|
||||
return 7
|
||||
case "27":
|
||||
return 27
|
||||
default:
|
||||
return 6
|
||||
return []string{
|
||||
qobuzDownloadAPIURL,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
type qobuzAPIProvider struct {
|
||||
Name string
|
||||
URL string
|
||||
Kind string
|
||||
}
|
||||
|
||||
func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
||||
const (
|
||||
qobuzAPIKindMusicDL = "musicdl"
|
||||
qobuzAPIKindStandard = "standard"
|
||||
)
|
||||
|
||||
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
|
||||
return []qobuzAPIProvider{
|
||||
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
|
||||
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
|
||||
// "deeb" is mapped from the legacy reference fallback endpoint.
|
||||
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
|
||||
}
|
||||
}
|
||||
|
||||
type qobuzDownloadInfo struct {
|
||||
DownloadURL string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
func extractQobuzDownloadInfoFromBody(body []byte) (qobuzDownloadInfo, error) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return "", fmt.Errorf("invalid JSON: %v", err)
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if detail, ok := raw["detail"].(string); ok && strings.TrimSpace(detail) != "" {
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("%s", detail)
|
||||
}
|
||||
|
||||
if success, ok := raw["success"].(bool); ok && !success {
|
||||
if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("%s", msg)
|
||||
}
|
||||
return "", fmt.Errorf("api returned success=false")
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("api returned success=false")
|
||||
}
|
||||
|
||||
info := qobuzDownloadInfo{
|
||||
BitDepth: qobuzParseBitDepth(raw["bit_depth"]),
|
||||
SampleRate: qobuzParseSampleRate(raw["sampling_rate"]),
|
||||
}
|
||||
if urlVal, ok := raw["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
info.DownloadURL = strings.TrimSpace(urlVal)
|
||||
return info, nil
|
||||
}
|
||||
if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
info.DownloadURL = strings.TrimSpace(urlVal)
|
||||
return info, nil
|
||||
}
|
||||
if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
||||
return strings.TrimSpace(linkVal), nil
|
||||
info.DownloadURL = strings.TrimSpace(linkVal)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
if data, ok := raw["data"].(map[string]any); ok {
|
||||
if info.BitDepth == 0 {
|
||||
info.BitDepth = qobuzParseBitDepth(data["bit_depth"])
|
||||
}
|
||||
if info.SampleRate == 0 {
|
||||
info.SampleRate = qobuzParseSampleRate(data["sampling_rate"])
|
||||
}
|
||||
if urlVal, ok := data["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
info.DownloadURL = strings.TrimSpace(urlVal)
|
||||
return info, nil
|
||||
}
|
||||
if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
info.DownloadURL = strings.TrimSpace(urlVal)
|
||||
return info, nil
|
||||
}
|
||||
if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
||||
return strings.TrimSpace(linkVal), nil
|
||||
info.DownloadURL = strings.TrimSpace(linkVal)
|
||||
return info, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no download URL in response")
|
||||
return qobuzDownloadInfo{}, 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®ion=%s", trackID, formatID, region)
|
||||
|
||||
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
||||
|
||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||
req, err := http.NewRequest("GET", jumoURL, nil)
|
||||
func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
||||
info, err := extractQobuzDownloadInfoFromBody(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
|
||||
return info.DownloadURL, nil
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
func qobuzParseBitDepth(value any) int {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case json.Number:
|
||||
n, _ := v.Int64()
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
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)
|
||||
func qobuzParseSampleRate(value any) int {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
if v > 0 && v < 1000 {
|
||||
return int(v * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return int(v)
|
||||
case int:
|
||||
if v > 0 && v < 1000 {
|
||||
return v * 1000
|
||||
}
|
||||
return v
|
||||
case int64:
|
||||
if v > 0 && v < 1000 {
|
||||
return int(v * 1000)
|
||||
}
|
||||
return int(v)
|
||||
case json.Number:
|
||||
if n, err := v.Float64(); err == nil {
|
||||
if n > 0 && n < 1000 {
|
||||
return int(n * 1000)
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
|
||||
GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n")
|
||||
return linkVal, nil
|
||||
func normalizeQobuzQualityCode(quality string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(quality)) {
|
||||
case "", "5", "6", "cd", "lossless":
|
||||
return "6"
|
||||
case "7", "hi-res":
|
||||
return "7"
|
||||
case "27", "hi-res-max":
|
||||
return "27"
|
||||
default:
|
||||
return "6"
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("URL not found in Jumo response")
|
||||
func mapQobuzQualityCodeToAPI(qualityCode string) string {
|
||||
switch normalizeQobuzQualityCode(qualityCode) {
|
||||
case "27":
|
||||
return "hi-res-max"
|
||||
case "7":
|
||||
return "hi-res"
|
||||
default:
|
||||
return "cd"
|
||||
}
|
||||
}
|
||||
|
||||
func getQobuzDebugKey() string {
|
||||
decoded := make([]byte, len(qobuzDebugKeyObfuscated))
|
||||
for i, b := range qobuzDebugKeyObfuscated {
|
||||
decoded[i] = b ^ qobuzDebugKeyXORMask
|
||||
}
|
||||
return string(decoded)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
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", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
@@ -518,8 +602,7 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||
|
||||
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", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
@@ -601,8 +684,6 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
|
||||
queries := []string{}
|
||||
|
||||
if artistName != "" && trackName != "" {
|
||||
@@ -654,7 +735,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
|
||||
GoLog("[Qobuz] Searching for: %s\n", cleanQuery)
|
||||
|
||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID)
|
||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(cleanQuery), q.appID)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
@@ -757,10 +838,10 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
}
|
||||
|
||||
type qobuzAPIResult struct {
|
||||
apiURL string
|
||||
downloadURL string
|
||||
err error
|
||||
duration time.Duration
|
||||
provider qobuzAPIProvider
|
||||
info qobuzDownloadInfo
|
||||
err error
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// Qobuz API timeout configuration
|
||||
@@ -779,54 +860,73 @@ func getQobuzAPITimeout() time.Duration {
|
||||
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, "")
|
||||
func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration) (qobuzDownloadInfo, error) {
|
||||
return fetchQobuzURLSingleAttempt(provider, 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) {
|
||||
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
|
||||
var lastErr error
|
||||
retryDelay := qobuzRetryDelay
|
||||
var payloadBytes []byte
|
||||
if provider.Kind == qobuzAPIKindMusicDL {
|
||||
requestQuality := mapQobuzQualityCodeToAPI(quality)
|
||||
payload := map[string]any{
|
||||
"quality": requestQuality,
|
||||
"upload_to_r2": false,
|
||||
"url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID),
|
||||
}
|
||||
var err error
|
||||
payloadBytes, err = json.Marshal(payload)
|
||||
if err != nil {
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for attempt := 0; attempt <= qobuzMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay)
|
||||
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay *= 2 // Exponential backoff
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(timeout)
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||
reqURL := provider.URL
|
||||
if country != "" {
|
||||
reqURL += "&country=" + country
|
||||
reqURL += "?country=" + url.QueryEscape(country)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
var (
|
||||
req *http.Request
|
||||
err error
|
||||
)
|
||||
if provider.Kind == qobuzAPIKindStandard {
|
||||
separator := "&"
|
||||
if !strings.Contains(reqURL, "?") {
|
||||
separator = "?"
|
||||
}
|
||||
reqURL = fmt.Sprintf(
|
||||
"%s%d%squality=%s",
|
||||
reqURL,
|
||||
trackID,
|
||||
separator,
|
||||
url.QueryEscape(normalizeQobuzQualityCode(quality)),
|
||||
)
|
||||
req, err = http.NewRequest("GET", reqURL, nil)
|
||||
} else {
|
||||
req, err = http.NewRequest("POST", reqURL, bytes.NewReader(payloadBytes))
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if provider.Kind == qobuzAPIKindMusicDL {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Check for retryable errors (timeout, connection reset)
|
||||
@@ -859,7 +959,7 @@ func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeo
|
||||
if resp.StatusCode != 200 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -870,108 +970,115 @@ func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeo
|
||||
}
|
||||
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
return "", fmt.Errorf("received HTML instead of JSON")
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("received HTML instead of JSON")
|
||||
}
|
||||
|
||||
urlVal, parseErr := extractQobuzDownloadURLFromBody(body)
|
||||
info, parseErr := extractQobuzDownloadInfoFromBody(body)
|
||||
if parseErr == nil {
|
||||
return urlVal, nil
|
||||
return info, nil
|
||||
}
|
||||
lastErr = parseErr
|
||||
continue
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
return qobuzDownloadInfo{}, lastErr
|
||||
}
|
||||
return "", fmt.Errorf("all retries failed")
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("all retries failed")
|
||||
}
|
||||
|
||||
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
func getQobuzDownloadURLParallel(providers []qobuzAPIProvider, trackID int64, quality string) (qobuzAPIProvider, qobuzDownloadInfo, error) {
|
||||
if len(providers) == 0 {
|
||||
return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
|
||||
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(providers))
|
||||
|
||||
resultChan := make(chan qobuzAPIResult, len(apis))
|
||||
resultChan := make(chan qobuzAPIResult, len(providers))
|
||||
startTime := time.Now()
|
||||
timeout := getQobuzAPITimeout()
|
||||
|
||||
for _, apiURL := range apis {
|
||||
go func(api string) {
|
||||
for _, provider := range providers {
|
||||
go func(provider qobuzAPIProvider) {
|
||||
reqStart := time.Now()
|
||||
downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout)
|
||||
info, err := fetchQobuzURLWithRetry(provider, trackID, quality, timeout)
|
||||
resultChan <- qobuzAPIResult{
|
||||
apiURL: api,
|
||||
downloadURL: downloadURL,
|
||||
err: err,
|
||||
duration: time.Since(reqStart),
|
||||
provider: provider,
|
||||
info: info,
|
||||
err: err,
|
||||
duration: time.Since(reqStart),
|
||||
}
|
||||
}(apiURL)
|
||||
}(provider)
|
||||
}
|
||||
|
||||
var errors []string
|
||||
|
||||
for i := 0; i < len(apis); i++ {
|
||||
for i := 0; i < len(providers); i++ {
|
||||
result := <-resultChan
|
||||
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.provider.Name, result.duration)
|
||||
|
||||
go func(remaining int) {
|
||||
for j := 0; j < remaining; j++ {
|
||||
<-resultChan
|
||||
}
|
||||
}(len(apis) - i - 1)
|
||||
}(len(providers) - i - 1)
|
||||
|
||||
GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
|
||||
return result.apiURL, result.downloadURL, nil
|
||||
return result.provider, result.info, nil
|
||||
}
|
||||
errMsg := result.err.Error()
|
||||
if len(errMsg) > 50 {
|
||||
errMsg = errMsg[:50] + "..."
|
||||
}
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", result.provider.Name, errMsg))
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
|
||||
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
|
||||
GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(providers), time.Since(startTime))
|
||||
return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(providers), errors)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
apis := q.GetAvailableAPIs()
|
||||
if len(apis) == 0 {
|
||||
return "", fmt.Errorf("no Qobuz API available")
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (qobuzDownloadInfo, error) {
|
||||
providers := q.GetAvailableProviders()
|
||||
if len(providers) == 0 {
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("no Qobuz API available")
|
||||
}
|
||||
|
||||
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
||||
qualityCode := normalizeQobuzQualityCode(quality)
|
||||
|
||||
downloadFunc := func(qual string) (qobuzDownloadInfo, error) {
|
||||
provider, info, err := getQobuzDownloadURLParallel(providers, trackID, qual)
|
||||
if err != nil {
|
||||
return qobuzDownloadInfo{}, err
|
||||
}
|
||||
GoLog("[Qobuz] Download URL resolved via %s\n", provider.Name)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
downloadInfo, err := downloadFunc(qualityCode)
|
||||
if err == nil {
|
||||
return downloadURL, nil
|
||||
return downloadInfo, 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" {
|
||||
currentQuality := qualityCode
|
||||
if currentQuality == "27" {
|
||||
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
|
||||
jumoURL, jumoErr = q.downloadFromJumo(trackID, "7")
|
||||
if jumoErr == nil {
|
||||
return jumoURL, nil
|
||||
downloadInfo, err = downloadFunc("7")
|
||||
if err == nil {
|
||||
return downloadInfo, nil
|
||||
}
|
||||
currentQuality = "7"
|
||||
}
|
||||
|
||||
if quality == "27" || quality == "7" {
|
||||
if currentQuality == "7" {
|
||||
GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n")
|
||||
jumoURL, jumoErr = q.downloadFromJumo(trackID, "6")
|
||||
if jumoErr == nil {
|
||||
return jumoURL, nil
|
||||
downloadInfo, err = downloadFunc("6")
|
||||
if err == nil {
|
||||
return downloadInfo, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("all Qobuz APIs failed: %w", err)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
@@ -1067,14 +1174,12 @@ type QobuzDownloadResult struct {
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloader, logPrefix string) (*QobuzTrack, error) {
|
||||
if downloader == nil {
|
||||
downloader = NewQobuzDownloader()
|
||||
}
|
||||
if strings.TrimSpace(logPrefix) == "" {
|
||||
logPrefix = "Qobuz"
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
@@ -1084,15 +1189,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
|
||||
// Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate)
|
||||
if req.QobuzID != "" {
|
||||
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
|
||||
GoLog("[%s] Using Qobuz ID from Odesli enrichment: %s\n", logPrefix, req.QobuzID)
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
track, err = downloader.GetTrackByID(trackID)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Failed to get track by Odesli ID %d: %v\n", trackID, err)
|
||||
GoLog("[%s] Failed to get track by Odesli ID %d: %v\n", logPrefix, trackID, err)
|
||||
track = nil
|
||||
} else if track != nil {
|
||||
GoLog("[Qobuz] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
|
||||
GoLog("[%s] Successfully found track via Odesli ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1100,10 +1205,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
// Strategy 2: Use cached Qobuz Track ID (fast, no search needed)
|
||||
if track == nil && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||
GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.QobuzTrackID)
|
||||
track, err = downloader.GetTrackByID(cached.QobuzTrackID)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Cache hit but GetTrackByID failed: %v\n", err)
|
||||
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
@@ -1111,19 +1216,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
|
||||
// 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)
|
||||
GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, 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)
|
||||
GoLog("[%s] Got Qobuz ID %d from SongLink\n", logPrefix, trackID)
|
||||
track, err = downloader.GetTrackByID(trackID)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Failed to get track by SongLink ID %d: %v\n", trackID, err)
|
||||
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, 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)
|
||||
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
||||
// Cache for future use
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||
@@ -1135,16 +1240,16 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
|
||||
// Strategy 4: ISRC search with duration verification
|
||||
if track == nil && req.ISRC != "" {
|
||||
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
|
||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||
if track != nil {
|
||||
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, track.Performer.Name)
|
||||
GoLog("[%s] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
logPrefix, req.ArtistName, track.Performer.Name)
|
||||
track = nil
|
||||
} else if !qobuzTitlesMatch(req.TrackName, track.Title) {
|
||||
GoLog("[Qobuz] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.TrackName, track.Title)
|
||||
GoLog("[%s] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
logPrefix, req.TrackName, track.Title)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
@@ -1152,11 +1257,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
|
||||
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
|
||||
if track == nil {
|
||||
GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
||||
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
|
||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, track.Performer.Name)
|
||||
GoLog("[%s] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
logPrefix, req.ArtistName, track.Performer.Name)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
@@ -1166,14 +1271,32 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||
return nil, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||
GoLog("[%s] Match found: '%s' by '%s' (duration: %ds)\n", logPrefix, track.Title, track.Performer.Name, track.Duration)
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
track, err := resolveQobuzTrackForRequest(req, downloader, "Qobuz")
|
||||
if err != nil {
|
||||
return QobuzDownloadResult{}, err
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
@@ -1212,27 +1335,42 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
actualSampleRate := int(track.MaximumSamplingRate * 1000)
|
||||
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||
downloadInfo, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||
if err != nil {
|
||||
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
if downloadInfo.BitDepth > 0 {
|
||||
actualBitDepth = downloadInfo.BitDepth
|
||||
}
|
||||
if downloadInfo.SampleRate > 0 {
|
||||
actualSampleRate = downloadInfo.SampleRate
|
||||
}
|
||||
if actualBitDepth > 0 || actualSampleRate > 0 {
|
||||
GoLog("[Qobuz] API returned quality: %d-bit/%dHz\n", actualBitDepth, actualSampleRate)
|
||||
}
|
||||
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
embedLyrics = false
|
||||
}
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
req.CoverURL,
|
||||
coverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.EmbedLyrics,
|
||||
embedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}()
|
||||
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if err := downloader.DownloadFile(downloadInfo.DownloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return QobuzDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
@@ -1277,8 +1415,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if isSafOutput {
|
||||
GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
if isSafOutput || !req.EmbedMetadata {
|
||||
if !req.EmbedMetadata {
|
||||
GoLog("[Qobuz] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
||||
} else {
|
||||
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)
|
||||
@@ -1317,7 +1459,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,24 @@ package gobackend
|
||||
import "testing"
|
||||
|
||||
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
|
||||
body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`)
|
||||
|
||||
info, err := extractQobuzDownloadInfoFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if info.DownloadURL != "https://example.test/new.flac" {
|
||||
t.Fatalf("unexpected URL: %q", info.DownloadURL)
|
||||
}
|
||||
if info.BitDepth != 24 {
|
||||
t.Fatalf("unexpected bit depth: %d", info.BitDepth)
|
||||
}
|
||||
if info.SampleRate != 96000 {
|
||||
t.Fatalf("unexpected sample rate: %d", info.SampleRate)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reads nested data.url", func(t *testing.T) {
|
||||
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
|
||||
|
||||
@@ -44,4 +62,74 @@ func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||
t.Fatalf("expected blocked error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns detail error", func(t *testing.T) {
|
||||
body := []byte(`{"detail":"Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']" {
|
||||
t.Fatalf("expected detail error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeQobuzQualityCode(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": "6",
|
||||
"5": "6",
|
||||
"6": "6",
|
||||
"cd": "6",
|
||||
"lossless": "6",
|
||||
"7": "7",
|
||||
"hi-res": "7",
|
||||
"27": "27",
|
||||
"hi-res-max": "27",
|
||||
"unexpected": "6",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := normalizeQobuzQualityCode(input); got != want {
|
||||
t.Fatalf("normalizeQobuzQualityCode(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetQobuzDebugKey(t *testing.T) {
|
||||
got := getQobuzDebugKey()
|
||||
if len(got) != len(qobuzDebugKeyObfuscated) {
|
||||
t.Fatalf("unexpected debug key length: %d", len(got))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] {
|
||||
t.Fatalf("unexpected debug key reconstruction at index %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzAvailableProviders(t *testing.T) {
|
||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||
if len(providers) != 3 {
|
||||
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"musicdl": qobuzAPIKindMusicDL,
|
||||
"dabmusic": qobuzAPIKindStandard,
|
||||
"deeb": qobuzAPIKindStandard,
|
||||
}
|
||||
|
||||
for _, provider := range providers {
|
||||
wantKind, ok := want[provider.Name]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected provider %q", provider.Name)
|
||||
}
|
||||
if provider.Kind != wantKind {
|
||||
t.Fatalf("provider %q has kind %q, want %q", provider.Name, provider.Kind, wantKind)
|
||||
}
|
||||
delete(want, provider.Name)
|
||||
}
|
||||
|
||||
if len(want) != 0 {
|
||||
t.Fatalf("missing providers: %v", want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -35,6 +34,8 @@ type TrackAvailability struct {
|
||||
var (
|
||||
globalSongLinkClient *SongLinkClient
|
||||
songLinkClientOnce sync.Once
|
||||
songLinkRegion = "US"
|
||||
songLinkRegionMu sync.RWMutex
|
||||
)
|
||||
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
@@ -46,14 +47,72 @@ func NewSongLinkClient() *SongLinkClient {
|
||||
return globalSongLinkClient
|
||||
}
|
||||
|
||||
func normalizeSongLinkRegion(region string) string {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(region))
|
||||
if len(normalized) != 2 {
|
||||
return "US"
|
||||
}
|
||||
for _, ch := range normalized {
|
||||
if ch < 'A' || ch > 'Z' {
|
||||
return "US"
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func SetSongLinkRegion(region string) {
|
||||
normalized := normalizeSongLinkRegion(region)
|
||||
songLinkRegionMu.Lock()
|
||||
songLinkRegion = normalized
|
||||
songLinkRegionMu.Unlock()
|
||||
}
|
||||
|
||||
func GetSongLinkRegion() string {
|
||||
songLinkRegionMu.RLock()
|
||||
region := songLinkRegion
|
||||
songLinkRegionMu.RUnlock()
|
||||
return region
|
||||
}
|
||||
|
||||
func songLinkBaseURL() string {
|
||||
opts := GetNetworkCompatibilityOptions()
|
||||
if opts.AllowHTTP {
|
||||
return "http://api.song.link/v1-alpha.1/links"
|
||||
}
|
||||
return "https://api.song.link/v1-alpha.1/links"
|
||||
}
|
||||
|
||||
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
|
||||
if userCountry == "" {
|
||||
userCountry = GetSongLinkRegion()
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL))
|
||||
if userCountry != "" {
|
||||
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
||||
}
|
||||
return apiURL
|
||||
}
|
||||
|
||||
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string {
|
||||
if userCountry == "" {
|
||||
userCountry = GetSongLinkRegion()
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s",
|
||||
songLinkBaseURL(),
|
||||
url.QueryEscape(platform),
|
||||
url.QueryEscape(entityType),
|
||||
url.QueryEscape(entityID))
|
||||
if userCountry != "" {
|
||||
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
||||
}
|
||||
return apiURL
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -158,7 +217,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||
func extractDeezerIDFromURL(deezerURL string) string {
|
||||
parts := strings.Split(deezerURL, "/")
|
||||
if len(parts) > 0 {
|
||||
@@ -236,10 +294,6 @@ func extractQobuzIDFromURL(qobuzURL string) string {
|
||||
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 ""
|
||||
@@ -265,11 +319,6 @@ func extractTidalIDFromURL(tidalURL string) string {
|
||||
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 ""
|
||||
@@ -326,7 +375,6 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
|
||||
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 {
|
||||
@@ -340,7 +388,6 @@ func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string
|
||||
return availability.YouTubeURL, nil
|
||||
}
|
||||
|
||||
// AlbumAvailability represents album availability on different platforms
|
||||
type AlbumAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Deezer bool `json:"deezer"`
|
||||
@@ -351,11 +398,8 @@ type AlbumAvailability struct {
|
||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -401,7 +445,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink
|
||||
func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) {
|
||||
availability, err := s.CheckAlbumAvailability(spotifyAlbumID)
|
||||
if err != nil {
|
||||
@@ -440,9 +483,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
||||
apiURL := buildSongLinkURLFromTarget(deezerURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -520,16 +561,17 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
}
|
||||
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,10 +588,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
||||
url.QueryEscape(platform),
|
||||
url.QueryEscape(entityType),
|
||||
url.QueryEscape(entityID))
|
||||
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -620,23 +659,23 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||
parts := strings.Split(spotifyURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
@@ -662,7 +701,6 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e
|
||||
return availability.SpotifyID, nil
|
||||
}
|
||||
|
||||
// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink
|
||||
func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) {
|
||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
@@ -689,7 +727,6 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
|
||||
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 {
|
||||
@@ -706,8 +743,7 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string,
|
||||
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))
|
||||
apiURL := buildSongLinkURLFromTarget(inputURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -771,16 +807,17 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
artistRelatedURL = "https://api.spotify.com/v1/artists/%s/related-artists"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
artistCacheTTL = 10 * time.Minute
|
||||
searchCacheTTL = 5 * time.Minute
|
||||
@@ -140,6 +141,8 @@ type TrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
}
|
||||
|
||||
@@ -361,6 +364,10 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
}
|
||||
|
||||
for _, track := range response.Tracks.Items {
|
||||
var firstArtistID string
|
||||
if len(track.Artists) > 0 {
|
||||
firstArtistID = track.Artists[0].ID
|
||||
}
|
||||
result.Tracks = append(result.Tracks, TrackMetadata{
|
||||
SpotifyID: track.ID,
|
||||
Artists: joinArtists(track.Artists),
|
||||
@@ -375,6 +382,8 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
DiscNumber: track.DiscNumber,
|
||||
ExternalURL: track.ExternalURL.Spotify,
|
||||
ISRC: track.ExternalID.ISRC,
|
||||
AlbumID: track.Album.ID,
|
||||
ArtistID: firstArtistID,
|
||||
AlbumType: track.Album.AlbumType,
|
||||
})
|
||||
}
|
||||
@@ -426,6 +435,10 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
}
|
||||
|
||||
for _, track := range response.Tracks.Items {
|
||||
var firstArtistID string
|
||||
if len(track.Artists) > 0 {
|
||||
firstArtistID = track.Artists[0].ID
|
||||
}
|
||||
result.Tracks = append(result.Tracks, TrackMetadata{
|
||||
SpotifyID: track.ID,
|
||||
Artists: joinArtists(track.Artists),
|
||||
@@ -440,6 +453,8 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
DiscNumber: track.DiscNumber,
|
||||
ExternalURL: track.ExternalURL.Spotify,
|
||||
ISRC: track.ExternalID.ISRC,
|
||||
AlbumID: track.Album.ID,
|
||||
ArtistID: firstArtistID,
|
||||
AlbumType: track.Album.AlbumType,
|
||||
})
|
||||
}
|
||||
@@ -838,6 +853,47 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Artists []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, fmt.Sprintf(artistRelatedURL, artistID), token, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxItems := len(data.Artists)
|
||||
if limit > 0 && limit < maxItems {
|
||||
maxItems = limit
|
||||
}
|
||||
|
||||
result := make([]SearchArtistResult, 0, maxItems)
|
||||
for i := 0; i < maxItems; i++ {
|
||||
artist := data.Artists[i]
|
||||
result = append(result, SearchArtistResult{
|
||||
ID: artist.ID,
|
||||
Name: artist.Name,
|
||||
Images: firstImageURL(artist.Images),
|
||||
Followers: artist.Followers.Total,
|
||||
Popularity: artist.Popularity,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
||||
var data struct {
|
||||
ExternalID externalID `json:"external_ids"`
|
||||
|
||||
@@ -20,13 +20,8 @@ import (
|
||||
)
|
||||
|
||||
type TidalDownloader struct {
|
||||
client *http.Client
|
||||
clientID string
|
||||
clientSecret string
|
||||
apiURL string
|
||||
cachedToken string
|
||||
tokenExpiresAt time.Time
|
||||
tokenMu sync.Mutex
|
||||
client *http.Client
|
||||
apiURL string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -34,6 +29,11 @@ var (
|
||||
tidalDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
const (
|
||||
spotifyTrackBaseURL = "https://open.spotify.com/track/"
|
||||
songLinkLookupBaseURL = "https://api.song.link/v1-alpha.1/links?url="
|
||||
)
|
||||
|
||||
type TidalTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -102,13 +102,8 @@ type MPD struct {
|
||||
|
||||
func NewTidalDownloader() *TidalDownloader {
|
||||
tidalDownloaderOnce.Do(func() {
|
||||
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
|
||||
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
|
||||
|
||||
globalTidalDownloader = &TidalDownloader{
|
||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||
clientID: string(clientID),
|
||||
clientSecret: string(clientSecret),
|
||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||
}
|
||||
|
||||
apis := globalTidalDownloader.GetAvailableAPIs()
|
||||
@@ -120,85 +115,27 @@ func NewTidalDownloader() *TidalDownloader {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||
encodedAPIs := []string{
|
||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority)
|
||||
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
|
||||
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
||||
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
|
||||
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
|
||||
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
|
||||
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
|
||||
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
|
||||
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
|
||||
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
|
||||
return []string{
|
||||
"https://tidal-api.binimum.org", // priority
|
||||
"https://tidal.kinoplus.online",
|
||||
"https://triton.squid.wtf",
|
||||
"https://vogel.qqdl.site",
|
||||
"https://maus.qqdl.site",
|
||||
"https://hund.qqdl.site",
|
||||
"https://katze.qqdl.site",
|
||||
"https://wolf.qqdl.site",
|
||||
"https://hifi-one.spotisaver.net",
|
||||
"https://hifi-two.spotisaver.net",
|
||||
}
|
||||
|
||||
var apis []string
|
||||
for _, encoded := range encodedAPIs {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
apis = append(apis, "https://"+string(decoded))
|
||||
}
|
||||
|
||||
return apis
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||
t.tokenMu.Lock()
|
||||
defer t.tokenMu.Unlock()
|
||||
|
||||
if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) {
|
||||
return t.cachedToken, nil
|
||||
}
|
||||
|
||||
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
|
||||
|
||||
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
|
||||
req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(t.clientID, t.clientSecret)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := DoRequestWithUserAgent(t.client, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
t.cachedToken = result.AccessToken
|
||||
if result.ExpiresIn > 0 {
|
||||
t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
||||
} else {
|
||||
t.tokenExpiresAt = time.Now().Add(55 * time.Minute) // Default 55 min
|
||||
}
|
||||
|
||||
return result.AccessToken, nil
|
||||
return "", fmt.Errorf("tidal official metadata API disabled: no client credentials mode")
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
spotifyURL := fmt.Sprintf("%s%s", spotifyTrackBaseURL, spotifyTrackID)
|
||||
apiURL := fmt.Sprintf("%s%s", songLinkLookupBaseURL, url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -251,321 +188,20 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
||||
token, err := t.GetAccessToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=")
|
||||
trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID)
|
||||
|
||||
req, err := http.NewRequest("GET", trackURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := DoRequestWithUserAgent(t.client, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("failed to get track info: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var trackInfo TidalTrack
|
||||
if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &trackInfo, nil
|
||||
return nil, fmt.Errorf("tidal track lookup API disabled: no client credentials mode")
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||
token, err := t.GetAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=50&countryCode=US", string(searchBase), url.QueryEscape(isrc))
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := DoRequestWithUserAgent(t.client, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Items []TidalTrack `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range result.Items {
|
||||
if result.Items[i].ISRC == isrc {
|
||||
return &result.Items[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Items) == 0 {
|
||||
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
|
||||
}
|
||||
|
||||
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||
token, err := t.GetAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build search queries - multiple strategies (same as PC version)
|
||||
queries := []string{}
|
||||
|
||||
if artistName != "" && trackName != "" {
|
||||
queries = append(queries, artistName+" "+trackName)
|
||||
}
|
||||
|
||||
if trackName != "" {
|
||||
queries = append(queries, trackName)
|
||||
}
|
||||
|
||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||
romajiTrack := JapaneseToRomaji(trackName)
|
||||
romajiArtist := JapaneseToRomaji(artistName)
|
||||
|
||||
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
||||
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
||||
|
||||
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||
if !containsQuery(queries, romajiQuery) {
|
||||
queries = append(queries, romajiQuery)
|
||||
GoLog("[Tidal] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||
}
|
||||
}
|
||||
|
||||
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||
if !containsQuery(queries, cleanRomajiTrack) {
|
||||
queries = append(queries, cleanRomajiTrack)
|
||||
}
|
||||
}
|
||||
|
||||
if artistName != "" && cleanRomajiTrack != "" {
|
||||
partialQuery := artistName + " " + cleanRomajiTrack
|
||||
if !containsQuery(queries, partialQuery) {
|
||||
queries = append(queries, partialQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if artistName != "" {
|
||||
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
||||
if artistOnly != "" && !containsQuery(queries, artistOnly) {
|
||||
queries = append(queries, artistOnly)
|
||||
}
|
||||
}
|
||||
|
||||
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
||||
|
||||
var allTracks []TidalTrack
|
||||
searchedQueries := make(map[string]bool)
|
||||
|
||||
for _, query := range queries {
|
||||
cleanQuery := strings.TrimSpace(query)
|
||||
if cleanQuery == "" || searchedQueries[cleanQuery] {
|
||||
continue
|
||||
}
|
||||
searchedQueries[cleanQuery] = true
|
||||
|
||||
GoLog("[Tidal] Searching for: %s\n", cleanQuery)
|
||||
|
||||
searchURL := fmt.Sprintf("%s%s&limit=100&countryCode=US", string(searchBase), url.QueryEscape(cleanQuery))
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := DoRequestWithUserAgent(t.client, req)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] Search error for '%s': %v\n", cleanQuery, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Items []TidalTrack `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
resp.Body.Close()
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if len(result.Items) > 0 {
|
||||
GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
||||
|
||||
if spotifyISRC != "" {
|
||||
for i := range result.Items {
|
||||
if result.Items[i].ISRC == spotifyISRC {
|
||||
track := &result.Items[i]
|
||||
if expectedDuration > 0 {
|
||||
durationDiff := track.Duration - expectedDuration
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff <= 3 {
|
||||
GoLog("[Tidal] ISRC match: '%s' (duration verified)\n", track.Title)
|
||||
return track, nil
|
||||
}
|
||||
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||
expectedDuration, track.Duration)
|
||||
} else {
|
||||
GoLog("[Tidal] ISRC match: '%s'\n", track.Title)
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allTracks = append(allTracks, result.Items...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(allTracks) == 0 {
|
||||
return nil, fmt.Errorf("no tracks found for any search query")
|
||||
}
|
||||
|
||||
if spotifyISRC != "" {
|
||||
GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
|
||||
var isrcMatches []*TidalTrack
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
if track.ISRC == spotifyISRC {
|
||||
isrcMatches = append(isrcMatches, track)
|
||||
}
|
||||
}
|
||||
|
||||
if len(isrcMatches) > 0 {
|
||||
if expectedDuration > 0 {
|
||||
var durationVerifiedMatches []*TidalTrack
|
||||
for _, track := range isrcMatches {
|
||||
durationDiff := track.Duration - expectedDuration
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff <= 3 {
|
||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||
}
|
||||
}
|
||||
|
||||
if len(durationVerifiedMatches) > 0 {
|
||||
GoLog("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||
return durationVerifiedMatches[0], nil
|
||||
}
|
||||
|
||||
GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
||||
expectedDuration, isrcMatches[0].Duration)
|
||||
}
|
||||
|
||||
GoLog("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||
return isrcMatches[0], nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if expectedDuration > 0 {
|
||||
tolerance := 3 // 3 seconds tolerance
|
||||
var durationMatches []*TidalTrack
|
||||
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
durationDiff := track.Duration - expectedDuration
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff <= tolerance {
|
||||
durationMatches = append(durationMatches, track)
|
||||
}
|
||||
}
|
||||
|
||||
if len(durationMatches) > 0 {
|
||||
bestMatch := durationMatches[0]
|
||||
for _, track := range durationMatches {
|
||||
for _, tag := range track.MediaMetadata.Tags {
|
||||
if tag == "HIRES_LOSSLESS" {
|
||||
bestMatch = track
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
GoLog("[Tidal] Found via duration match: %s - %s (%s)\n",
|
||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality)
|
||||
return bestMatch, nil
|
||||
}
|
||||
}
|
||||
|
||||
bestMatch := &allTracks[0]
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
for _, tag := range track.MediaMetadata.Tags {
|
||||
if tag == "HIRES_LOSSLESS" {
|
||||
bestMatch = track
|
||||
break
|
||||
}
|
||||
}
|
||||
if bestMatch != &allTracks[0] {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Tidal] Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n",
|
||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality)
|
||||
|
||||
return bestMatch, nil
|
||||
}
|
||||
|
||||
func containsQuery(queries []string, query string) bool {
|
||||
for _, q := range queries {
|
||||
if q == query {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
|
||||
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
|
||||
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
||||
}
|
||||
|
||||
// TidalDownloadInfo contains download URL and quality info
|
||||
@@ -1289,6 +925,32 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
looseExpected := normalizeLooseTitle(normExpected)
|
||||
looseFound := normalizeLooseTitle(normFound)
|
||||
if looseExpected != "" && looseFound != "" {
|
||||
if looseExpected == looseFound {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji/symbol-only titles must be matched strictly to avoid false positives
|
||||
// like mapping "🪐" to "Higher Power".
|
||||
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
|
||||
strings.TrimSpace(expectedTitle) != "" &&
|
||||
strings.TrimSpace(foundTitle) != "" {
|
||||
expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle)
|
||||
foundSymbols := normalizeSymbolOnlyTitle(foundTitle)
|
||||
if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols {
|
||||
GoLog("[Tidal] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||
return true
|
||||
}
|
||||
GoLog("[Tidal] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||
return false
|
||||
}
|
||||
|
||||
expectedLatin := isLatinScript(expectedTitle)
|
||||
foundLatin := isLatinScript(foundTitle)
|
||||
if expectedLatin != foundLatin {
|
||||
@@ -1406,182 +1068,9 @@ func isLatinScript(s string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
|
||||
var track *TidalTrack
|
||||
var err error
|
||||
|
||||
if req.TidalID != "" {
|
||||
GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID)
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] Failed to get track by Odesli ID %d: %v\n", trackID, err)
|
||||
track = nil
|
||||
} else if track != nil {
|
||||
GoLog("[Tidal] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Artist.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if track == nil && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] Cache hit but failed to get track info: %v\n", err)
|
||||
track = nil // Fall through to normal search
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if track == nil && req.ISRC != "" {
|
||||
GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC)
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||
if track != nil {
|
||||
// Verify artist only (ISRC match is already accurate)
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
GoLog("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if track == nil && req.SpotifyID != "" {
|
||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||
|
||||
var trackID int64
|
||||
var gotTidalID bool
|
||||
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
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 {
|
||||
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 gotTidalID && trackID > 0 {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
|
||||
if track != nil && expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for future use
|
||||
if track != nil && req.ISRC != "" {
|
||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tidalTrackArtistsDisplay(track *TidalTrack) string {
|
||||
if track == nil {
|
||||
GoLog("[Tidal] Trying metadata search as last resort...\n")
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
if !titlesMatch(req.TrackName, track.Title) {
|
||||
GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.TrackName, track.Title)
|
||||
track = nil
|
||||
} else if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
GoLog("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if track == nil {
|
||||
errMsg := "could not find matching track on Tidal (artist/duration mismatch)"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||
return ""
|
||||
}
|
||||
|
||||
tidalArtist := track.Artist.Name
|
||||
@@ -1592,10 +1081,130 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||
return tidalArtist
|
||||
}
|
||||
|
||||
func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloader, logPrefix string) (*TidalTrack, error) {
|
||||
if downloader == nil {
|
||||
downloader = NewTidalDownloader()
|
||||
}
|
||||
if strings.TrimSpace(logPrefix) == "" {
|
||||
logPrefix = "Tidal"
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
var trackID int64
|
||||
var gotTidalID bool
|
||||
|
||||
if req.TidalID != "" {
|
||||
GoLog("[%s] Using Tidal ID from Odesli enrichment: %s\n", logPrefix, req.TidalID)
|
||||
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||
GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.TidalTrackID)
|
||||
trackID = cached.TidalTrackID
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
|
||||
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
|
||||
|
||||
resolveFromAvailability := func(availability *TrackAvailability) {
|
||||
if availability == nil || gotTidalID {
|
||||
return
|
||||
}
|
||||
if availability.TidalID != "" {
|
||||
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
|
||||
gotTidalID = true
|
||||
return
|
||||
}
|
||||
}
|
||||
if availability.TidalURL != "" {
|
||||
var idErr error
|
||||
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
|
||||
if idErr == nil && trackID > 0 {
|
||||
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer Deezer-based SongLink lookup when DeezerID is available.
|
||||
if req.DeezerID != "" {
|
||||
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
availability, slErr := songlink.CheckAvailabilityFromDeezer(req.DeezerID)
|
||||
if slErr == nil {
|
||||
resolveFromAvailability(availability)
|
||||
} else {
|
||||
GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr)
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && req.SpotifyID != "" {
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
if slErr == nil {
|
||||
resolveFromAvailability(availability)
|
||||
} else {
|
||||
GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && req.SpotifyID != "" && !strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
songlink := NewSongLinkClient()
|
||||
availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
if slErr == nil {
|
||||
resolveFromAvailability(availability)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID || trackID <= 0 {
|
||||
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
|
||||
}
|
||||
|
||||
track := &TidalTrack{
|
||||
ID: trackID,
|
||||
Title: strings.TrimSpace(req.TrackName),
|
||||
ISRC: strings.TrimSpace(req.ISRC),
|
||||
Duration: expectedDurationSec,
|
||||
TrackNumber: req.TrackNumber,
|
||||
VolumeNumber: req.DiscNumber,
|
||||
}
|
||||
track.Artist.Name = strings.TrimSpace(req.ArtistName)
|
||||
track.Album.Title = strings.TrimSpace(req.AlbumName)
|
||||
track.Album.ReleaseDate = strings.TrimSpace(req.ReleaseDate)
|
||||
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||
GetTrackIDCache().SetTidal(req.ISRC, trackID)
|
||||
}
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
track, err := resolveTidalTrackForRequest(req, downloader, "Tidal")
|
||||
if err != nil {
|
||||
return TidalDownloadResult{}, err
|
||||
}
|
||||
|
||||
quality := req.Quality
|
||||
@@ -1674,13 +1283,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
embedLyrics = false
|
||||
}
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
req.CoverURL,
|
||||
coverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.EmbedLyrics,
|
||||
embedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}()
|
||||
@@ -1764,11 +1379,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
|
||||
if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) {
|
||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
if req.EmbedMetadata {
|
||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
GoLog("[Tidal] Metadata embedding disabled by settings, skipping FLAC metadata/lyrics embedding\n")
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
@@ -1791,14 +1410,14 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
fmt.Println("[Tidal] Lyrics embedded successfully")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
} else if req.EmbedMetadata && req.EmbedLyrics {
|
||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||
}
|
||||
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
||||
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 != "" {
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
@@ -1829,7 +1448,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
bitDepth = 0
|
||||
sampleRate = 44100
|
||||
}
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
||||
func normalizeLooseTitle(title string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(title))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(trimmed))
|
||||
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsNumber(r):
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r):
|
||||
b.WriteByte(' ')
|
||||
// Treat common separators as spaces.
|
||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
// Drop other punctuation/symbols (including emoji) for loose matching.
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
func hasAlphaNumericRunes(value string) bool {
|
||||
for _, r := range value {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeSymbolOnlyTitle keeps symbol/emoji runes while dropping letters,
|
||||
// digits, spaces and punctuation. This is useful for emoji-only titles such as
|
||||
// "🪐", "🌎" etc, so we can compare them strictly and avoid false matches.
|
||||
func normalizeSymbolOnlyTitle(title string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(title))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(trimmed))
|
||||
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsNumber(r), unicode.IsSpace(r), unicode.IsPunct(r):
|
||||
continue
|
||||
// Drop combining marks such as emoji variation selectors.
|
||||
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeLooseTitle_Separators(t *testing.T) {
|
||||
got := normalizeLooseTitle("Doctor / Cops")
|
||||
if got != "doctor cops" {
|
||||
t.Fatalf("expected doctor cops, got %q", got)
|
||||
}
|
||||
|
||||
got = normalizeLooseTitle("Doctor _ Cops")
|
||||
if got != "doctor cops" {
|
||||
t.Fatalf("expected doctor cops, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) {
|
||||
got := normalizeLooseTitle("Music Of The Spheres 🌎✨")
|
||||
if got != "music of the spheres" {
|
||||
t.Fatalf("expected music of the spheres, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
|
||||
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
||||
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitlesMatch_EmojiStrict(t *testing.T) {
|
||||
if titlesMatch("🪐", "Higher Power") {
|
||||
t.Fatal("expected emoji title not to match unrelated textual title")
|
||||
}
|
||||
if !titlesMatch("🪐", "🪐") {
|
||||
t.Fatal("expected identical emoji titles to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzTitlesMatch_SeparatorVariants(t *testing.T) {
|
||||
if !qobuzTitlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
||||
t.Fatal("expected qobuzTitlesMatch to accept / vs _ variant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzTitlesMatch_EmojiStrict(t *testing.T) {
|
||||
if qobuzTitlesMatch("🪐", "Higher Power") {
|
||||
t.Fatal("expected emoji title not to match unrelated textual title")
|
||||
}
|
||||
if !qobuzTitlesMatch("🪐", "🪐") {
|
||||
t.Fatal("expected identical emoji titles to match")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -20,6 +21,8 @@ type YouTubeDownloader struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
const spotubeBaseURL = "https://spotubedl.com"
|
||||
|
||||
var (
|
||||
globalYouTubeDownloader *YouTubeDownloader
|
||||
youtubeDownloaderOnce sync.Once
|
||||
@@ -29,9 +32,17 @@ type YouTubeQuality string
|
||||
|
||||
const (
|
||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
|
||||
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeOpusSupportedBitrates = []int{128, 256}
|
||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||
)
|
||||
|
||||
type CobaltRequest struct {
|
||||
URL string `json:"url"`
|
||||
AudioBitrate string `json:"audioBitrate,omitempty"`
|
||||
@@ -79,6 +90,77 @@ func NewYouTubeDownloader() *YouTubeDownloader {
|
||||
return globalYouTubeDownloader
|
||||
}
|
||||
|
||||
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return (r < '0' || r > '9')
|
||||
})
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if parsed, err := strconv.Atoi(part); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultBitrate
|
||||
}
|
||||
|
||||
func nearestSupportedBitrate(value int, supported []int) int {
|
||||
nearest := supported[0]
|
||||
nearestDistance := absInt(value - nearest)
|
||||
|
||||
for _, option := range supported[1:] {
|
||||
distance := absInt(value - option)
|
||||
// On tie prefer higher quality.
|
||||
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
|
||||
nearest = option
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
return nearest
|
||||
}
|
||||
|
||||
func absInt(value int) int {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
|
||||
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
|
||||
|
||||
if strings.HasPrefix(normalizedRaw, "opus") {
|
||||
parsed := extractBitrateFromQuality(normalizedRaw, 256)
|
||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
|
||||
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(normalizedRaw, "mp3") {
|
||||
parsed := extractBitrateFromQuality(normalizedRaw, 320)
|
||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
|
||||
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
|
||||
}
|
||||
|
||||
// Backward compatibility for legacy symbolic values.
|
||||
switch normalizedRaw {
|
||||
case "opus_256", "opus256", "opus":
|
||||
return "opus", 256, YouTubeQualityOpus256
|
||||
case "opus_128", "opus128":
|
||||
return "opus", 128, YouTubeQualityOpus128
|
||||
case "mp3_320", "mp3320", "mp3", "":
|
||||
return "mp3", 320, YouTubeQualityMP3320
|
||||
case "mp3_256", "mp3256":
|
||||
return "mp3", 256, YouTubeQualityMP3256
|
||||
case "mp3_128", "mp3128":
|
||||
return "mp3", 128, YouTubeQualityMP3128
|
||||
default:
|
||||
return "mp3", 320, YouTubeQualityMP3320
|
||||
}
|
||||
}
|
||||
|
||||
// SearchYouTube returns a YouTube Music search URL for the given track
|
||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
@@ -95,22 +177,11 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
|
||||
var audioFormat string
|
||||
var audioBitrate string
|
||||
|
||||
switch quality {
|
||||
case YouTubeQualityOpus256:
|
||||
audioFormat = "opus"
|
||||
audioBitrate = "256"
|
||||
case YouTubeQualityMP3320:
|
||||
audioFormat = "mp3"
|
||||
audioBitrate = "320"
|
||||
default:
|
||||
audioFormat = "mp3"
|
||||
audioBitrate = "320"
|
||||
}
|
||||
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
|
||||
audioBitrate := strconv.Itoa(bitrate)
|
||||
|
||||
// Try SpotubeDL first (primary)
|
||||
var spotubeErr error
|
||||
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
||||
if extractErr == nil {
|
||||
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
||||
@@ -120,6 +191,7 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
spotubeErr = err
|
||||
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
||||
} else {
|
||||
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
||||
@@ -132,6 +204,9 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
|
||||
|
||||
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
||||
if err != nil {
|
||||
if spotubeErr != nil {
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
|
||||
}
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
||||
}
|
||||
|
||||
@@ -201,11 +276,34 @@ func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitr
|
||||
}
|
||||
|
||||
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
||||
// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
|
||||
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s",
|
||||
videoID, audioFormat, audioBitrate)
|
||||
engines := []string{"v1"}
|
||||
if strings.EqualFold(audioFormat, "mp3") {
|
||||
engines = append(engines, "v3", "v2")
|
||||
}
|
||||
var lastErr error
|
||||
|
||||
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
|
||||
for _, engine := range engines {
|
||||
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no SpotubeDL engine available")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
|
||||
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
|
||||
|
||||
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -225,27 +323,60 @@ func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
|
||||
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
|
||||
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
||||
}
|
||||
|
||||
if result.URL == "" {
|
||||
return nil, fmt.Errorf("no download URL from spotubedl")
|
||||
downloadURL := strings.TrimSpace(result.URL)
|
||||
if downloadURL == "" {
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
|
||||
}
|
||||
if result.Message != "" {
|
||||
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from SpotubeDL\n")
|
||||
if strings.HasPrefix(downloadURL, "/") {
|
||||
downloadURL = spotubeBaseURL + downloadURL
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
|
||||
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
|
||||
}
|
||||
|
||||
filename := strings.TrimSpace(result.Filename)
|
||||
if filename == "" {
|
||||
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
|
||||
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
|
||||
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
|
||||
filename = decodedFilename
|
||||
} else {
|
||||
filename = queryFilename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
|
||||
return &CobaltResponse{
|
||||
Status: "tunnel",
|
||||
URL: result.URL,
|
||||
Status: "tunnel",
|
||||
URL: downloadURL,
|
||||
Filename: filename,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -411,15 +542,7 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||
downloader := NewYouTubeDownloader()
|
||||
|
||||
var quality YouTubeQuality
|
||||
switch strings.ToLower(req.Quality) {
|
||||
case "opus_256", "opus256", "opus":
|
||||
quality = YouTubeQualityOpus256
|
||||
case "mp3_320", "mp3320", "mp3":
|
||||
quality = YouTubeQualityMP3320
|
||||
default:
|
||||
quality = YouTubeQualityMP3320 // Default to MP3 320kbps
|
||||
}
|
||||
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
||||
|
||||
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
|
||||
var youtubeURL string
|
||||
@@ -480,18 +603,23 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
var ext string
|
||||
var format string
|
||||
var bitrate int
|
||||
switch quality {
|
||||
case YouTubeQualityOpus256:
|
||||
ext := ".mp3"
|
||||
if format == "opus" {
|
||||
ext = ".opus"
|
||||
format = "opus"
|
||||
bitrate = 256
|
||||
case YouTubeQualityMP3320:
|
||||
ext = ".mp3"
|
||||
format = "mp3"
|
||||
bitrate = 320
|
||||
}
|
||||
|
||||
// Some SpotubeDL engines may return a different output container than requested.
|
||||
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
|
||||
if cobaltResp != nil && cobaltResp.Filename != "" {
|
||||
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
|
||||
switch {
|
||||
case strings.HasSuffix(lowerName, ".mp3"):
|
||||
ext = ".mp3"
|
||||
format = "mp3"
|
||||
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
|
||||
ext = ".opus"
|
||||
format = "opus"
|
||||
}
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
|
||||
if format != "opus" {
|
||||
t.Fatalf("expected opus format, got %s", format)
|
||||
}
|
||||
if bitrate != 128 {
|
||||
t.Fatalf("expected 128 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityOpus128 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
|
||||
if format != "mp3" {
|
||||
t.Fatalf("expected mp3 format, got %s", format)
|
||||
}
|
||||
if bitrate != 256 {
|
||||
t.Fatalf("expected 256 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityMP3256 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
||||
if opusBitrate != 256 {
|
||||
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
|
||||
}
|
||||
|
||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
||||
if mp3Bitrate != 128 {
|
||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -5,6 +5,15 @@ import Gobackend // Import Go framework
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
private let CHANNEL = "com.zarz.spotiflac/backend"
|
||||
private let DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream"
|
||||
private let LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream"
|
||||
private let streamQueue = DispatchQueue(label: "com.zarz.spotiflac.progress_stream", qos: .utility)
|
||||
private var downloadProgressTimer: DispatchSourceTimer?
|
||||
private var downloadProgressEventSink: FlutterEventSink?
|
||||
private var lastDownloadProgressPayload: String?
|
||||
private var libraryScanProgressTimer: DispatchSourceTimer?
|
||||
private var libraryScanProgressEventSink: FlutterEventSink?
|
||||
private var lastLibraryScanProgressPayload: String?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -16,14 +25,111 @@ import Gobackend // Import Go framework
|
||||
name: CHANNEL,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
let downloadProgressEvents = FlutterEventChannel(
|
||||
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
let libraryScanProgressEvents = FlutterEventChannel(
|
||||
name: LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handleMethodCall(call: call, result: result)
|
||||
}
|
||||
downloadProgressEvents.setStreamHandler(
|
||||
ClosureStreamHandler(
|
||||
onListen: { [weak self] _, events in
|
||||
self?.startDownloadProgressStream(events)
|
||||
return nil
|
||||
},
|
||||
onCancel: { [weak self] _ in
|
||||
self?.stopDownloadProgressStream()
|
||||
return nil
|
||||
}
|
||||
)
|
||||
)
|
||||
libraryScanProgressEvents.setStreamHandler(
|
||||
ClosureStreamHandler(
|
||||
onListen: { [weak self] _, events in
|
||||
self?.startLibraryScanProgressStream(events)
|
||||
return nil
|
||||
},
|
||||
onCancel: { [weak self] _ in
|
||||
self?.stopLibraryScanProgressStream()
|
||||
return nil
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopDownloadProgressStream()
|
||||
stopLibraryScanProgressStream()
|
||||
}
|
||||
|
||||
private func startDownloadProgressStream(_ eventSink: @escaping FlutterEventSink) {
|
||||
stopDownloadProgressStream()
|
||||
downloadProgressEventSink = eventSink
|
||||
lastDownloadProgressPayload = nil
|
||||
|
||||
let timer = DispatchSource.makeTimerSource(queue: streamQueue)
|
||||
timer.schedule(deadline: .now(), repeating: .milliseconds(800))
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
let payload = GobackendGetAllDownloadProgress() as String? ?? "{}"
|
||||
if payload == self.lastDownloadProgressPayload {
|
||||
return
|
||||
}
|
||||
self.lastDownloadProgressPayload = payload
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.downloadProgressEventSink?(payload)
|
||||
}
|
||||
}
|
||||
downloadProgressTimer = timer
|
||||
timer.resume()
|
||||
}
|
||||
|
||||
private func stopDownloadProgressStream() {
|
||||
downloadProgressTimer?.setEventHandler {}
|
||||
downloadProgressTimer?.cancel()
|
||||
downloadProgressTimer = nil
|
||||
downloadProgressEventSink = nil
|
||||
lastDownloadProgressPayload = nil
|
||||
}
|
||||
|
||||
private func startLibraryScanProgressStream(_ eventSink: @escaping FlutterEventSink) {
|
||||
stopLibraryScanProgressStream()
|
||||
libraryScanProgressEventSink = eventSink
|
||||
lastLibraryScanProgressPayload = nil
|
||||
|
||||
let timer = DispatchSource.makeTimerSource(queue: streamQueue)
|
||||
timer.schedule(deadline: .now(), repeating: .milliseconds(800))
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
let payload = GobackendGetLibraryScanProgressJSON() as String? ?? "{}"
|
||||
if payload == self.lastLibraryScanProgressPayload {
|
||||
return
|
||||
}
|
||||
self.lastLibraryScanProgressPayload = payload
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.libraryScanProgressEventSink?(payload)
|
||||
}
|
||||
}
|
||||
libraryScanProgressTimer = timer
|
||||
timer.resume()
|
||||
}
|
||||
|
||||
private func stopLibraryScanProgressStream() {
|
||||
libraryScanProgressTimer?.setEventHandler {}
|
||||
libraryScanProgressTimer?.cancel()
|
||||
libraryScanProgressTimer = nil
|
||||
libraryScanProgressEventSink = nil
|
||||
lastLibraryScanProgressPayload = nil
|
||||
}
|
||||
|
||||
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
@@ -74,6 +180,14 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getSpotifyRelatedArtists":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let artistId = args["artist_id"] as! String
|
||||
let limit = args["limit"] as? Int ?? 12
|
||||
let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkAvailability":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -127,6 +241,13 @@ import Gobackend // Import Go framework
|
||||
GobackendSetDownloadDirectory(path, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let allowHTTP = args["allow_http"] as? Bool ?? false
|
||||
let insecureTLS = args["insecure_tls"] as? Bool ?? false
|
||||
GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
||||
return nil
|
||||
|
||||
case "checkDuplicate":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -191,6 +312,17 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getLyricsLRCWithSource":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let spotifyId = args["spotify_id"] as! String
|
||||
let trackName = args["track_name"] as! String
|
||||
let artistName = args["artist_name"] as! String
|
||||
let filePath = args["file_path"] as? String ?? ""
|
||||
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||
let response = GobackendGetLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "embedLyricsToFile":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -264,6 +396,14 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getDeezerRelatedArtists":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let artistId = args["artist_id"] as! String
|
||||
let limit = args["limit"] as? Int ?? 12
|
||||
let response = GobackendGetDeezerRelatedArtists(artistId, Int(limit), &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getDeezerMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let resourceType = args["resource_type"] as! String
|
||||
@@ -783,6 +923,36 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Lyrics Provider Settings
|
||||
case "setLyricsProviders":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let providersJson = args["providers_json"] as? String ?? "[]"
|
||||
GobackendSetLyricsProvidersJSON(providersJson, &error)
|
||||
if let error = error { throw error }
|
||||
return "{\"success\":true}"
|
||||
|
||||
case "getLyricsProviders":
|
||||
let response = GobackendGetLyricsProvidersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getAvailableLyricsProviders":
|
||||
let response = GobackendGetAvailableLyricsProvidersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setLyricsFetchOptions":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let optionsJson = args["options_json"] as? String ?? "{}"
|
||||
GobackendSetLyricsFetchOptionsJSON(optionsJson, &error)
|
||||
if let error = error { throw error }
|
||||
return "{\"success\":true}"
|
||||
|
||||
case "getLyricsFetchOptions":
|
||||
let response = GobackendGetLyricsFetchOptionsJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
default:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
@@ -792,3 +962,27 @@ import Gobackend // Import Go framework
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
|
||||
typealias ListenHandler = (_ arguments: Any?, _ events: @escaping FlutterEventSink) -> FlutterError?
|
||||
typealias CancelHandler = (_ arguments: Any?) -> FlutterError?
|
||||
|
||||
private let onListenHandler: ListenHandler
|
||||
private let onCancelHandler: CancelHandler
|
||||
|
||||
init(
|
||||
onListen: @escaping ListenHandler,
|
||||
onCancel: @escaping CancelHandler = { _ in nil }
|
||||
) {
|
||||
self.onListenHandler = onListen
|
||||
self.onCancelHandler = onCancel
|
||||
}
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
||||
onListenHandler(arguments, events)
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
onCancelHandler(arguments)
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 576 B After Width: | Height: | Size: 905 B |
|
Before Width: | Height: | Size: 744 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 419 B After Width: | Height: | Size: 624 B |
|
Before Width: | Height: | Size: 789 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 576 B After Width: | Height: | Size: 905 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 717 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 752 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -105,5 +105,11 @@
|
||||
<string>tidal</string>
|
||||
<string>youtube-music</string>
|
||||
</array>
|
||||
|
||||
<!-- Background audio playback -->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -37,6 +37,9 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (context, state) => const TutorialScreen(),
|
||||
),
|
||||
],
|
||||
// Safety net: if a deep link URL (e.g. Spotify/Deezer) somehow reaches
|
||||
// GoRouter, redirect to home instead of showing "Page Not Found".
|
||||
errorBuilder: (context, state) => const MainShell(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,10 +57,14 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
: null;
|
||||
|
||||
Locale? locale;
|
||||
if (localeString != 'system') {
|
||||
if (localeString != 'system' && localeString.isNotEmpty) {
|
||||
if (localeString.contains('_')) {
|
||||
final parts = localeString.split('_');
|
||||
locale = Locale(parts[0], parts[1]);
|
||||
if (parts.length == 2) {
|
||||
locale = Locale(parts[0], parts[1]);
|
||||
} else {
|
||||
locale = Locale(parts[0]);
|
||||
}
|
||||
} else {
|
||||
locale = Locale(localeString);
|
||||
}
|
||||
@@ -76,6 +83,25 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
themeAnimationCurve: Curves.easeInOut,
|
||||
routerConfig: router,
|
||||
locale: locale,
|
||||
localeResolutionCallback: (deviceLocale, supportedLocales) {
|
||||
if (locale != null) return locale;
|
||||
if (deviceLocale == null) return supportedLocales.first;
|
||||
|
||||
for (var supportedLocale in supportedLocales) {
|
||||
if (supportedLocale.languageCode == deviceLocale.languageCode &&
|
||||
supportedLocale.countryCode == deviceLocale.countryCode) {
|
||||
return supportedLocale;
|
||||
}
|
||||
}
|
||||
|
||||
for (var supportedLocale in supportedLocales) {
|
||||
if (supportedLocale.languageCode == deviceLocale.languageCode) {
|
||||
return supportedLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return supportedLocales.first;
|
||||
},
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.6.7';
|
||||
static const String buildNumber = '81';
|
||||
static const String version = '3.7.0';
|
||||
static const String buildNumber = '103';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -544,6 +544,54 @@ abstract class AppLocalizations {
|
||||
/// **'Try other services if download fails'**
|
||||
String get optionsAutoFallbackSubtitle;
|
||||
|
||||
/// Toggle to skip to the next queue track when current track stream resolution fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto Skip Unavailable Tracks'**
|
||||
String get optionsAutoSkipUnavailableTracks;
|
||||
|
||||
/// Subtitle when auto skip on resolve failure is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Automatically skip to the next queue track when a stream cannot be resolved.'**
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn;
|
||||
|
||||
/// Subtitle when auto skip on resolve failure is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stop on failed track resolution and show an error.'**
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff;
|
||||
|
||||
/// Tap behavior mode for track lists
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Interaction Mode'**
|
||||
String get optionsInteractionMode;
|
||||
|
||||
/// Interaction mode where taps queue downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloader Mode'**
|
||||
String get modeDownloader;
|
||||
|
||||
/// Subtitle for downloader interaction mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap tracks to add them to download queue'**
|
||||
String get modeDownloaderSubtitle;
|
||||
|
||||
/// Interaction mode where taps start playback
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Streaming Mode'**
|
||||
String get modeStreaming;
|
||||
|
||||
/// Subtitle for streaming interaction mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap tracks to play instantly'**
|
||||
String get modeStreamingSubtitle;
|
||||
|
||||
/// Enable extension download providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1906,6 +1954,12 @@ abstract class AppLocalizations {
|
||||
/// **'No tracks found'**
|
||||
String get errorNoTracksFound;
|
||||
|
||||
/// Error - seek disabled for live decrypted stream
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Seeking is not supported for this live stream'**
|
||||
String get errorSeekNotSupported;
|
||||
|
||||
/// Error - extension source not available
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2842,6 +2896,12 @@ abstract class AppLocalizations {
|
||||
/// **'Download All ({count})'**
|
||||
String downloadAllCount(int count);
|
||||
|
||||
/// Play all button with count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play All ({count})'**
|
||||
String playAllCount(int count);
|
||||
|
||||
/// Track count display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3508,6 +3568,42 @@ abstract class AppLocalizations {
|
||||
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
|
||||
String get youtubeQualityNote;
|
||||
|
||||
/// Title for YouTube Opus bitrate setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'YouTube Opus Bitrate'**
|
||||
String get youtubeOpusBitrateTitle;
|
||||
|
||||
/// Title for YouTube MP3 bitrate setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'YouTube MP3 Bitrate'**
|
||||
String get youtubeMp3BitrateTitle;
|
||||
|
||||
/// Subtitle showing current bitrate and valid range
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{bitrate}kbps ({min}-{max})'**
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max);
|
||||
|
||||
/// Helper text for manual YouTube bitrate input
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter custom bitrate ({min}-{max} kbps)'**
|
||||
String youtubeBitrateInputHelp(int min, int max);
|
||||
|
||||
/// Label for YouTube bitrate input field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bitrate (kbps)'**
|
||||
String get youtubeBitrateFieldLabel;
|
||||
|
||||
/// Validation error for invalid YouTube bitrate input
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bitrate must be between {min} and {max} kbps'**
|
||||
String youtubeBitrateValidationError(int min, int max);
|
||||
|
||||
/// Setting - show quality picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4012,12 +4108,24 @@ abstract class AppLocalizations {
|
||||
/// **'Download Discography'**
|
||||
String get discographyDownload;
|
||||
|
||||
/// Button - play artist discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play Discography'**
|
||||
String get discographyPlay;
|
||||
|
||||
/// Option - download entire discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download All'**
|
||||
String get discographyDownloadAll;
|
||||
|
||||
/// Option - play entire discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play All'**
|
||||
String get discographyPlayAll;
|
||||
|
||||
/// Subtitle showing total tracks and albums
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4084,6 +4192,12 @@ abstract class AppLocalizations {
|
||||
/// **'Download Selected'**
|
||||
String get discographyDownloadSelected;
|
||||
|
||||
/// Button - play selected albums
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play Selected'**
|
||||
String get discographyPlaySelected;
|
||||
|
||||
/// Snackbar - tracks added from discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4306,6 +4420,12 @@ abstract class AppLocalizations {
|
||||
/// **'{count} tracks'**
|
||||
String libraryTracksCount(int count);
|
||||
|
||||
/// Unit label for tracks count (without the number itself)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{track} other{tracks}}'**
|
||||
String libraryTracksUnit(int count);
|
||||
|
||||
/// Last scan time display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -5089,7 +5209,7 @@ abstract class AppLocalizations {
|
||||
/// Menu action - re-embed metadata into audio file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Re-enrich Metadata'**
|
||||
/// **'Re-enrich'**
|
||||
String get trackReEnrich;
|
||||
|
||||
/// Subtitle for re-enrich metadata action
|
||||
@@ -5221,6 +5341,364 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Conversion failed'**
|
||||
String get trackConvertFailed;
|
||||
|
||||
/// Generic action button - create
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create'**
|
||||
String get actionCreate;
|
||||
|
||||
/// Library section title for custom folders
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'My folders'**
|
||||
String get collectionFoldersTitle;
|
||||
|
||||
/// Custom folder for saved tracks to download later
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Wishlist'**
|
||||
String get collectionWishlist;
|
||||
|
||||
/// Custom folder for favorite tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Loved'**
|
||||
String get collectionLoved;
|
||||
|
||||
/// Custom user playlists folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlists'**
|
||||
String get collectionPlaylists;
|
||||
|
||||
/// Single playlist label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist'**
|
||||
String get collectionPlaylist;
|
||||
|
||||
/// Action to add a track to user playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to playlist'**
|
||||
String get collectionAddToPlaylist;
|
||||
|
||||
/// Action to create a new playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create playlist'**
|
||||
String get collectionCreatePlaylist;
|
||||
|
||||
/// Empty state title when user has no playlists
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No playlists yet'**
|
||||
String get collectionNoPlaylistsYet;
|
||||
|
||||
/// Empty state subtitle when user has no playlists
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create a playlist to start categorizing tracks'**
|
||||
String get collectionNoPlaylistsSubtitle;
|
||||
|
||||
/// Track count label for custom playlists
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
|
||||
String collectionPlaylistTracks(int count);
|
||||
|
||||
/// Snackbar after adding track to playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added to \"{playlistName}\"'**
|
||||
String collectionAddedToPlaylist(String playlistName);
|
||||
|
||||
/// Snackbar when track already exists in playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Already in \"{playlistName}\"'**
|
||||
String collectionAlreadyInPlaylist(String playlistName);
|
||||
|
||||
/// Snackbar after creating playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist created'**
|
||||
String get collectionPlaylistCreated;
|
||||
|
||||
/// Hint text for playlist name input
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist name'**
|
||||
String get collectionPlaylistNameHint;
|
||||
|
||||
/// Validation error for empty playlist name
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist name is required'**
|
||||
String get collectionPlaylistNameRequired;
|
||||
|
||||
/// Action to rename playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rename playlist'**
|
||||
String get collectionRenamePlaylist;
|
||||
|
||||
/// Action to delete playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete playlist'**
|
||||
String get collectionDeletePlaylist;
|
||||
|
||||
/// Confirmation message for deleting playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete \"{playlistName}\" and all tracks inside it?'**
|
||||
String collectionDeletePlaylistMessage(String playlistName);
|
||||
|
||||
/// Snackbar after deleting playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist deleted'**
|
||||
String get collectionPlaylistDeleted;
|
||||
|
||||
/// Snackbar after renaming playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist renamed'**
|
||||
String get collectionPlaylistRenamed;
|
||||
|
||||
/// Wishlist empty state title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Wishlist is empty'**
|
||||
String get collectionWishlistEmptyTitle;
|
||||
|
||||
/// Wishlist empty state subtitle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap + on tracks to save what you want to download later'**
|
||||
String get collectionWishlistEmptySubtitle;
|
||||
|
||||
/// Loved empty state title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Loved folder is empty'**
|
||||
String get collectionLovedEmptyTitle;
|
||||
|
||||
/// Loved empty state subtitle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap love on tracks to keep your favorites'**
|
||||
String get collectionLovedEmptySubtitle;
|
||||
|
||||
/// Playlist empty state title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist is empty'**
|
||||
String get collectionPlaylistEmptyTitle;
|
||||
|
||||
/// Playlist empty state subtitle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Long-press + on any track to add it here'**
|
||||
String get collectionPlaylistEmptySubtitle;
|
||||
|
||||
/// Tooltip for removing track from playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove from playlist'**
|
||||
String get collectionRemoveFromPlaylist;
|
||||
|
||||
/// Tooltip for removing track from wishlist/loved folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove from folder'**
|
||||
String get collectionRemoveFromFolder;
|
||||
|
||||
/// Snackbar after removing a track from a collection
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'\"{trackName}\" removed'**
|
||||
String collectionRemoved(String trackName);
|
||||
|
||||
/// Snackbar after adding track to loved folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'\"{trackName}\" added to Loved'**
|
||||
String collectionAddedToLoved(String trackName);
|
||||
|
||||
/// Snackbar after removing track from loved folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'\"{trackName}\" removed from Loved'**
|
||||
String collectionRemovedFromLoved(String trackName);
|
||||
|
||||
/// Snackbar after adding track to wishlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'\"{trackName}\" added to Wishlist'**
|
||||
String collectionAddedToWishlist(String trackName);
|
||||
|
||||
/// Snackbar after removing track from wishlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'\"{trackName}\" removed from Wishlist'**
|
||||
String collectionRemovedFromWishlist(String trackName);
|
||||
|
||||
/// Bottom sheet action label - add track to loved folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to Loved'**
|
||||
String get trackOptionAddToLoved;
|
||||
|
||||
/// Bottom sheet action label - remove track from loved folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove from Loved'**
|
||||
String get trackOptionRemoveFromLoved;
|
||||
|
||||
/// Bottom sheet action label - add track to wishlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to Wishlist'**
|
||||
String get trackOptionAddToWishlist;
|
||||
|
||||
/// Bottom sheet action label - remove track from wishlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove from Wishlist'**
|
||||
String get trackOptionRemoveFromWishlist;
|
||||
|
||||
/// Bottom sheet action to pick a custom cover image for a playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Change cover image'**
|
||||
String get collectionPlaylistChangeCover;
|
||||
|
||||
/// Bottom sheet action to remove custom cover image from a playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove cover image'**
|
||||
String get collectionPlaylistRemoveCover;
|
||||
|
||||
/// Share button text with count in selection mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Share {count} {count, plural, =1{track} other{tracks}}'**
|
||||
String selectionShareCount(int count);
|
||||
|
||||
/// Snackbar when no selected files exist on disk
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No shareable files found'**
|
||||
String get selectionShareNoFiles;
|
||||
|
||||
/// Convert button text with count in selection mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert {count} {count, plural, =1{track} other{tracks}}'**
|
||||
String selectionConvertCount(int count);
|
||||
|
||||
/// Snackbar when no selected tracks support conversion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No convertible tracks selected'**
|
||||
String get selectionConvertNoConvertible;
|
||||
|
||||
/// Confirmation dialog title for batch conversion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Batch Convert'**
|
||||
String get selectionBatchConvertConfirmTitle;
|
||||
|
||||
/// Confirmation dialog message for batch conversion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.'**
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
);
|
||||
|
||||
/// Snackbar during batch conversion progress
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Converting {current} of {total}...'**
|
||||
String selectionBatchConvertProgress(int current, int total);
|
||||
|
||||
/// Snackbar after batch conversion completes
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Converted {success} of {total} tracks to {format}'**
|
||||
String selectionBatchConvertSuccess(int success, int total, String format);
|
||||
|
||||
/// Title for mode selection step in setup wizard
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose Your Mode'**
|
||||
String get setupModeSelectionTitle;
|
||||
|
||||
/// Description for mode selection step
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How would you like to use SpotiFLAC? You can always change this later in Settings.'**
|
||||
String get setupModeSelectionDescription;
|
||||
|
||||
/// Title for downloader mode option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloader'**
|
||||
String get setupModeDownloaderTitle;
|
||||
|
||||
/// Downloader mode feature 1
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download tracks in lossless FLAC quality'**
|
||||
String get setupModeDownloaderFeature1;
|
||||
|
||||
/// Downloader mode feature 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save music to your device for offline listening'**
|
||||
String get setupModeDownloaderFeature2;
|
||||
|
||||
/// Downloader mode feature 3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Manage your local music library'**
|
||||
String get setupModeDownloaderFeature3;
|
||||
|
||||
/// Title for streaming mode option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Streaming'**
|
||||
String get setupModeStreamingTitle;
|
||||
|
||||
/// Streaming mode feature 1
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stream tracks instantly without downloading'**
|
||||
String get setupModeStreamingFeature1;
|
||||
|
||||
/// Streaming mode feature 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smart Queue auto-discovers new music for you'**
|
||||
String get setupModeStreamingFeature2;
|
||||
|
||||
/// Streaming mode feature 3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play any track on demand with playback controls'**
|
||||
String get setupModeStreamingFeature3;
|
||||
|
||||
/// Hint that mode can be changed later
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You can switch between modes anytime in Settings.'**
|
||||
String get setupModeChangeableLater;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -251,6 +251,33 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Andere Dienste versuchen, wenn Download fehlschlägt';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Erweiterungs-Anbieter verwenden';
|
||||
|
||||
@@ -1057,6 +1084,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Keine Titel gefunden';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Kann $item nicht lade wegen fehlender Erweiterungsquelle';
|
||||
@@ -1578,6 +1609,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1944,6 +1980,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -2232,9 +2292,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2279,6 +2345,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -2404,6 +2473,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2883,7 +2963,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2963,4 +3043,249 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Wähle deinen Modus';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Downloader';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Lade Titel in verlustfreier FLAC-Qualität herunter';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Speichere Musik auf deinem Gerät zum Offline-Hören';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Verwalte deine lokale Musikbibliothek';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Streame Titel sofort ohne Herunterladen';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue entdeckt automatisch neue Musik für dich';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Spiele jeden Titel auf Abruf mit Wiedergabesteuerung';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln.';
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1923,6 +1959,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -2211,9 +2271,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2258,6 +2324,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -2383,6 +2452,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2862,7 +2942,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2942,4 +3022,248 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Choose Your Mode';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'How would you like to use SpotiFLAC? You can always change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Downloader';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Download tracks in lossless FLAC quality';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Save music to your device for offline listening';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => 'Manage your local music library';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Stream tracks instantly without downloading';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue auto-discovers new music for you';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Play any track on demand with playback controls';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'You can switch between modes anytime in Settings.';
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1923,6 +1959,30 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -2211,9 +2271,15 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2258,6 +2324,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -2383,6 +2452,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2862,7 +2942,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2942,6 +3022,251 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Elige tu modo';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Descargador';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Descarga pistas en calidad FLAC sin pérdida';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Guarda música en tu dispositivo para escuchar sin conexión';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Gestiona tu biblioteca de música local';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Transmite pistas al instante sin descargar';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue descubre automáticamente nueva música para ti';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Reproduce cualquier pista bajo demanda con controles de reproducción';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Puedes cambiar entre modos en cualquier momento en Ajustes.';
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
@@ -5828,7 +6153,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -5908,4 +6233,45 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Elige tu modo';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Descargador';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Descarga pistas en calidad FLAC sin pérdida';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Guarda música en tu dispositivo para escuchar sin conexión';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Gestiona tu biblioteca de música local';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Transmite pistas al instante sin descargar';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue descubre automáticamente nueva música para ti';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Reproduce cualquier pista bajo demanda con controles de reproducción';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Puedes cambiar entre modos en cualquier momento en Ajustes.';
|
||||
}
|
||||
|
||||
@@ -253,6 +253,33 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Essayez d\'autres services si le téléchargement échoue';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders =>
|
||||
'Utiliser des fournisseurs d\'extension';
|
||||
@@ -1047,6 +1074,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1563,6 +1594,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1929,6 +1965,30 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -2217,9 +2277,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2264,6 +2330,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -2389,6 +2458,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2868,7 +2948,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2948,4 +3028,249 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Choisissez votre mode';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Comment souhaitez-vous utiliser SpotiFLAC ? Vous pouvez toujours changer cela plus tard dans les Paramètres.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Téléchargeur';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Téléchargez des pistes en qualité FLAC sans perte';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Enregistrez de la musique sur votre appareil pour une écoute hors ligne';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Gérez votre bibliothèque musicale locale';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Diffusez des pistes instantanément sans télécharger';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue découvre automatiquement de nouvelle musique pour vous';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Écoutez n\'importe quelle piste à la demande avec les contrôles de lecture';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Vous pouvez changer de mode à tout moment dans les Paramètres.';
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1923,6 +1959,30 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -2211,9 +2271,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2258,6 +2324,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -2383,6 +2452,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2862,7 +2942,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2942,4 +3022,249 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'अपना मोड चुनें';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'डाउनलोडर';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'स्ट्रीमिंग';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'आप सेटिंग्स में कभी भी मोड बदल सकते हैं।';
|
||||
}
|
||||
|
||||
@@ -251,6 +251,34 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Coba layanan lain jika unduhan gagal';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks =>
|
||||
'Lewati Otomatis Lagu yang Tidak Tersedia';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Otomatis lanjut ke lagu berikutnya di antrean jika stream lagu tidak bisa ditemukan.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Berhenti di lagu yang gagal dan tampilkan pesan error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Mode Interaksi';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Mode Downloader';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Ketuk lagu untuk menambah ke antrean unduhan';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Mode Streaming';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Ketuk lagu untuk langsung memutar';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Gunakan Provider Ekstensi';
|
||||
|
||||
@@ -1047,6 +1075,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Menggeser posisi lagu tidak didukung untuk live stream ini';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Tidak dapat memuat $item: sumber ekstensi tidak ada';
|
||||
@@ -1567,6 +1599,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Unduh Semua ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Putar Semua ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1935,6 +1972,30 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'Bitrate Opus YouTube';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Masukkan bitrate manual ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate harus antara $min dan $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||
|
||||
@@ -2224,9 +2285,15 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Putar Diskografi';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Unduh Semua';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Putar Semua';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2271,6 +2338,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Putar Terpilih';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -2396,6 +2466,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'trek',
|
||||
one: 'trek',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2875,7 +2956,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2955,4 +3036,250 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Buat';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'Folder saya';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Tambahkan ke playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Buat playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'Belum ada playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Buat playlist untuk mulai mengategorikan lagu';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count lagu',
|
||||
one: '1 lagu',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Ditambahkan ke \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Sudah ada di \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist berhasil dibuat';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Nama playlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Nama playlist wajib diisi';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Ubah nama playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Hapus playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Hapus \"$playlistName\" beserta semua lagunya?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist dihapus';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Nama playlist diperbarui';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist masih kosong';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + di lagu untuk menyimpan yang ingin diunduh nanti';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Folder Loved masih kosong';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love di lagu untuk menyimpan favoritmu';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist masih kosong';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Tekan lama tombol + pada lagu untuk menambahkannya ke sini';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Hapus dari playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Hapus dari folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" dihapus';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" ditambahkan ke Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" dihapus dari Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" ditambahkan ke Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" dihapus dari Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Tambahkan ke Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Hapus dari Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Tambahkan ke Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Hapus dari Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Ubah gambar sampul';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Hapus gambar sampul';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'trek',
|
||||
one: 'trek',
|
||||
);
|
||||
return 'Bagikan $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'trek',
|
||||
one: 'trek',
|
||||
);
|
||||
return 'Konversi $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible =>
|
||||
'Tidak ada trek yang dapat dikonversi dipilih';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Konversi Massal';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'trek',
|
||||
one: 'trek',
|
||||
);
|
||||
return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Mengonversi $current dari $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Berhasil mengonversi $success dari $total trek ke $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Pilih Mode Anda';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Bagaimana Anda ingin menggunakan SpotiFLAC? Anda dapat mengubahnya nanti di Pengaturan.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Pengunduh';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Unduh trek dalam kualitas FLAC lossless';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Simpan musik ke perangkat Anda untuk mendengarkan offline';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Kelola perpustakaan musik lokal Anda';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Streaming trek secara instan tanpa mengunduh';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue secara otomatis menemukan musik baru untuk Anda';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Putar trek apa pun sesuai permintaan dengan kontrol pemutaran';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Anda dapat beralih antar mode kapan saja di Pengaturan.';
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する';
|
||||
|
||||
@@ -1035,6 +1062,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'トラックがありません';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return '$item を読み込めません: 拡張ソースがありません';
|
||||
@@ -1550,6 +1581,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return 'すべてダウンロード ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1911,6 +1947,30 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
||||
|
||||
@@ -2197,9 +2257,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'ディスコグラフィをダウンロード';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'すべてダウンロード';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$albumCount 個のリリースから $count 個のトラック';
|
||||
@@ -2244,6 +2310,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => '選択済みをダウンロード';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -2369,6 +2438,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2848,7 +2928,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2928,4 +3008,242 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'モードを選択';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'ダウンローダー';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 => 'ロスレスFLAC品質でトラックをダウンロード';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 => 'オフライン再生用に音楽をデバイスに保存';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => 'ローカル音楽ライブラリを管理';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'ストリーミング';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 => 'ダウンロードせずにトラックを即座にストリーミング';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 => 'Smart Queueが自動的に新しい音楽を見つけます';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 => '再生コントロールで任意のトラックをオンデマンド再生';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater => '設定からいつでもモードを切り替えられます。';
|
||||
}
|
||||
|
||||
@@ -247,6 +247,33 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1040,6 +1067,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1556,6 +1587,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1922,6 +1958,30 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -2210,9 +2270,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2257,6 +2323,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -2382,6 +2451,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2861,7 +2941,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2941,4 +3021,242 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => '모드 선택';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => '다운로더';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 => '무손실 FLAC 품질로 트랙 다운로드';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 => '오프라인 감상을 위해 기기에 음악 저장';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => '로컬 음악 라이브러리 관리';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => '스트리밍';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 => '다운로드 없이 트랙을 즉시 스트리밍';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 => 'Smart Queue가 자동으로 새로운 음악을 발견합니다';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 => '재생 컨트롤로 원하는 트랙을 온디맨드 재생';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater => '설정에서 언제든지 모드를 전환할 수 있습니다.';
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1923,6 +1959,30 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -2211,9 +2271,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2258,6 +2324,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -2383,6 +2452,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2862,7 +2942,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2942,4 +3022,249 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Kies je modus';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Hoe wil je SpotiFLAC gebruiken? Je kunt dit later altijd wijzigen in Instellingen.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Downloader';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Download nummers in lossless FLAC-kwaliteit';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Sla muziek op je apparaat op om offline te luisteren';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Beheer je lokale muziekbibliotheek';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Stream nummers direct zonder te downloaden';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue ontdekt automatisch nieuwe muziek voor je';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Speel elk nummer op aanvraag af met afspeelbediening';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Je kunt op elk moment wisselen tussen modi in Instellingen.';
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1923,6 +1959,30 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -2211,9 +2271,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2258,6 +2324,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -2383,6 +2452,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2862,7 +2942,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2942,6 +3022,251 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Escolha seu modo';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Como você gostaria de usar o SpotiFLAC? Você pode alterar isso depois nas Configurações.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Downloader';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Baixe faixas em qualidade FLAC lossless';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Salve músicas no seu dispositivo para ouvir offline';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Gerencie sua biblioteca de músicas local';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Transmita faixas instantaneamente sem baixar';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue descobre automaticamente novas músicas para você';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Reproduza qualquer faixa sob demanda com controles de reprodução';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Você pode alternar entre os modos a qualquer momento nas Configurações.';
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
@@ -5822,7 +6147,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -5902,4 +6227,45 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Escolha o seu modo';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Como gostaria de utilizar o SpotiFLAC? Pode alterar isto mais tarde nas Definições.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Transferência';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Transfira faixas em qualidade FLAC sem perdas';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Guarde música no seu dispositivo para ouvir offline';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Faça a gestão da sua biblioteca de música local';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Transmita faixas instantaneamente sem transferir';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue descobre automaticamente novas músicas para si';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Reproduza qualquer faixa a pedido com controlos de reprodução';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Pode alternar entre modos a qualquer momento nas Definições.';
|
||||
}
|
||||
|
||||
@@ -255,6 +255,33 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Попробовать другие сервисы при сбое загрузки';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders =>
|
||||
'Использовать провайдера расширений';
|
||||
@@ -1066,6 +1093,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Треки не найдены';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Невозможно загрузить $item: отсутствует источник расширения';
|
||||
@@ -1587,6 +1618,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Скачать все ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1963,6 +1999,30 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube обеспечивает только звук с потерями(Lossy).';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
||||
|
||||
@@ -1990,17 +2050,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
@@ -2270,9 +2319,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Скачать дискографию';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Скачать всё';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count треков из $albumCount релизов';
|
||||
@@ -2317,6 +2372,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Скачать выбранное';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Добавлено $count треков в очередь';
|
||||
@@ -2453,6 +2511,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Последнее сканирование: $time';
|
||||
@@ -2970,10 +3039,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -3054,4 +3120,249 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Выберите режим';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Загрузчик';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Скачивайте треки в качестве FLAC без потерь';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Сохраняйте музыку на устройство для прослушивания офлайн';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Управляйте своей локальной музыкальной библиотекой';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Стриминг';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Слушайте треки мгновенно без скачивания';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue автоматически подбирает новую музыку для вас';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Воспроизводите любой трек по запросу с элементами управления';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Вы можете переключаться между режимами в любое время в Настройках.';
|
||||
}
|
||||
|
||||
@@ -252,6 +252,33 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'İndirme başarısız olursa diğer hizmetleri dene';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Eklenti sağlayıcılarını kullan';
|
||||
|
||||
@@ -1048,6 +1075,10 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Parça bulunamadı';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return '$item yüklenemedi: Eksik eklenti kaynağı';
|
||||
@@ -1570,6 +1601,11 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return 'Tümünü İndir ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1938,6 +1974,30 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -2226,9 +2286,15 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2273,6 +2339,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return '$count şarkı kuyruğa eklendi';
|
||||
@@ -2398,6 +2467,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2877,7 +2957,7 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2957,4 +3037,248 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Modunuzu Seçin';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'SpotiFLAC\'ı nasıl kullanmak istersiniz? Bunu daha sonra Ayarlar\'dan değiştirebilirsiniz.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'İndirici';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Kayıpsız FLAC kalitesinde parça indirin';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Çevrimdışı dinlemek için müziği cihazınıza kaydedin';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => 'Yerel müzik kütüphanenizi yönetin';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Yayın Akışı';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'İndirmeden parçaları anında yayınlayın';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue sizin için otomatik olarak yeni müzik keşfeder';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'İstediğiniz parçayı oynatma kontrolleriyle çalın';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Ayarlar\'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz.';
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -1923,6 +1959,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -2211,9 +2271,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2258,6 +2324,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -2383,6 +2452,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -2862,7 +2942,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -2942,6 +3022,243 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => '选择您的模式';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => '下载器';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => '管理您的本地音乐库';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => '流媒体';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater => '您可以随时在设置中切换模式。';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
@@ -5795,7 +6112,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -5875,6 +6192,39 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => '选择您的模式';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => '下载器';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => '管理您的本地音乐库';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => '流媒体';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater => '您可以随时在设置中切换模式。';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
|
||||
@@ -8728,7 +9078,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
String get trackReEnrich => 'Re-enrich';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
@@ -8808,4 +9158,37 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => '選擇您的模式';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => '下載器';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 => '以無損 FLAC 品質下載曲目';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 => '將音樂儲存到裝置以供離線收聽';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => '管理您的本機音樂庫';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => '串流';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 => '無需下載即可即時串流曲目';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 => 'Smart Queue 自動為您探索新音樂';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 => '透過播放控制項隨時點播任意曲目';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater => '您可以隨時在設定中切換模式。';
|
||||
}
|
||||
|
||||
@@ -3750,7 +3750,7 @@
|
||||
"@trackSaveLyricsProgress": {
|
||||
"description": "Snackbar while saving lyrics to file"
|
||||
},
|
||||
"trackReEnrich": "Re-enrich Metadata",
|
||||
"trackReEnrich": "Re-enrich",
|
||||
"@trackReEnrich": {
|
||||
"description": "Menu action - re-embed metadata into audio file"
|
||||
},
|
||||
@@ -3868,5 +3868,16 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Wähle deinen Modus",
|
||||
"setupModeSelectionDescription": "Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.",
|
||||
"setupModeDownloaderTitle": "Downloader",
|
||||
"setupModeDownloaderFeature1": "Lade Titel in verlustfreier FLAC-Qualität herunter",
|
||||
"setupModeDownloaderFeature2": "Speichere Musik auf deinem Gerät zum Offline-Hören",
|
||||
"setupModeDownloaderFeature3": "Verwalte deine lokale Musikbibliothek",
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"setupModeStreamingFeature1": "Streame Titel sofort ohne Herunterladen",
|
||||
"setupModeStreamingFeature2": "Smart Queue entdeckt automatisch neue Musik für dich",
|
||||
"setupModeStreamingFeature3": "Spiele jeden Titel auf Abruf mit Wiedergabesteuerung",
|
||||
"setupModeChangeableLater": "Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln."
|
||||
}
|
||||
@@ -175,6 +175,22 @@
|
||||
"@optionsAutoFallback": {"description": "Auto-retry with other services"},
|
||||
"optionsAutoFallbackSubtitle": "Try other services if download fails",
|
||||
"@optionsAutoFallbackSubtitle": {"description": "Subtitle for auto fallback"},
|
||||
"optionsAutoSkipUnavailableTracks": "Auto Skip Unavailable Tracks",
|
||||
"@optionsAutoSkipUnavailableTracks": {"description": "Toggle to skip to the next queue track when current track stream resolution fails"},
|
||||
"optionsAutoSkipUnavailableTracksSubtitleOn": "Automatically skip to the next queue track when a stream cannot be resolved.",
|
||||
"@optionsAutoSkipUnavailableTracksSubtitleOn": {"description": "Subtitle when auto skip on resolve failure is enabled"},
|
||||
"optionsAutoSkipUnavailableTracksSubtitleOff": "Stop on failed track resolution and show an error.",
|
||||
"@optionsAutoSkipUnavailableTracksSubtitleOff": {"description": "Subtitle when auto skip on resolve failure is disabled"},
|
||||
"optionsInteractionMode": "Interaction Mode",
|
||||
"@optionsInteractionMode": {"description": "Tap behavior mode for track lists"},
|
||||
"modeDownloader": "Downloader Mode",
|
||||
"@modeDownloader": {"description": "Interaction mode where taps queue downloads"},
|
||||
"modeDownloaderSubtitle": "Tap tracks to add them to download queue",
|
||||
"@modeDownloaderSubtitle": {"description": "Subtitle for downloader interaction mode"},
|
||||
"modeStreaming": "Streaming Mode",
|
||||
"@modeStreaming": {"description": "Interaction mode where taps start playback"},
|
||||
"modeStreamingSubtitle": "Tap tracks to play instantly",
|
||||
"@modeStreamingSubtitle": {"description": "Subtitle for streaming interaction mode"},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"@optionsUseExtensionProviders": {"description": "Enable extension download providers"},
|
||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
||||
@@ -759,6 +775,8 @@
|
||||
},
|
||||
"errorNoTracksFound": "No tracks found",
|
||||
"@errorNoTracksFound": {"description": "Error - search returned no results"},
|
||||
"errorSeekNotSupported": "Seeking is not supported for this live stream",
|
||||
"@errorSeekNotSupported": {"description": "Error - seek disabled for live decrypted stream"},
|
||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -1151,6 +1169,13 @@
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"playAllCount": "Play All ({count})",
|
||||
"@playAllCount": {
|
||||
"description": "Play all button with count",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||
"@tracksCount": {
|
||||
"description": "Track count display",
|
||||
@@ -1420,6 +1445,37 @@
|
||||
"@qualityNote": {"description": "Note about quality availability"},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
|
||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||
"@youtubeOpusBitrateTitle": {"description": "Title for YouTube Opus bitrate setting"},
|
||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||
"@youtubeMp3BitrateTitle": {"description": "Title for YouTube MP3 bitrate setting"},
|
||||
"youtubeBitrateSubtitle": "{bitrate}kbps ({min}-{max})",
|
||||
"@youtubeBitrateSubtitle": {
|
||||
"description": "Subtitle showing current bitrate and valid range",
|
||||
"placeholders": {
|
||||
"bitrate": {"type": "int"},
|
||||
"min": {"type": "int"},
|
||||
"max": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"youtubeBitrateInputHelp": "Enter custom bitrate ({min}-{max} kbps)",
|
||||
"@youtubeBitrateInputHelp": {
|
||||
"description": "Helper text for manual YouTube bitrate input",
|
||||
"placeholders": {
|
||||
"min": {"type": "int"},
|
||||
"max": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"youtubeBitrateFieldLabel": "Bitrate (kbps)",
|
||||
"@youtubeBitrateFieldLabel": {"description": "Label for YouTube bitrate input field"},
|
||||
"youtubeBitrateValidationError": "Bitrate must be between {min} and {max} kbps",
|
||||
"@youtubeBitrateValidationError": {
|
||||
"description": "Validation error for invalid YouTube bitrate input",
|
||||
"placeholders": {
|
||||
"min": {"type": "int"},
|
||||
"max": {"type": "int"}
|
||||
}
|
||||
},
|
||||
|
||||
"downloadAskBeforeDownload": "Ask Before Download",
|
||||
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
|
||||
@@ -1638,8 +1694,12 @@
|
||||
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {"description": "Button - download artist discography"},
|
||||
"discographyPlay": "Play Discography",
|
||||
"@discographyPlay": {"description": "Button - play artist discography"},
|
||||
"discographyDownloadAll": "Download All",
|
||||
"@discographyDownloadAll": {"description": "Option - download entire discography"},
|
||||
"discographyPlayAll": "Play All",
|
||||
"@discographyPlayAll": {"description": "Option - play entire discography"},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
@@ -1691,6 +1751,8 @@
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {"description": "Button - download selected albums"},
|
||||
"discographyPlaySelected": "Play Selected",
|
||||
"@discographyPlaySelected": {"description": "Button - play selected albums"},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
@@ -1783,6 +1845,13 @@
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||
"@libraryTracksUnit": {
|
||||
"description": "Unit label for tracks count (without the number itself)",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryLastScanned": "Last scanned: {time}",
|
||||
"@libraryLastScanned": {
|
||||
"description": "Last scan time display",
|
||||
@@ -2156,7 +2225,7 @@
|
||||
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||
"trackSaveLyricsProgress": "Saving lyrics...",
|
||||
"@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"},
|
||||
"trackReEnrich": "Re-enrich Metadata",
|
||||
"trackReEnrich": "Re-enrich",
|
||||
"@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"},
|
||||
@@ -2227,5 +2296,204 @@
|
||||
}
|
||||
},
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
|
||||
"@trackConvertFailed": {"description": "Snackbar when conversion fails"},
|
||||
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {"description": "Generic action button - create"},
|
||||
|
||||
"collectionFoldersTitle": "My folders",
|
||||
"@collectionFoldersTitle": {"description": "Library section title for custom folders"},
|
||||
"collectionWishlist": "Wishlist",
|
||||
"@collectionWishlist": {"description": "Custom folder for saved tracks to download later"},
|
||||
"collectionLoved": "Loved",
|
||||
"@collectionLoved": {"description": "Custom folder for favorite tracks"},
|
||||
"collectionPlaylists": "Playlists",
|
||||
"@collectionPlaylists": {"description": "Custom user playlists folder"},
|
||||
"collectionPlaylist": "Playlist",
|
||||
"@collectionPlaylist": {"description": "Single playlist label"},
|
||||
"collectionAddToPlaylist": "Add to playlist",
|
||||
"@collectionAddToPlaylist": {"description": "Action to add a track to user playlist"},
|
||||
"collectionCreatePlaylist": "Create playlist",
|
||||
"@collectionCreatePlaylist": {"description": "Action to create a new playlist"},
|
||||
"collectionNoPlaylistsYet": "No playlists yet",
|
||||
"@collectionNoPlaylistsYet": {"description": "Empty state title when user has no playlists"},
|
||||
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||
"@collectionNoPlaylistsSubtitle": {"description": "Empty state subtitle when user has no playlists"},
|
||||
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||
"@collectionPlaylistTracks": {
|
||||
"description": "Track count label for custom playlists",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||
"@collectionAddedToPlaylist": {
|
||||
"description": "Snackbar after adding track to playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||
"@collectionAlreadyInPlaylist": {
|
||||
"description": "Snackbar when track already exists in playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistCreated": "Playlist created",
|
||||
"@collectionPlaylistCreated": {"description": "Snackbar after creating playlist"},
|
||||
"collectionPlaylistNameHint": "Playlist name",
|
||||
"@collectionPlaylistNameHint": {"description": "Hint text for playlist name input"},
|
||||
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||
"@collectionPlaylistNameRequired": {"description": "Validation error for empty playlist name"},
|
||||
"collectionRenamePlaylist": "Rename playlist",
|
||||
"@collectionRenamePlaylist": {"description": "Action to rename playlist"},
|
||||
"collectionDeletePlaylist": "Delete playlist",
|
||||
"@collectionDeletePlaylist": {"description": "Action to delete playlist"},
|
||||
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||
"@collectionDeletePlaylistMessage": {
|
||||
"description": "Confirmation message for deleting playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistDeleted": "Playlist deleted",
|
||||
"@collectionPlaylistDeleted": {"description": "Snackbar after deleting playlist"},
|
||||
"collectionPlaylistRenamed": "Playlist renamed",
|
||||
"@collectionPlaylistRenamed": {"description": "Snackbar after renaming playlist"},
|
||||
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||
"@collectionWishlistEmptyTitle": {"description": "Wishlist empty state title"},
|
||||
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||
"@collectionWishlistEmptySubtitle": {"description": "Wishlist empty state subtitle"},
|
||||
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||
"@collectionLovedEmptyTitle": {"description": "Loved empty state title"},
|
||||
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||
"@collectionLovedEmptySubtitle": {"description": "Loved empty state subtitle"},
|
||||
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||
"@collectionPlaylistEmptyTitle": {"description": "Playlist empty state title"},
|
||||
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||
"@collectionPlaylistEmptySubtitle": {"description": "Playlist empty state subtitle"},
|
||||
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||
"@collectionRemoveFromPlaylist": {"description": "Tooltip for removing track from playlist"},
|
||||
"collectionRemoveFromFolder": "Remove from folder",
|
||||
"@collectionRemoveFromFolder": {"description": "Tooltip for removing track from wishlist/loved folder"},
|
||||
"collectionRemoved": "\"{trackName}\" removed",
|
||||
"@collectionRemoved": {
|
||||
"description": "Snackbar after removing a track from a collection",
|
||||
"placeholders": {
|
||||
"trackName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||
"@collectionAddedToLoved": {
|
||||
"description": "Snackbar after adding track to loved folder",
|
||||
"placeholders": {
|
||||
"trackName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||
"@collectionRemovedFromLoved": {
|
||||
"description": "Snackbar after removing track from loved folder",
|
||||
"placeholders": {
|
||||
"trackName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||
"@collectionAddedToWishlist": {
|
||||
"description": "Snackbar after adding track to wishlist",
|
||||
"placeholders": {
|
||||
"trackName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||
"@collectionRemovedFromWishlist": {
|
||||
"description": "Snackbar after removing track from wishlist",
|
||||
"placeholders": {
|
||||
"trackName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
|
||||
"trackOptionAddToLoved": "Add to Loved",
|
||||
"@trackOptionAddToLoved": {"description": "Bottom sheet action label - add track to loved folder"},
|
||||
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||
"@trackOptionRemoveFromLoved": {"description": "Bottom sheet action label - remove track from loved folder"},
|
||||
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||
"@trackOptionAddToWishlist": {"description": "Bottom sheet action label - add track to wishlist"},
|
||||
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||
"@trackOptionRemoveFromWishlist": {"description": "Bottom sheet action label - remove track from wishlist"},
|
||||
|
||||
"collectionPlaylistChangeCover": "Change cover image",
|
||||
"@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"},
|
||||
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||
"@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"},
|
||||
|
||||
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionShareCount": {
|
||||
"description": "Share button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"selectionShareNoFiles": "No shareable files found",
|
||||
"@selectionShareNoFiles": {"description": "Snackbar when no selected files exist on disk"},
|
||||
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionConvertCount": {
|
||||
"description": "Convert button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||
"@selectionConvertNoConvertible": {"description": "Snackbar when no selected tracks support conversion"},
|
||||
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||
"@selectionBatchConvertConfirmTitle": {"description": "Confirmation dialog title for batch conversion"},
|
||||
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||
"@selectionBatchConvertConfirmMessage": {
|
||||
"description": "Confirmation dialog message for batch conversion",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"format": {"type": "String"},
|
||||
"bitrate": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||
"@selectionBatchConvertProgress": {
|
||||
"description": "Snackbar during batch conversion progress",
|
||||
"placeholders": {
|
||||
"current": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||
"@selectionBatchConvertSuccess": {
|
||||
"description": "Snackbar after batch conversion completes",
|
||||
"placeholders": {
|
||||
"success": {"type": "int"},
|
||||
"total": {"type": "int"},
|
||||
"format": {"type": "String"}
|
||||
}
|
||||
},
|
||||
|
||||
"setupModeSelectionTitle": "Choose Your Mode",
|
||||
"@setupModeSelectionTitle": {"description": "Title for mode selection step in setup wizard"},
|
||||
"setupModeSelectionDescription": "How would you like to use SpotiFLAC? You can always change this later in Settings.",
|
||||
"@setupModeSelectionDescription": {"description": "Description for mode selection step"},
|
||||
"setupModeDownloaderTitle": "Downloader",
|
||||
"@setupModeDownloaderTitle": {"description": "Title for downloader mode option"},
|
||||
"setupModeDownloaderFeature1": "Download tracks in lossless FLAC quality",
|
||||
"@setupModeDownloaderFeature1": {"description": "Downloader mode feature 1"},
|
||||
"setupModeDownloaderFeature2": "Save music to your device for offline listening",
|
||||
"@setupModeDownloaderFeature2": {"description": "Downloader mode feature 2"},
|
||||
"setupModeDownloaderFeature3": "Manage your local music library",
|
||||
"@setupModeDownloaderFeature3": {"description": "Downloader mode feature 3"},
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"@setupModeStreamingTitle": {"description": "Title for streaming mode option"},
|
||||
"setupModeStreamingFeature1": "Stream tracks instantly without downloading",
|
||||
"@setupModeStreamingFeature1": {"description": "Streaming mode feature 1"},
|
||||
"setupModeStreamingFeature2": "Smart Queue auto-discovers new music for you",
|
||||
"@setupModeStreamingFeature2": {"description": "Streaming mode feature 2"},
|
||||
"setupModeStreamingFeature3": "Play any track on demand with playback controls",
|
||||
"@setupModeStreamingFeature3": {"description": "Streaming mode feature 3"},
|
||||
"setupModeChangeableLater": "You can switch between modes anytime in Settings.",
|
||||
"@setupModeChangeableLater": {"description": "Hint that mode can be changed later"}
|
||||
}
|
||||
|
||||
@@ -2565,5 +2565,16 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Elige tu modo",
|
||||
"setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.",
|
||||
"setupModeDownloaderTitle": "Descargador",
|
||||
"setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida",
|
||||
"setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión",
|
||||
"setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local",
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"setupModeStreamingFeature1": "Transmite pistas al instante sin descargar",
|
||||
"setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti",
|
||||
"setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción",
|
||||
"setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes."
|
||||
}
|
||||
@@ -3750,7 +3750,7 @@
|
||||
"@trackSaveLyricsProgress": {
|
||||
"description": "Snackbar while saving lyrics to file"
|
||||
},
|
||||
"trackReEnrich": "Re-enrich Metadata",
|
||||
"trackReEnrich": "Re-enrich",
|
||||
"@trackReEnrich": {
|
||||
"description": "Menu action - re-embed metadata into audio file"
|
||||
},
|
||||
@@ -3868,5 +3868,16 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Elige tu modo",
|
||||
"setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.",
|
||||
"setupModeDownloaderTitle": "Descargador",
|
||||
"setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida",
|
||||
"setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión",
|
||||
"setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local",
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"setupModeStreamingFeature1": "Transmite pistas al instante sin descargar",
|
||||
"setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti",
|
||||
"setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción",
|
||||
"setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes."
|
||||
}
|
||||