Compare commits

..

45 Commits

Author SHA1 Message Date
zarzet 4e530ffbc3 chore: bump app version to v4.2.1 2026-04-04 21:48:19 +07:00
zarzet 14f6776fdc fix: remove stale audio service manifest entries causing crashes on some devices 2026-04-04 21:40:46 +07:00
zarzet da1c6e9171 fix: harden gomobile extension bindings and m4a cover retention 2026-04-04 21:30:11 +07:00
zarzet 9c3e934395 fix: preserve local convert format and library entries 2026-04-04 21:29:20 +07:00
zarzet 15d2c3b465 feat: enrich composer and track totals metadata 2026-04-04 18:50:05 +07:00
zarzet 8aaa6d5cbe fix: preserve embedded metadata details 2026-04-04 18:06:52 +07:00
zarzet 9158d0228d ci: pin iOS release builds to macOS 15 and Xcode 26.1.1 2026-04-04 15:53:46 +07:00
zarzet 2bbcda3320 fix: patch device_info_plus iOS build for older Xcode SDKs 2026-04-04 15:49:34 +07:00
zarzet a7622676dd feat: add additional search/metadata API with separate rate limiting 2026-04-04 13:54:55 +07:00
zarzet 5779f910a2 perf: incremental download queue lookup updates, async cover cleanup, and native JSON decoding on iOS
- Embed DownloadQueueLookup into DownloadQueueState; add updatedForIndices() for O(changed) incremental updates during frequent progress ticks instead of full O(n) rebuild
- downloadQueueLookupProvider now reads pre-computed lookup from state directly
- Replace sync file deletion in DownloadedEmbeddedCoverResolver with unawaited async cleanup to avoid blocking the main thread
- Parse JSON payloads on iOS native side (parseJsonPayload) so event sinks and method channel responses return native objects, avoiding redundant Dart-side JSON decode
- Use .cast<String, dynamic>() instead of Map.from() in _decodeMapResult for zero-copy map handling
2026-04-03 23:03:11 +07:00
zarzet 030f44a444 perf: reduce UI jank via memoization, compute isolates, SQL-backed playlist picker, and viewport-aware image caching
- Move explore JSON decode/encode to compute() isolate to avoid blocking main thread
- Memoize search sort results (artists/albums/playlists/tracks) in HomeTab; invalidate on new query
- Extract _DownloadedOrRemoteCover StatefulWidget with proper embedded-cover lifecycle management
- Replace O(playlists x tracks) in-memory playlist picker check with SQL loadPlaylistPickerSummaries query
- Add FutureProvider.family (libraryPlaylistPickerSummariesProvider) invalidated on all playlist mutations
- Memoize _buildQueueHistoryStats, localPathMatchKeys, and localSingleItems in QueueTab
- Add coverCacheWidthForViewport util; apply memCacheWidth/cacheWidth based on real DPR across all album/playlist/track screens
- Convert sync file ops in TrackMetadataScreen to async; use mtime+size as validation token
- Fetch Deezer album nb_tracks in parallel via fetchAlbumTrackCounts
2026-04-03 22:31:04 +07:00
zarzet 1248270fb4 fix: route Qobuz API calls through authenticated gateway to resolve 401 errors 2026-04-03 21:35:47 +07:00
zarzet 413e3b0686 refactor: consolidate FLAC/MP3/Opus metadata embedding into unified _embedMetadataToFile 2026-04-03 03:22:33 +07:00
zarzet ac711efadc feat: add skipLyrics manifest field for extensions to opt out of lyrics fetching 2026-04-03 03:14:51 +07:00
zarzet 59f2fe880a chore: remove redundant comments and update donor list 2026-04-03 02:21:40 +07:00
zarzet 355f2eba2a fix: resolve missing track/disc numbers from search downloads and suppress FFmpeg log noise
- Tidal: use actual API track_number/disc_number when request values are 0
  (fixes search/popular downloads having no track position in metadata)
- Extension enrichment: copy TrackNumber/DiscNumber back from enriched results
- Extension fallback download: add request metadata fallback for non-source
  extensions (Album, AlbumArtist, ReleaseDate, ISRC, TrackNumber, DiscNumber)
- FFmpeg: add -v error -hide_banner to all commands (embed, convert, CUE split)
  to eliminate banner, build config, and full metadata/lyrics dump in logcat
- ebur128: add framelog=quiet to suppress per-frame loudness measurements
  while keeping the summary needed for ReplayGain parsing
- Track metadata screen: separate embedded lyrics check from online fetch,
  show file-only state with manual online fetch button
2026-04-03 00:56:09 +07:00
zarzet f2f45fa31d fix: improve extension runtime safety, HTTP response URL, SongLink parsing, and recommended service for extensions
- Add 'url' field (final URL after redirects) to all extension HTTP responses and fix fetch polyfill to return final URL instead of original request URL
- Fix RunWithTimeout race condition: increase force-timeout from 1s to 60s to prevent concurrent VM access crashes, add nil guards
- Use lockReadyVM() for thread-safe VM access in GetPlaylistWithExtensionJSON and InvokeAction
- Handle mixed JSON types (string, null, array) in SongLink resolve API SongUrls field
- Fix recommended download service not showing for extension-based searches in download picker
2026-04-02 23:16:37 +07:00
zarzet 042937a8ed fix: resolve label and copyright from file metadata on info screen
The info screen was not reading label/copyright from the actual file metadata, so these fields were always empty for local library items and download history items that lacked them in-memory. Now _resolveAudioMetadata() extracts and displays them without requiring a manual save first.
2026-04-02 19:44:37 +07:00
zarzet 674e9af3d0 fix: validate ISRC in track metadata screen to prevent ID leakage
Sanitize the isrc getter to only return valid ISRC codes (12-char format per ISO 3901). Invalid values such as Spotify/Deezer/Tidal IDs that may leak into the ISRC field are now silently discarded, preventing them from being displayed or embedded into file tags.
2026-04-02 15:29:42 +07:00
zarzet 76d50fab3a fix: correct track/disc defaults, forward extension metadata, and fix service ID display
- Default track/disc number to 0 (unknown) instead of 1, letting the
  backend use the service-provided value or skip the field entirely
- Add releaseDate to ExploreItem so explore downloads carry release info
- Pass discNumber and releaseDate from extension album/playlist tracks
- Fix isDeezer detection using service field instead of substring match
- Add _displayServiceTrackId() to properly strip prefixes for all services
2026-04-02 15:13:11 +07:00
zarzet 81e25d7dab chore: bump version to 4.2.0 (build 121) 2026-04-02 03:20:56 +07:00
zarzet 26f26f792a feat: add ReplayGain scanning, APEv2 tag support, and fix metadata bugs
ReplayGain (track + album):
- Scan track loudness via FFmpeg ebur128 filter (-18 LUFS reference)
- Duration-weighted power-mean for album gain computation
- Support for FLAC (native Vorbis), MP3 (ID3v2 TXXX), Opus, M4A
- Album RG auto-finalizes when all album tracks complete
- Retryable gate: blocks finalization while failed/skipped items exist
- SAF support: lossy album RG writes via temp file + writeTempToSaf
- New embedReplayGain setting (off by default) with UI toggle

APEv2 tag support:
- Full APEv2 reader/writer with header+items+footer format
- Merge-based editing with override keys for explicit deletions
- Binary cover art embedding (Cover Art (Front) item)
- Library scanner support for .ape/.wv/.mpc files
- ReplayGain fields in APE read/write/edit pipeline

Bug fixes (26):
- setArtistComments wiping fields on empty string value
- APEv2 rewrite corrupting files with ID3v1 trailer
- APE edit replacing entire tag instead of merging
- ReplayGain lost on manual MP3/Opus/M4A metadata edit
- Editor metadata save losing custom tags (preserveMetadata)
- Album RG accumulator not cleaned on queue mutation
- Album gain using unweighted mean instead of power-mean
- writeAlbumReplayGainTags return value silently ignored
- SAF album RG writing to deleted temp path
- Cancelled tracks polluting album gain computation
- APE ReplayGain not wired end-to-end
- APE field deletion not working in merge
- APE cover edit was a no-op
- Album RG duplicate entries on retry
- APE apeKeysFromFields missing track/disc/lyrics mappings
- Album RG entries purged by removeItem before computation
- FFmpeg converters discarding empty metadata values
- _appendVorbisArtistEntries skipping empty value (null vs empty)
- Album RG write-back fails for SAF lossy files
- Album RG partial finalization on failed tracks
- FLAC ClearEmpty flag destroying tags on partial callers
- clearCompleted not retriggering album RG checks
- ReadFileMetadata MP3/Ogg missing label and copyright
- Cover embed on CUE split destroying split artist tags
- Album RG gain format inconsistent (missing + prefix)
- FLAC reader/editor missing tag aliases (ALBUMARTIST, LABEL, etc.)
- dart:math log shadowed by logger.dart export
2026-04-02 03:15:01 +07:00
zarzet 4dfa76b49e fix: remove deleted local library item from provider state after file deletion
When deleting a non-CUE local library track from the metadata screen,
only the file was removed but the library database entry and provider
state were left untouched, causing the track to persist in the library UI.
Now calls removeItem() on localLibraryProvider after deleteFile().
2026-04-01 21:04:42 +07:00
zarzet f511f30ad0 feat: add resolve API with SongLink fallback, fix multi-artist tags (#288), and cleanup
Resolve API (api.zarz.moe):
- Refactor songlink.go: Spotify URLs use resolve API, non-Spotify uses SongLink API
- Add SongLink fallback when resolve API fails for Spotify (two-layer resilience)
- Remove dead code: page parser, XOR-obfuscated keys, legacy helpers

Multi-artist tag fix (#288):
- Add RewriteSplitArtistTags() in Go to rewrite ARTIST/ALBUMARTIST as split Vorbis comments
- Wire method channel handler in Android (MainActivity.kt) and iOS (AppDelegate.swift)
- Add PlatformBridge.rewriteSplitArtistTags() in Dart
- Call native FLAC rewriter after FFmpeg embed when split_vorbis mode is active
- Extract deezerTrackArtistDisplay() helper to use Contributors in album/playlist tracks

Code cleanup:
- Remove unused imports, dead code, and redundant comments across Go and Dart
- Fix build: remove stale getQobuzDebugKey() reference in deezer_download.go
2026-04-01 02:49:19 +07:00
zarzet a1aa1319ce feat: add separate filename format for singles and EPs (#271)
Add singleFilenameFormat setting so singles/EPs can use a different filename template than albums. The format editor is reused with custom title/description. Dart selects the correct format based on track.isSingle before passing to Go, so no backend changes needed. Also fix isSingle getter to include all EPs regardless of totalTracks. Closes #271
2026-03-31 18:55:48 +07:00
zarzet c936bd7dd0 fix: match system navigation bar color with app theme
Set systemNavigationBarColor to surfaceContainer (matching the in-app
NavigationBar) via AppBarTheme.systemOverlayStyle. Handles light, dark,
AMOLED and dynamic color schemes automatically.

Closes zarzet/SpotiFLAC-Mobile#284
2026-03-31 18:36:28 +07:00
zarzet 3a60ea2f4e feat: add field selection dialog for bulk re-enrich metadata
Add a bottom sheet dialog that lets users choose which metadata field
groups to update during bulk re-enrich (cover, lyrics, album/album
artist, track/disc number, date/ISRC, genre/label/copyright).

Backend (Go):
- Filter FLAC Metadata struct and FFmpeg metadata map by selected
  update_fields so non-selected groups preserve existing file values
- Guard Deezer extended metadata fetch with shouldUpdateField(extra)
- Title/Artist are never overwritten by re-enrich (search keys only)
- enrichedMeta response only includes selected field groups

Frontend (Dart):
- New re_enrich_field_dialog.dart bottom sheet with checkboxes
- FFmpegService embed methods gain preserveMetadata param that uses
  -map_metadata 0 instead of -1 to preserve non-selected tags
- Hide selection overlay/bar before showing dialog, restore on cancel
- Fix setState-after-dispose guard in cancel branches

Cleanup:
- Remove dead code in library_tracks_folder_screen.dart
- Fix use_build_context_synchronously in main_shell.dart
- Suppress false-positive use_null_aware_elements lints
- Update l10n label from 'Title, Artist, Album' to 'Album, Album Artist'
2026-03-31 18:21:45 +07:00
zarzet 7dba938299 fix: prefer local file for cover/lyrics save and update build dependencies
- Cover art: extract from downloaded file first, fall back to URL download
- Lyrics: check embedded lyrics/sidecar LRC before fetching online
- Add audioFilePath param to FetchAndSaveLyrics (Go, Kotlin, Swift, Dart)
- Handle SAF content:// URIs for lyrics extraction in Kotlin bridge
- Update Go 1.25.7 -> 1.25.8, Gradle 9.3.1 -> 9.4.1, Kotlin 2.2.21 -> 2.3.20
- Update NDK r27d -> r28b, Flutter FVM 3.41.4 -> 3.41.5
- Upgrade all Flutter and Go module dependencies to latest
2026-03-31 17:25:30 +07:00
zarzet 93e77aeb84 refactor: remove legacy API clients, Yoinkify fallback, and unused lyrics provider
- Delete dead metadata client and extract shared types to metadata_types.go
- Remove Yoinkify download fallback from Deezer, use MusicDL only
- Clean up retired settings fields and metadataSource
- Remove dead l10n keys for retired provider
- Add migration to strip retired provider from existing users' lyrics config
2026-03-30 23:26:37 +07:00
zarzet dd750b95ca chore: bump version to 4.1.3 (build 120) 2026-03-30 18:25:42 +07:00
zarzet e42e44f28b fix: Samsung SAF library scan, Qobuz album cover, M4A metadata save and log improvements
- Fix M4A/ALAC scan silently failing on Samsung by adding proper fallback
  to scanFromFilename when ReadM4ATags fails (consistent with MP3/FLAC/Ogg)
- Propagate displayNameHint to all format scanners so fd numbers (214, 207)
  no longer appear as track names when /proc/self/fd/ paths are used
- Cache /proc/self/fd/ readability in Kotlin to skip failed attempts after
  first failure, reducing error log noise and improving scan speed on Samsung
- Fix Qobuz download returning wrong album cover when track exists on
  multiple albums by preferring req.CoverURL over API default
- Fix FFmpeg M4A metadata save failing with 'codec not currently supported
  in container' by forcing mp4 muxer instead of ipod when cover art present
- Clean up FLAC SAF temp file after metadata write-back (was leaking)
- Update LRC lyrics tag to credit Paxsenix API
- Remove log message truncation, defer to UI preview truncation instead
2026-03-30 18:12:20 +07:00
zarzet 67daefdf60 feat: add artist tag mode setting with split Vorbis support and improve library scan progress
- Add artist_tag_mode setting (joined / split_vorbis) for FLAC/Opus multi-artist tags
- Split 'Artist A, Artist B' into separate ARTIST= Vorbis comments when split mode is enabled
- Join repeated ARTIST/ALBUMARTIST Vorbis comments when reading metadata
- Propagate artistTagMode through download pipeline, re-enrich, and metadata editor
- Improve library scan progress: separate polling intervals, finalizing state, indeterminate progress
- Add initial progress snapshot on library scan stream connect
- Use req.ArtistName consistently for Qobuz downloads instead of track.Performer.Name
- Add l10n keys for artist tag mode, library files unit, and scan finalizing status
2026-03-30 12:38:42 +07:00
zarzet fabaf0a3ff feat: add stable cover cache keys, Qobuz album-search fallback, metadata filters and extended sort options
- Introduce coverCacheKey parameter through Go backend and Kotlin bridge for stable SAF cover caching
- Add MetadataFromFilename flag to skip filename-only metadata and retry via temp-file copy
- Add Qobuz album-search fallback between API search and store scraping
- Extract buildReEnrichFFmpegMetadata to skip empty metadata fields
- Add metadata completeness filter (complete, missing year/genre/album artist)
- Add sort modes: artist, album, release date, genre (asc/desc)
- Prune stale library cover cache files after full scan
- Skip empty values and zero track/disc numbers in FFmpeg metadata
- Add new l10n keys for metadata filter and sort options
2026-03-30 11:41:11 +07:00
zarzet fb90c73f42 fix: use Tidal quality options as fallback instead of DEFAULT for extensions 2026-03-29 18:57:13 +07:00
zarzet c6cf65f075 fix: normalize DEFAULT quality to prevent Tidal/Qobuz API failures 2026-03-29 18:49:57 +07:00
zarzet 25de009ebc feat: replace batch operation snackbars with progress dialog
Add reusable BatchProgressDialog widget with circular/linear progress
indicators, cancel support, and track detail display. Uses ValueNotifier
pattern to communicate progress from caller to dialog across navigator
routes.
2026-03-29 18:04:38 +07:00
zarzet 8918d74bb5 refactor: extract and improve ReEnrich track selection with scoring-based matching 2026-03-29 17:45:51 +07:00
zarzet f9de8d45d9 fix: add attached_pic disposition to ALAC cover art embedding 2026-03-29 17:41:43 +07:00
zarzet 48eef0853d i18n: extract hardcoded strings into l10n keys
Move hardcoded UI strings across multiple screens and the notification
service into ARB-backed l10n keys so they can be translated via Crowdin.
Adds 62 new keys covering sort labels, dialog copy, metadata error
snackbars, folder-picker errors, home-tab error states, extensions home
feed selector, and all notification titles/bodies. NotificationService
now caches an AppLocalizations instance (injected from MainShell via
didChangeDependencies) and falls back to English literals when no locale
is available.
2026-03-29 17:02:12 +07:00
zarzet fc70a912bf refactor: route spotify URLs through extensions 2026-03-29 16:35:16 +07:00
zarzet cd3e5b4b28 chore: bump version to 4.1.2+119 2026-03-29 15:40:24 +07:00
zarzet 482ca82eb4 feat: improve track matching 2026-03-29 15:34:44 +07:00
zarzet 6d87ae5484 feat: add haptic feedback when swiping library tabs 2026-03-29 01:56:22 +07:00
zarzet bd3e2b999b feat: add play button to playlist/library track tiles
Show a play IconButton (matching local album style) next to the
more-options button when a track has a local file available.
Uses PlaybackController.playTrackList to resolve and open the file.
2026-03-29 01:54:27 +07:00
zarzet 186196e12b fix: use START_NOT_STICKY for DownloadService to prevent auto-restart
Prevents Android from automatically recreating the download service
after it is killed, avoiding duplicate or orphaned download processes.
2026-03-29 01:37:24 +07:00
114 changed files with 3161 additions and 8980 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|---|---|---|---|---|
| [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) | [Monochrome](https://monochrome.tf) |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
---
-4
View File
@@ -20,10 +20,6 @@ android {
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
buildFeatures {
buildConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
-14
View File
@@ -86,20 +86,6 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="music.youtube.com" />
</intent-filter>
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="spotify-callback" />
</intent-filter>
</activity>
<!-- Download Service -->
@@ -4,7 +4,6 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
@@ -308,40 +307,8 @@ class MainActivity: FlutterFragmentActivity() {
}
}
private fun forceFilenameExt(name: String, outputExt: String): String {
val normalizedExt = normalizeExt(outputExt)
if (normalizedExt.isBlank()) return sanitizeFilename(name)
val safeName = sanitizeFilename(name)
val lower = safeName.lowercase(Locale.ROOT)
val knownExts = listOf(".flac", ".m4a", ".mp3", ".opus", ".lrc")
for (knownExt in knownExts) {
if (lower.endsWith(knownExt)) {
return safeName.dropLast(knownExt.length) + normalizedExt
}
}
return safeName + normalizedExt
}
private fun sanitizeFilename(name: String): String {
var sanitized = name
.replace("/", " ")
.replace(Regex("[\\\\:*?\"<>|]"), " ")
.filter { ch ->
val code = ch.code
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
code == 0x7F ||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
}
.trim()
.trim('.', ' ')
sanitized = sanitized
.replace(Regex("\\s+"), " ")
.replace(Regex("_+"), "_")
.trim('_', ' ')
return if (sanitized.isBlank()) "Unknown" else sanitized
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
}
private fun sanitizeRelativeDir(relativeDir: String): String {
@@ -401,43 +368,6 @@ class MainActivity: FlutterFragmentActivity() {
return current
}
private fun createOrReuseDocumentFile(
parent: DocumentFile,
mimeType: String,
fileName: String
): DocumentFile? {
val safeFileName = sanitizeFilename(fileName)
if (safeFileName.isBlank()) return null
synchronized(safDirLock) {
val existing = parent.findFile(safeFileName)
if (existing != null && existing.isFile) {
return existing
}
val created = parent.createFile(mimeType, safeFileName) ?: return null
val createdName = created.name ?: safeFileName
if (createdName == safeFileName) {
return created
}
// SAF can auto-rename to "name (1)" when another writer wins the race
// between findFile() and createFile(). Prefer the exact sibling if it
// appeared, and discard the duplicate document we just created.
val winner = parent.findFile(safeFileName)
if (winner != null && winner.isFile) {
if (winner.uri != created.uri) {
try {
created.delete()
} catch (_: Exception) {}
}
return winner
}
return created
}
}
private fun resetSafScanProgress() {
synchronized(safScanLock) {
safScanProgress = SafScanProgress()
@@ -669,12 +599,12 @@ class MainActivity: FlutterFragmentActivity() {
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
if (provided.isNotBlank()) return sanitizeFilename(provided)
val trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "")
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
return forceFilenameExt(baseName, outputExt)
return sanitizeFilename(baseName) + outputExt
}
private fun errorJson(message: String): String {
@@ -988,7 +918,8 @@ class MainActivity: FlutterFragmentActivity() {
val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory")
var document = createOrReuseDocumentFile(targetDir, mimeType, fileName)
val existingFile = targetDir.findFile(fileName)
val document = existingFile ?: targetDir.createFile(mimeType, fileName)
?: return errorJson("Failed to create SAF file")
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
@@ -1013,34 +944,16 @@ class MainActivity: FlutterFragmentActivity() {
) {
try {
val srcFile = java.io.File(goFilePath)
if (!srcFile.exists() || srcFile.length() <= 0) {
throw IllegalStateException("extension output missing or empty: $goFilePath")
}
val actualExt = normalizeExt(srcFile.extension)
if (actualExt.isNotBlank() && actualExt != outputExt) {
val actualFileName = buildSafFileName(req, actualExt)
val actualMimeType = mimeTypeForExt(actualExt)
val replacement = createOrReuseDocumentFile(
targetDir,
actualMimeType,
actualFileName,
)
?: throw IllegalStateException("failed to create SAF output with actual extension")
if (replacement.uri != document.uri) {
document.delete()
document = replacement
if (srcFile.exists() && srcFile.length() > 0) {
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
}
srcFile.delete()
}
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: throw IllegalStateException("failed to open SAF output stream")
srcFile.delete()
} catch (e: Exception) {
document.delete()
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}")
return errorJson("Failed to copy extension output to SAF: ${e.message}")
}
}
respObj.put("file_path", document.uri.toString())
@@ -2018,54 +1931,9 @@ class MainActivity: FlutterFragmentActivity() {
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
override fun shouldHandleDeeplinking(): Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleExtensionOAuthIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleExtensionOAuthIntent(intent)
}
/**
* Deliver Spotify (or other) OAuth authorization code to the extension runtime
* and run its token exchange (e.g. completeSpotifyLogin). State must be the extension id.
*/
private fun handleExtensionOAuthIntent(intent: Intent?) {
val uri = intent?.data ?: return
if (!uri.scheme.equals("spotiflac", ignoreCase = true)) {
return
}
val host = (uri.host ?: "").lowercase(Locale.US)
val path = (uri.path ?: "").lowercase(Locale.US)
val isCallback =
host == "callback" ||
host == "spotify-callback" ||
path.contains("callback")
if (!isCallback) {
return
}
val code = uri.getQueryParameter("code")?.trim().orEmpty()
if (code.isEmpty()) {
return
}
val extId = uri.getQueryParameter("state")?.trim().orEmpty()
if (extId.isEmpty()) {
android.util.Log.w("SpotiFLAC", "Extension OAuth redirect missing state (extension id)")
return
}
intent.data = null
scope.launch(Dispatchers.IO) {
try {
Gobackend.setExtensionAuthCodeByID(extId, code)
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
}
}
}
override fun onDestroy() {
@@ -2081,7 +1949,6 @@ class MainActivity: FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Gobackend.setAppVersion(BuildConfig.VERSION_NAME)
// Always-enabled back callback to ensure back presses reach Flutter.
// Nested tab navigators can incorrectly set frameworkHandlesBack(false),
@@ -2266,6 +2133,7 @@ class MainActivity: FlutterFragmentActivity() {
result.error("saf_pending", "SAF picker already active", null)
return@launch
}
pendingSafTreeResult = result
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
@@ -2273,24 +2141,7 @@ class MainActivity: FlutterFragmentActivity() {
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
)
val resolver = intent.resolveActivity(packageManager)
if (resolver == null) {
result.error("saf_unavailable", "No folder picker available on this device", null)
return@launch
}
pendingSafTreeResult = result
try {
android.util.Log.i("SpotiFLAC", "Launching SAF picker via $resolver")
safTreeLauncher.launch(intent)
} catch (e: Exception) {
pendingSafTreeResult = null
android.util.Log.e("SpotiFLAC", "Failed to launch SAF picker: ${e.message}", e)
result.error(
"saf_launch_failed",
e.message ?: "Failed to launch folder picker",
null
)
}
safTreeLauncher.launch(intent)
}
"safExists" -> {
val uriStr = call.argument<String>("uri") ?: ""
@@ -2365,8 +2216,7 @@ class MainActivity: FlutterFragmentActivity() {
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
val existing = dir.findFile(fileName)
val createdNew = existing == null
val doc = createOrReuseDocumentFile(dir, mimeType, fileName)
?: return@withContext null
val doc = existing ?: dir.createFile(mimeType, fileName) ?: return@withContext null
if (!writeUriFromPath(doc.uri, srcPath)) {
if (createdNew) {
doc.delete()
@@ -2864,6 +2714,16 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"searchDeezerAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"searchTidalAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -3105,13 +2965,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"setDownloadFallbackExtensionIds" -> {
val extensionIdsJson = call.argument<String>("extension_ids") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setExtensionFallbackProviderIDsJSON(extensionIdsJson)
}
result.success(null)
}
"setMetadataProviderPriority" -> {
val priorityJson = call.argument<String>("priority") ?: "[]"
withContext(Dispatchers.IO) {
+1 -1
View File
@@ -1,2 +1,2 @@
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+4 -4
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.3.1",
"versionDate": "2026-04-14",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.3.1/SpotiFLAC-v4.3.1-ios-unsigned.ipa",
"version": "3.9.0",
"versionDate": "2026-03-25",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 34773598
"size": 34477323
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 70 KiB

-14
View File
@@ -10,7 +10,6 @@ import (
var ErrDownloadCancelled = errors.New("download cancelled")
type cancelEntry struct {
ctx context.Context
cancel context.CancelFunc
canceled bool
}
@@ -28,21 +27,8 @@ func initDownloadCancel(itemID string) context.Context {
cancelMu.Lock()
defer cancelMu.Unlock()
if entry, ok := cancelMap[itemID]; ok {
if entry.ctx == nil {
ctx, cancel := context.WithCancel(context.Background())
entry.ctx = ctx
entry.cancel = cancel
if entry.canceled && entry.cancel != nil {
entry.cancel()
}
}
return entry.ctx
}
ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{
ctx: ctx,
cancel: cancel,
canceled: false,
}
+446
View File
@@ -0,0 +1,446 @@
package gobackend
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr"
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 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 resolveDeezerTrackURL(req DownloadRequest) (string, error) {
deezerID := strings.TrimSpace(req.DeezerID)
if deezerID == "" {
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
deezerID = strings.TrimSpace(prefixed)
}
}
if deezerID != "" {
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
// Don't reject direct IDs from request payload — they're presumably correct.
}
return trackURL, nil
}
spotifyID := strings.TrimSpace(req.SpotifyID)
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
songlink := NewSongLinkClient()
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
if err == nil && availability.Deezer && availability.DeezerURL != "" {
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
// Fall through to ISRC search instead of using wrong track.
} else {
return availability.DeezerURL, nil
}
} else {
return availability.DeezerURL, nil
}
}
}
isrc := strings.TrimSpace(req.ISRC)
if isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
if err == nil && track != nil {
resolvedID := songLinkExtractDeezerTrackID(track)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
}
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
}
}
}
return "", fmt.Errorf("could not resolve Deezer track URL")
}
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
if err != nil {
return nil // Can't verify — don't block the download.
}
resolved := resolvedTrackInfo{
Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists,
ISRC: trackResp.Track.ISRC,
Duration: trackResp.Track.DurationMS / 1000,
SkipNameVerification: skipNameVerification,
}
if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
}
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
return nil
}
type deezerMusicDLRequest struct {
Platform string `json:"platform"`
URL string `json:"url"`
}
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
payload := deezerMusicDLRequest{
Platform: "deezer",
URL: deezerTrackURL,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("MusicDL request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
}
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
return "", fmt.Errorf("MusicDL error: %s", errMsg)
}
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
}
if data, ok := raw["data"].(map[string]any); ok {
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
}
}
return "", fmt.Errorf("no download URL found in MusicDL response")
}
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
if err != nil {
return err
}
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return err
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush output: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close output: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
return nil
}
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
deezerClient := GetDeezerClient()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
var outputPath string
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
}
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
coverURL := req.CoverURL
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
embedLyrics = false
}
parallelResult = FetchCoverAndLyricsParallel(
coverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
embedLyrics,
int64(req.DurationMS),
)
}()
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
if deezerURLErr != nil {
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed: could not resolve Deezer URL: %w",
deezerURLErr,
)
}
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
downloadErr := deezerClient.DownloadFromMusicDL(
deezerTrackURL,
outputPath,
req.OutputFD,
req.ItemID,
)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
return DeezerDownloadResult{}, ErrDownloadCancelled
}
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed via MusicDL: %w",
downloadErr,
)
}
<-parallelDone
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
TotalDiscs: req.TotalDiscs,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Composer: req.Composer,
}
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
}
+141 -347
View File
@@ -5,16 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/dop251/goja"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func CheckAvailability(spotifyID, isrc string) (string, error) {
@@ -37,113 +33,6 @@ func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
}
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
type musicBrainzTag struct {
Count int `json:"count"`
Name string `json:"name"`
}
type musicBrainzRecordingResponse struct {
Recordings []struct {
Tags []musicBrainzTag `json:"tags"`
} `json:"recordings"`
}
func formatMusicBrainzGenre(tags []musicBrainzTag) string {
if len(tags) == 0 {
return ""
}
caser := cases.Title(language.English)
seen := make(map[string]struct{}, len(tags))
maxCount := -1
bestTag := ""
for _, tag := range tags {
name := strings.TrimSpace(tag.Name)
if name == "" {
continue
}
key := strings.ToLower(name)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
formatted := caser.String(name)
if tag.Count > maxCount {
maxCount = tag.Count
bestTag = formatted
}
}
return bestTag
}
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
if normalizedISRC == "" {
return "", fmt.Errorf("no ISRC provided")
}
client := NewMetadataHTTPClient(10 * time.Second)
query := fmt.Sprintf("isrc:%s", normalizedISRC)
reqURL := fmt.Sprintf(
"%s/recording?query=%s&fmt=json&inc=tags",
musicBrainzAPIBase,
url.QueryEscape(query),
)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", getRandomUserAgent())
var resp *http.Response
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
resp, lastErr = client.Do(req)
if lastErr == nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close()
}
if attempt < 2 {
time.Sleep(2 * time.Second)
}
}
if lastErr != nil {
return "", lastErr
}
if resp == nil {
return "", fmt.Errorf("MusicBrainz request failed without response")
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
}
defer resp.Body.Close()
var payload musicBrainzRecordingResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", err
}
if len(payload.Recordings) == 0 {
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
}
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
if genre == "" {
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
}
return genre, nil
}
type DownloadRequest struct {
ISRC string `json:"isrc"`
Service string `json:"service"`
@@ -185,34 +74,33 @@ type DownloadRequest struct {
}
type DownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ISRC string `json:"isrc,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
Success bool `json:"success"`
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ISRC string `json:"isrc,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
}
type DownloadResult struct {
@@ -235,15 +123,8 @@ type DownloadResult struct {
Composer string
LyricsLRC string
DecryptionKey string
Decryption *DownloadDecryptionInfo
}
var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return GetDeezerClient().GetExtendedMetadataByISRC(ctx, isrc)
}
var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC
type reEnrichRequest struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
@@ -302,12 +183,6 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
}
if req.shouldUpdateField("basic_tags") {
if track.Name != "" {
req.TrackName = track.Name
}
if track.Artists != "" {
req.ArtistName = track.Artists
}
if track.AlbumName != "" {
req.AlbumName = track.AlbumName
}
@@ -361,29 +236,6 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
}
}
func isPlaceholderReEnrichValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "unknown", "unknown artist", "unknown title", "unknown album":
return true
default:
return false
}
}
func buildReEnrichSearchQuery(req reEnrichRequest) string {
parts := make([]string, 0, 2)
if !isPlaceholderReEnrichValue(req.TrackName) {
parts = append(parts, strings.TrimSpace(req.TrackName))
}
if !isPlaceholderReEnrichValue(req.ArtistName) {
parts = append(parts, strings.TrimSpace(req.ArtistName))
}
if len(parts) == 0 && !isPlaceholderReEnrichValue(req.AlbumName) {
parts = append(parts, strings.TrimSpace(req.AlbumName))
}
return strings.TrimSpace(strings.Join(parts, " "))
}
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
return DownloadRequest{
TrackName: req.TrackName,
@@ -404,12 +256,6 @@ func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
metadata := map[string]string{}
if req.shouldUpdateField("basic_tags") {
if req.TrackName != "" {
metadata["TITLE"] = req.TrackName
}
if req.ArtistName != "" {
metadata["ARTIST"] = req.ArtistName
}
if req.AlbumName != "" {
metadata["ALBUM"] = req.AlbumName
}
@@ -753,7 +599,6 @@ func buildDownloadSuccessResponse(
Composer: composer,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
}
}
@@ -796,75 +641,6 @@ func enrichResultQualityFromFile(result *DownloadResult) {
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
}
func applyExtendedMetadataFields(
genre *string,
label *string,
copyright *string,
extMeta *AlbumExtendedMetadata,
) {
if extMeta == nil {
return
}
if genre != nil && *genre == "" && extMeta.Genre != "" {
*genre = extMeta.Genre
}
if label != nil && *label == "" && extMeta.Label != "" {
*label = extMeta.Label
}
if copyright != nil && *copyright == "" && extMeta.Copyright != "" {
*copyright = extMeta.Copyright
}
}
func enrichExtraMetadataByISRC(
logPrefix string,
isrc string,
genre *string,
label *string,
copyright *string,
) {
normalizedISRC := strings.TrimSpace(isrc)
if normalizedISRC == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
extMeta, err := fetchDeezerExtendedMetadataByISRC(ctx, normalizedISRC)
if err != nil {
GoLog("[%s] Failed to get extended metadata from Deezer: %v\n", logPrefix, err)
}
applyExtendedMetadataFields(genre, label, copyright, extMeta)
if genre != nil && *genre == "" {
musicBrainzGenre, err := fetchMusicBrainzGenreByISRC(normalizedISRC)
if err != nil {
GoLog("[%s] Failed to get genre from MusicBrainz: %v\n", logPrefix, err)
} else if musicBrainzGenre != "" {
*genre = musicBrainzGenre
GoLog("[%s] Genre fallback from MusicBrainz: %s\n", logPrefix, *genre)
}
}
currentGenre := ""
currentLabel := ""
currentCopyright := ""
if genre != nil {
currentGenre = *genre
}
if label != nil {
currentLabel = *label
}
if copyright != nil {
currentCopyright = *copyright
}
if currentGenre != "" || currentLabel != "" || currentCopyright != "" {
GoLog("[%s] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", logPrefix, currentGenre, currentLabel, currentCopyright)
}
}
func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req == nil {
return
@@ -874,13 +650,30 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
return
}
enrichExtraMetadataByISRC(
"DownloadWithFallback",
req.ISRC,
&req.Genre,
&req.Label,
&req.Copyright,
)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
deezerClient := GetDeezerClient()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
if err != nil || extMeta == nil {
if err != nil {
GoLog("[DownloadWithFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
return
}
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
func applySongLinkRegionFromRequest(req *DownloadRequest) {
@@ -953,6 +746,24 @@ func DownloadTrack(requestJSON string) (string, error) {
}
}
err = qobuzErr
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 errorResponse("Unknown service: " + req.Service)
}
@@ -1003,7 +814,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
serviceNormalized := strings.ToLower(serviceRaw)
normalizedReq := req
if isBuiltInDownloadProvider(serviceNormalized) {
if isBuiltInProvider(serviceNormalized) {
normalizedReq.Service = serviceNormalized
}
@@ -1016,7 +827,7 @@ 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 && isBuiltInDownloadProvider(serviceNormalized) {
if !req.UseFallback && isBuiltInProvider(serviceNormalized) {
return DownloadTrack(normalizedJSON)
}
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
@@ -1055,9 +866,9 @@ func DownloadWithFallback(requestJSON string) (string, error) {
enrichRequestExtendedMetadata(&req)
allServices := []string{"tidal", "qobuz"}
allServices := []string{"tidal", "qobuz", "deezer"}
preferredService := req.Service
if !isBuiltInDownloadProvider(preferredService) {
if preferredService == "" {
preferredService = "tidal"
}
@@ -1123,6 +934,26 @@ func DownloadWithFallback(requestJSON string) (string, error) {
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
}
err = qobuzErr
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) {
@@ -1485,7 +1316,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
coverPath := strings.TrimSpace(fields["cover_path"])
if isFlac {
@@ -1531,7 +1361,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
DiscNumber: discNum,
TotalDiscs: totalDiscs,
ISRC: fields["isrc"],
Lyrics: fields["lyrics"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
@@ -1598,19 +1427,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
if isM4AFile && hasOnlyM4AReplayGainFields(fields) {
if err := EditM4AReplayGain(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
}
resp := map[string]any{
"success": true,
"method": "native_m4a_replaygain",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
resp := map[string]any{
"success": true,
"method": "ffmpeg",
@@ -1620,29 +1436,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
allowed := map[string]struct{}{
"replaygain_track_gain": {},
"replaygain_track_peak": {},
"replaygain_album_gain": {},
"replaygain_album_peak": {},
}
hasReplayGain := false
for key, value := range fields {
if strings.TrimSpace(value) == "" {
continue
}
if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok {
hasReplayGain = true
continue
}
return false
}
return hasReplayGain
}
func SetDownloadDirectory(path string) error {
return setDownloadDir(path)
}
@@ -1879,6 +1672,24 @@ func ClearTrackIDCache() {
ClearTrackCache()
}
func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := GetDeezerClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewTidalDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
@@ -2484,9 +2295,12 @@ func ReEnrichFile(requestJSON string) (string, error) {
// When search_online is true, search for metadata from internet using the
// configured metadata-provider priority.
if req.SearchOnline {
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
searchQuery := req.TrackName + " " + req.ArtistName
found := false
deezerClient := GetDeezerClient()
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
manager := getExtensionManager()
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
@@ -2496,28 +2310,36 @@ func ReEnrichFile(requestJSON string) (string, error) {
found = true
}
searchQuery := buildReEnrichSearchQuery(req)
if searchQuery != "" {
GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery)
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
if searchErr == nil && len(tracks) > 0 {
track := selectBestReEnrichTrack(req, tracks)
if track != nil {
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
applyReEnrichTrackMetadata(&req, *track)
found = true
}
} else if searchErr != nil {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
if searchErr == nil && len(tracks) > 0 {
track := selectBestReEnrichTrack(req, tracks)
if track != nil {
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
applyReEnrichTrackMetadata(&req, *track)
found = true
}
} else {
GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
} else if searchErr != nil {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
}
// Try to enrich extra metadata from ISRC if not already set.
// Try to get extended metadata from Deezer if not already set
if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
if !found {
@@ -2617,8 +2439,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
"duration_ms": req.DurationMs,
}
if req.shouldUpdateField("basic_tags") {
enrichedMeta["track_name"] = req.TrackName
enrichedMeta["artist_name"] = req.ArtistName
enrichedMeta["album_name"] = req.AlbumName
enrichedMeta["album_artist"] = req.AlbumArtist
}
@@ -2651,8 +2471,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
ArtistTagMode: req.ArtistTagMode,
}
if req.shouldUpdateField("basic_tags") {
metadata.Title = req.TrackName
metadata.Artist = req.ArtistName
metadata.Album = req.AlbumName
metadata.AlbumArtist = req.AlbumArtist
}
@@ -2847,30 +2665,6 @@ func GetProviderPriorityJSON() (string, error) {
return string(jsonBytes), nil
}
func SetExtensionFallbackProviderIDsJSON(providerIDsJSON string) error {
if strings.TrimSpace(providerIDsJSON) == "" {
SetExtensionFallbackProviderIDs(nil)
return nil
}
var providerIDs []string
if err := json.Unmarshal([]byte(providerIDsJSON), &providerIDs); err != nil {
return err
}
SetExtensionFallbackProviderIDs(providerIDs)
return nil
}
func GetExtensionFallbackProviderIDsJSON() (string, error) {
providerIDs := GetExtensionFallbackProviderIDs()
jsonBytes, err := json.Marshal(providerIDs)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
var priority []string
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
+7 -169
View File
@@ -1,24 +1,6 @@
package gobackend
import (
"context"
"testing"
)
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
}
if got := GetExtensionFallbackProviderIDs(); got != nil {
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
}
}
import "testing"
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{
@@ -132,124 +114,6 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
}
}
func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) {
req := DownloadRequest{
TrackName: "Track",
ArtistName: "Artist",
}
result := DownloadResult{
Title: "Track",
Artist: "Artist",
DecryptionKey: "00112233",
}
resp := buildDownloadSuccessResponse(
req,
result,
"amazon",
"ok",
"/tmp/test.m4a",
false,
)
if resp.Decryption == nil {
t.Fatal("expected decryption descriptor to be present")
}
if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy {
t.Fatalf("strategy = %q", resp.Decryption.Strategy)
}
if resp.Decryption.Key != result.DecryptionKey {
t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey)
}
}
func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) {
got := formatMusicBrainzGenre([]musicBrainzTag{
{Name: "art pop", Count: 3},
{Name: "pop", Count: 8},
{Name: "dance pop", Count: 5},
})
if got != "Pop" {
t.Fatalf("genre = %q, want %q", got, "Pop")
}
}
func TestEnrichExtraMetadataByISRCFallsBackToMusicBrainzGenre(t *testing.T) {
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
defer func() {
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
}()
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return nil, nil
}
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
if isrc != "TEST123" {
t.Fatalf("unexpected isrc: %q", isrc)
}
return "Alternative Rock", nil
}
genre := ""
label := ""
copyright := ""
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST123", &genre, &label, &copyright)
if genre != "Alternative Rock" {
t.Fatalf("genre = %q, want fallback genre", genre)
}
if label != "" {
t.Fatalf("label = %q, want empty", label)
}
if copyright != "" {
t.Fatalf("copyright = %q, want empty", copyright)
}
}
func TestEnrichExtraMetadataByISRCPrefersDeezerGenre(t *testing.T) {
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
defer func() {
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
}()
musicBrainzCalled := false
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return &AlbumExtendedMetadata{
Genre: "Synthpop",
Label: "EMI",
Copyright: "(C) Test",
}, nil
}
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
musicBrainzCalled = true
return "Rock", nil
}
genre := ""
label := ""
copyright := ""
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST456", &genre, &label, &copyright)
if genre != "Synthpop" {
t.Fatalf("genre = %q, want Deezer genre", genre)
}
if label != "EMI" {
t.Fatalf("label = %q, want Deezer label", label)
}
if copyright != "(C) Test" {
t.Fatalf("copyright = %q, want Deezer copyright", copyright)
}
if musicBrainzCalled {
t.Fatal("expected MusicBrainz not to be called when Deezer already provides genre")
}
}
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
req := reEnrichRequest{
SpotifyID: "spotify-track-id",
@@ -331,11 +195,13 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
metadata := buildReEnrichFFmpegMetadata(&req, "")
if metadata["TITLE"] != "Song" {
t.Fatalf("title = %q", metadata["TITLE"])
// Title and Artist are never written by re-enrich (they are search keys
// preserved as-is from the file).
if _, exists := metadata["TITLE"]; exists {
t.Fatalf("TITLE should not be in metadata: %#v", metadata)
}
if metadata["ARTIST"] != "Artist" {
t.Fatalf("artist = %q", metadata["ARTIST"])
if _, exists := metadata["ARTIST"]; exists {
t.Fatalf("ARTIST should not be in metadata: %#v", metadata)
}
if metadata["ALBUM"] != "Album" {
t.Fatalf("album = %q", metadata["ALBUM"])
@@ -359,35 +225,10 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
}
}
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
req := reEnrichRequest{
TrackName: "Sign of the Times",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
}
query := buildReEnrichSearchQuery(req)
if query != "Sign of the Times" {
t.Fatalf("query = %q", query)
}
req = reEnrichRequest{
TrackName: "Unknown Title",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
}
query = buildReEnrichSearchQuery(req)
if query != "Harry Styles" {
t.Fatalf("fallback album query = %q", query)
}
}
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
req := reEnrichRequest{}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
Name: "Resolved Song",
Artists: "Resolved Artist",
TrackNumber: 7,
TotalTracks: 12,
DiscNumber: 2,
@@ -401,9 +242,6 @@ func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
}
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
}
if req.Composer != "Composer" {
t.Fatalf("composer = %q", req.Composer)
}
+3 -15
View File
@@ -893,6 +893,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"`
@@ -950,6 +951,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
Name: ext.Manifest.Name,
DisplayName: ext.Manifest.DisplayName,
Version: ext.Manifest.Version,
Author: ext.Manifest.Author,
Description: ext.Manifest.Description,
Homepage: ext.Manifest.Homepage,
IconPath: iconPath,
@@ -1053,29 +1055,15 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
}
defer ext.VMMu.Unlock()
// Merge extension return values onto the top-level JSON object so Flutter can read
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
// Handle promise - return pending status
return { success: true, pending: true, message: 'Action started' };
}
if (result !== null && result !== undefined && typeof result === 'object') {
var isArr = false;
if (typeof Array !== 'undefined' && Array.isArray) {
isArr = Array.isArray(result);
}
if (!isArr) {
var out = { success: true };
for (var k in result) {
out[k] = result[k];
}
return out;
}
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
+5
View File
@@ -105,6 +105,7 @@ type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
@@ -154,6 +155,10 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "version", Message: "version is required"}
}
if strings.TrimSpace(m.Author) == "" {
return &ManifestValidationError{Field: "author", Message: "author is required"}
}
if strings.TrimSpace(m.Description) == "" {
return &ManifestValidationError{Field: "description", Message: "description is required"}
}
+98 -325
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -95,15 +96,6 @@ type ExtDownloadURLResult struct {
SampleRate int `json:"sample_rate,omitempty"`
}
type DownloadDecryptionInfo struct {
Strategy string `json:"strategy,omitempty"`
Key string `json:"key,omitempty"`
IV string `json:"iv,omitempty"`
InputFormat string `json:"input_format,omitempty"`
OutputExtension string `json:"output_extension,omitempty"`
Options map[string]interface{} `json:"options,omitempty"`
}
type ExtDownloadResult struct {
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
@@ -112,97 +104,16 @@ type ExtDownloadResult struct {
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ISRC string `json:"isrc,omitempty"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
}
const genericFFmpegMOVDecryptionStrategy = "ffmpeg.mov_key"
func cloneDownloadDecryptionInfo(info *DownloadDecryptionInfo) *DownloadDecryptionInfo {
if info == nil {
return nil
}
cloned := &DownloadDecryptionInfo{
Strategy: strings.TrimSpace(info.Strategy),
Key: strings.TrimSpace(info.Key),
IV: strings.TrimSpace(info.IV),
InputFormat: strings.TrimSpace(info.InputFormat),
OutputExtension: strings.TrimSpace(info.OutputExtension),
}
if len(info.Options) > 0 {
cloned.Options = make(map[string]interface{}, len(info.Options))
for key, value := range info.Options {
cloned.Options[key] = value
}
}
return cloned
}
func normalizeDownloadDecryptionStrategy(strategy string) string {
switch strings.ToLower(strings.TrimSpace(strategy)) {
case "", "ffmpeg.mov_key", "ffmpeg_mov_key", "mov_decryption_key", "mp4_decryption_key", "ffmpeg.mp4_decryption_key":
return genericFFmpegMOVDecryptionStrategy
default:
return strings.TrimSpace(strategy)
}
}
func normalizeDownloadDecryptionInfo(info *DownloadDecryptionInfo, legacyKey string) *DownloadDecryptionInfo {
normalized := cloneDownloadDecryptionInfo(info)
trimmedLegacyKey := strings.TrimSpace(legacyKey)
if normalized == nil {
if trimmedLegacyKey == "" {
return nil
}
return &DownloadDecryptionInfo{
Strategy: genericFFmpegMOVDecryptionStrategy,
Key: trimmedLegacyKey,
InputFormat: "mov",
}
}
normalized.Strategy = normalizeDownloadDecryptionStrategy(normalized.Strategy)
if normalized.Key == "" && trimmedLegacyKey != "" {
normalized.Key = trimmedLegacyKey
}
if normalized.Strategy == "" && normalized.Key != "" {
normalized.Strategy = genericFFmpegMOVDecryptionStrategy
}
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.InputFormat == "" {
normalized.InputFormat = "mov"
}
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.Key == "" {
return nil
}
return normalized
}
func normalizedDownloadDecryptionKey(info *DownloadDecryptionInfo, legacyKey string) string {
if normalized := normalizeDownloadDecryptionInfo(info, legacyKey); normalized != nil {
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy {
return normalized.Key
}
}
return strings.TrimSpace(legacyKey)
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ISRC string `json:"isrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
}
type extensionProviderWrapper struct {
@@ -622,10 +533,6 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
p.extension.runtime.setActiveDownloadItemID(itemID)
defer p.extension.runtime.clearActiveDownloadItemID()
}
if itemID != "" {
initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
@@ -693,14 +600,6 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
ErrorType: "internal_error",
}, nil
}
downloadResult.Decryption = normalizeDownloadDecryptionInfo(
downloadResult.Decryption,
downloadResult.DecryptionKey,
)
downloadResult.DecryptionKey = normalizedDownloadDecryptionKey(
downloadResult.Decryption,
downloadResult.DecryptionKey,
)
return &downloadResult, nil
}
@@ -777,9 +676,6 @@ func (m *extensionManager) SearchTracksWithExtensions(query string, limit int) (
var providerPriority []string
var providerPriorityMu sync.RWMutex
var extensionFallbackProviderIDs []string
var extensionFallbackProviderIDsMu sync.RWMutex
var metadataProviderPriority []string
var metadataProviderPriorityMu sync.RWMutex
@@ -788,8 +684,8 @@ var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
func SetProviderPriority(providerIDs []string) {
providerPriorityMu.Lock()
defer providerPriorityMu.Unlock()
providerPriority = sanitizeDownloadProviderPriority(providerIDs)
GoLog("[Extension] Download provider priority set: %v\n", providerPriority)
providerPriority = providerIDs
GoLog("[Extension] Download provider priority set: %v\n", providerIDs)
}
func GetProviderPriority() []string {
@@ -797,7 +693,7 @@ func GetProviderPriority() []string {
defer providerPriorityMu.RUnlock()
if len(providerPriority) == 0 {
return []string{"tidal", "qobuz"}
return []string{"tidal", "qobuz", "deezer"}
}
result := make([]string, len(providerPriority))
@@ -805,104 +701,11 @@ func GetProviderPriority() []string {
return result
}
func sanitizeDownloadProviderPriority(providerIDs []string) []string {
sanitized := make([]string, 0, len(providerIDs)+2)
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
if providerID == "" {
continue
}
normalizedBuiltIn := strings.ToLower(providerID)
if isBuiltInDownloadProvider(normalizedBuiltIn) {
providerID = normalizedBuiltIn
}
seenKey := strings.ToLower(providerID)
if _, exists := seen[seenKey]; exists {
continue
}
seen[seenKey] = struct{}{}
sanitized = append(sanitized, providerID)
}
for _, providerID := range []string{"tidal", "qobuz"} {
if _, exists := seen[providerID]; exists {
continue
}
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
return sanitized
}
func SetExtensionFallbackProviderIDs(providerIDs []string) {
extensionFallbackProviderIDsMu.Lock()
defer extensionFallbackProviderIDsMu.Unlock()
if providerIDs == nil {
extensionFallbackProviderIDs = nil
GoLog("[Extension] Extension fallback providers reset to default (all enabled download extensions)\n")
return
}
sanitized := make([]string, 0, len(providerIDs))
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
if providerID == "" || isBuiltInDownloadProvider(strings.ToLower(providerID)) {
continue
}
if _, exists := seen[providerID]; exists {
continue
}
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
extensionFallbackProviderIDs = sanitized
GoLog("[Extension] Extension fallback providers set: %v\n", sanitized)
}
func GetExtensionFallbackProviderIDs() []string {
extensionFallbackProviderIDsMu.RLock()
defer extensionFallbackProviderIDsMu.RUnlock()
if extensionFallbackProviderIDs == nil {
return nil
}
result := make([]string, len(extensionFallbackProviderIDs))
copy(result, extensionFallbackProviderIDs)
return result
}
func isExtensionFallbackAllowed(providerID string) bool {
if isBuiltInDownloadProvider(strings.ToLower(providerID)) {
return true
}
allowed := GetExtensionFallbackProviderIDs()
if allowed == nil {
return true
}
for _, allowedProviderID := range allowed {
if allowedProviderID == providerID {
return true
}
}
return false
}
func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
sanitized := make([]string, 0, len(providerIDs)+2)
sanitized := make([]string, 0, len(providerIDs)+3)
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
@@ -915,7 +718,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
for _, providerID := range []string{"qobuz", "tidal"} {
for _, providerID := range []string{"deezer", "qobuz", "tidal"} {
if _, exists := seen[providerID]; exists {
continue
}
@@ -932,7 +735,7 @@ func GetMetadataProviderPriority() []string {
defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 {
return []string{"qobuz", "tidal"}
return []string{"deezer", "qobuz", "tidal"}
}
result := make([]string, len(metadataProviderPriority))
@@ -942,35 +745,13 @@ func GetMetadataProviderPriority() []string {
func isBuiltInProvider(providerID string) bool {
switch providerID {
case "tidal", "qobuz":
case "tidal", "qobuz", "deezer":
return true
default:
return false
}
}
func isBuiltInDownloadProvider(providerID string) bool {
switch providerID {
case "tidal", "qobuz":
return true
default:
return false
}
}
func normalizeQualityForBuiltIn(quality string) string {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "alac", "hi_res_lossless", "lossless":
return "HI_RES_LOSSLESS"
case "atmos", "ac3", "dolby_atmos":
return "LOSSLESS"
case "aac", "aac-legacy":
return "LOSSLESS"
default:
return quality
}
}
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
deezerID := ""
tidalID := ""
@@ -1026,6 +807,20 @@ func metadataTrackDedupKey(track ExtTrackMetadata) string {
func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
switch providerID {
case "deezer":
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
results, err := GetDeezerClient().SearchAll(ctx, query, limit, 0, "track")
if err != nil {
return nil, err
}
tracks := make([]ExtTrackMetadata, 0, len(results.Tracks))
for _, track := range results.Tracks {
tracks = append(tracks, normalizeBuiltInMetadataTrack(track, "deezer"))
}
return tracks, nil
case "qobuz":
return NewQobuzDownloader().SearchTracks(query, limit)
case "tidal":
@@ -1135,7 +930,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
if !strictMode && req.Service != "" && isBuiltInDownloadProvider(strings.ToLower(req.Service)) {
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 {
@@ -1145,7 +940,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
priority = newPriority
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
} else if !strictMode && req.Service != "" && !isBuiltInDownloadProvider(strings.ToLower(req.Service)) {
} else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) {
found := false
for _, p := range priority {
if strings.EqualFold(p, req.Service) {
@@ -1333,14 +1128,28 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.ISRC != "" &&
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
}
if req.Source != "" &&
!isBuiltInProvider(strings.ToLower(req.Source)) &&
selectedProvider == req.Source {
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s' matching selected provider, trying it first\n", 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)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
@@ -1389,17 +1198,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Label: req.Label,
Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
}
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) {
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 {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
} else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath)
}
if ext.Manifest.SkipMetadataEnrichment {
@@ -1422,12 +1228,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if result.DiscNumber > 0 {
resp.DiscNumber = result.DiscNumber
}
if result.TotalTracks > 0 {
resp.TotalTracks = result.TotalTracks
}
if result.TotalDiscs > 0 {
resp.TotalDiscs = result.TotalDiscs
}
if result.ReleaseDate != "" {
resp.ReleaseDate = result.ReleaseDate
}
@@ -1437,29 +1237,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if result.ISRC != "" {
resp.ISRC = result.ISRC
}
if result.Genre != "" {
resp.Genre = result.Genre
}
if result.Label != "" {
resp.Label = result.Label
}
if result.Copyright != "" {
resp.Copyright = result.Copyright
}
if result.Composer != "" {
resp.Composer = result.Composer
}
if result.LyricsLRC != "" {
resp.LyricsLRC = result.LyricsLRC
}
}
if req.TrackName != "" && resp.Title == "" {
resp.Title = req.TrackName
}
if req.ArtistName != "" && resp.Artist == "" {
resp.Artist = req.ArtistName
}
if req.AlbumName != "" && resp.Album == "" {
resp.Album = req.AlbumName
}
@@ -1478,18 +1257,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
resp.DiscNumber = req.DiscNumber
}
if req.TotalTracks > 0 && resp.TotalTracks == 0 {
resp.TotalTracks = req.TotalTracks
}
if req.TotalDiscs > 0 && resp.TotalDiscs == 0 {
resp.TotalDiscs = req.TotalDiscs
}
if req.CoverURL != "" && resp.CoverURL == "" {
resp.CoverURL = req.CoverURL
}
if req.Composer != "" && resp.Composer == "" {
resp.Composer = req.Composer
}
return resp, nil
}
@@ -1533,30 +1303,40 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue
}
if skipBuiltIn && isBuiltInDownloadProvider(providerIDNormalized) {
if skipBuiltIn && isBuiltInProvider(providerIDNormalized) {
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
continue
}
if !isBuiltInDownloadProvider(providerIDNormalized) && !isExtensionFallbackAllowed(providerID) {
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
continue
}
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInDownloadProvider(providerIDNormalized) {
req.OutputExt = ""
if isBuiltInProvider(providerIDNormalized) {
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extra metadata from ISRC: %s\n", req.ISRC)
enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
deezerClient := GetDeezerClient()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
GoLog("[DownloadWithExtensionFallback] Genre from Deezer: %s\n", req.Genre)
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
}
} else if err != nil {
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
}
origQuality := req.Quality
req.Quality = normalizeQualityForBuiltIn(req.Quality)
result, err := tryBuiltInProvider(providerIDNormalized, req)
req.Quality = origQuality
if err == nil && result.Success {
result.Service = providerIDNormalized
if req.Label != "" {
@@ -1607,7 +1387,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue
}
req.OutputExt = ""
outputPath := buildOutputPathForExtension(req, ext)
if req.ItemID != "" {
StartItemProgress(req.ItemID)
@@ -1645,17 +1424,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Label: req.Label,
Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
}
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) {
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 {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
} else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath)
}
if ext.Manifest.SkipMetadataEnrichment {
@@ -1788,6 +1564,24 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
}
}
err = qobuzErr
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)
}
@@ -1815,7 +1609,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
Copyright: req.Copyright,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
}, nil
}
@@ -1857,24 +1650,19 @@ func buildOutputPath(req DownloadRequest) string {
outputDir := req.OutputDir
if strings.TrimSpace(outputDir) == "" {
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
os.MkdirAll(outputDir, 0755)
AddAllowedDownloadDir(outputDir)
}
os.MkdirAll(outputDir, 0755)
AddAllowedDownloadDir(outputDir)
return filepath.Join(outputDir, filename+ext)
}
func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string {
if strings.TrimSpace(req.OutputPath) != "" {
outputPath := strings.TrimSpace(req.OutputPath)
AddAllowedDownloadDir(filepath.Dir(outputPath))
return outputPath
return strings.TrimSpace(req.OutputPath)
}
// SAF downloads hand extensions a detached output FD owned by the host.
// Extensions still need a real local temp file so Android can copy it into
// the target document after provider-specific post-processing completes.
if !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
if strings.TrimSpace(req.OutputDir) != "" {
return buildOutputPath(req)
}
@@ -1915,21 +1703,6 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
return filepath.Join(tempDir, filename+outputExt)
}
func canEmbedGenreLabel(filePath string) bool {
path := strings.TrimSpace(filePath)
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
return false
}
if strings.ToLower(filepath.Ext(path)) != ".flac" {
return false
}
if !filepath.IsAbs(path) {
return false
}
info, err := os.Stat(path)
return err == nil && !info.IsDir() && info.Size() > 0
}
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
if !p.extension.Manifest.HasCustomSearch() {
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
+11 -195
View File
@@ -1,10 +1,6 @@
package gobackend
import (
"os"
"path/filepath"
"testing"
)
import "testing"
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
original := GetMetadataProviderPriority()
@@ -12,7 +8,7 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
SetMetadataProviderPriority([]string{"tidal"})
got := GetMetadataProviderPriority()
want := []string{"tidal", "qobuz"}
want := []string{"tidal", "deezer", "qobuz"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
@@ -23,190 +19,6 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
}
}
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
got := GetExtensionFallbackProviderIDs()
want := []string{"ext-a", "ext-b"}
if len(got) != len(want) {
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
}
}
}
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs(nil)
if !isExtensionFallbackAllowed("custom-ext") {
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
}
if !isExtensionFallbackAllowed("qobuz") {
t.Fatal("expected built-in provider to remain allowed")
}
}
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
if !isExtensionFallbackAllowed("allowed-ext") {
t.Fatal("expected explicitly allowed extension to be permitted")
}
if isExtensionFallbackAllowed("blocked-ext") {
t.Fatal("expected extension outside allowlist to be blocked")
}
if isExtensionFallbackAllowed("deezer") {
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
}
}
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
original := GetProviderPriority()
defer SetProviderPriority(original)
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
got := GetProviderPriority()
want := []string{"qobuz", "custom-ext", "tidal"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
}
}
}
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
if normalized == nil {
t.Fatal("expected legacy decryption key to produce normalized descriptor")
}
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
t.Fatalf("strategy = %q", normalized.Strategy)
}
if normalized.Key != "001122" {
t.Fatalf("key = %q", normalized.Key)
}
if normalized.InputFormat != "mov" {
t.Fatalf("input format = %q", normalized.InputFormat)
}
}
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
Strategy: "mp4_decryption_key",
Key: "abcd",
InputFormat: "",
}, "")
if normalized == nil {
t.Fatal("expected descriptor to remain available")
}
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
t.Fatalf("strategy = %q", normalized.Strategy)
}
if normalized.InputFormat != "mov" {
t.Fatalf("input format = %q", normalized.InputFormat)
}
}
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := buildOutputPath(DownloadRequest{
TrackName: "Song",
ArtistName: "Artist",
OutputDir: outputDir,
OutputExt: ".flac",
FilenameFormat: "",
})
if !isPathInAllowedDirs(outputPath) {
t.Fatalf("expected output path %q to be allowed", outputPath)
}
}
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := filepath.Join(outputDir, "custom.flac")
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
OutputPath: outputPath,
}, ext)
if resolved != outputPath {
t.Fatalf("resolved output path = %q", resolved)
}
if !isPathInAllowedDirs(outputPath) {
t.Fatalf("expected output path %q to be allowed", outputPath)
}
}
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
SetAllowedDownloadDirs(nil)
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
TrackName: "Song",
ArtistName: "Artist",
OutputDir: filepath.Join("Artist", "Album"),
OutputFD: 123,
OutputExt: ".flac",
}, ext)
expectedBase := filepath.Join(ext.DataDir, "downloads")
if !isPathWithinBase(expectedBase, resolved) {
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
}
if !isPathInAllowedDirs(resolved) {
t.Fatalf("expected resolved output path %q to be allowed", resolved)
}
}
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "track.flac")
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
tempM4A := filepath.Join(t.TempDir(), "track.m4a")
if err := os.WriteFile(tempM4A, []byte("not-flac"), 0644); err != nil {
t.Fatalf("failed to create temp m4a file: %v", err)
}
if canEmbedGenreLabel("relative.flac") {
t.Fatal("expected relative path to be rejected")
}
if canEmbedGenreLabel("content://example") {
t.Fatal("expected content URI to be rejected")
}
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
t.Fatal("expected missing file to be rejected")
}
if canEmbedGenreLabel(tempM4A) {
t.Fatalf("expected non-FLAC file %q to be rejected", tempM4A)
}
if !canEmbedGenreLabel(tempFile) {
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
}
}
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
originalPriority := GetMetadataProviderPriority()
originalSearch := searchBuiltInMetadataTracksFunc
@@ -215,7 +27,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
searchBuiltInMetadataTracksFunc = originalSearch
}()
SetMetadataProviderPriority([]string{"qobuz", "tidal"})
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
var calls []string
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
@@ -230,6 +42,10 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
}, nil
case "deezer":
return []ExtTrackMetadata{
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
}, nil
default:
return nil, nil
}
@@ -240,13 +56,13 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
if err != nil {
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
}
if len(tracks) != 2 {
t.Fatalf("unexpected track count: got %d want 2", len(tracks))
if len(tracks) != 3 {
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
}
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" {
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
t.Fatalf("unexpected track provider order: %+v", tracks)
}
if len(calls) != 2 || calls[0] != "qobuz" || calls[1] != "tidal" {
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
t.Fatalf("unexpected provider call order: %v", calls)
}
}
+1 -71
View File
@@ -5,7 +5,6 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -137,60 +136,12 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
storageFlushDelay: defaultStorageFlushDelay,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second))
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
return runtime
}
func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Duration {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return fallback
}
raw, ok := ext.Manifest.Capabilities["networkTimeoutSeconds"]
if !ok {
return fallback
}
seconds := parseExtensionTimeoutSeconds(raw)
if seconds <= 0 {
return fallback
}
if seconds < 5 {
seconds = 5
}
if seconds > 300 {
seconds = 300
}
return time.Duration(seconds) * time.Second
}
func parseExtensionTimeoutSeconds(raw interface{}) int {
switch v := raw.(type) {
case int:
return v
case int32:
return int(v)
case int64:
return int(v)
case float32:
return int(v)
case float64:
return int(v)
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return parsed
default:
return 0
}
}
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
@@ -209,19 +160,6 @@ func (r *extensionRuntime) getActiveDownloadItemID() string {
return r.activeDownloadItemID
}
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
if req == nil {
return nil
}
itemID := r.getActiveDownloadItemID()
if itemID == "" {
return req
}
return req.WithContext(initDownloadCancel(itemID))
}
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
@@ -439,9 +377,7 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
fileObj.Set("exists", r.fileExists)
fileObj.Set("delete", r.fileDelete)
fileObj.Set("read", r.fileRead)
fileObj.Set("readBytes", r.fileReadBytes)
fileObj.Set("write", r.fileWrite)
fileObj.Set("writeBytes", r.fileWriteBytes)
fileObj.Set("copy", r.fileCopy)
fileObj.Set("move", r.fileMove)
fileObj.Set("getSize", r.fileGetSize)
@@ -471,14 +407,8 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("stringifyJSON", r.stringifyJSON)
utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
utilsObj.Set("appVersion", r.appVersion)
utilsObj.Set("appUserAgent", r.appUserAgent)
utilsObj.Set("sleep", r.sleep)
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
vm.Set("utils", utilsObj)
logObj := vm.NewObject()
-1
View File
@@ -458,7 +458,6 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
-359
View File
@@ -1,359 +0,0 @@
package gobackend
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"github.com/dop251/goja"
"golang.org/x/crypto/blowfish"
)
type runtimeBlockCipherOptions struct {
Algorithm string
Mode string
Key []byte
IV []byte
InputEncoding string
OutputEncoding string
Padding string
}
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
if len(call.Arguments) <= index {
return nil
}
value := call.Arguments[index]
if goja.IsUndefined(value) || goja.IsNull(value) {
return nil
}
exported := value.Export()
if options, ok := exported.(map[string]interface{}); ok {
return options
}
return nil
}
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case string:
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
case []byte:
if len(value) > 0 {
return string(value)
}
}
return defaultValue
}
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case bool:
return value
case int:
return value != 0
case int64:
return value != 0
case float64:
return value != 0
case string:
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return defaultValue
}
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case int:
return int64(value)
case int32:
return int64(value)
case int64:
return value
case float32:
return int64(value)
case float64:
return int64(value)
case string:
value = strings.TrimSpace(value)
if value == "" {
return defaultValue
}
var parsed int64
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
return parsed
}
}
return defaultValue
}
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
if options == nil {
return false
}
_, exists := options[key]
return exists
}
func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "utf8", "utf-8", "text":
return []byte(input), nil
case "base64":
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input))
if err != nil {
return nil, fmt.Errorf("invalid base64 data: %w", err)
}
return decoded, nil
case "hex":
decoded, err := hex.DecodeString(strings.TrimSpace(input))
if err != nil {
return nil, fmt.Errorf("invalid hex data: %w", err)
}
return decoded, nil
default:
return nil, fmt.Errorf("unsupported byte encoding: %s", encoding)
}
}
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
switch value := raw.(type) {
case string:
return decodeRuntimeBytesString(value, encoding)
case []byte:
cloned := make([]byte, len(value))
copy(cloned, value)
return cloned, nil
case []interface{}:
decoded := make([]byte, len(value))
for i, item := range value {
switch num := item.(type) {
case int:
decoded[i] = byte(num)
case int64:
decoded[i] = byte(num)
case float64:
decoded[i] = byte(int(num))
default:
return nil, fmt.Errorf("unsupported byte array item at index %d", i)
}
}
return decoded, nil
default:
return nil, fmt.Errorf("unsupported byte payload type")
}
}
func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "base64":
return base64.StdEncoding.EncodeToString(data), nil
case "hex":
return hex.EncodeToString(data), nil
case "utf8", "utf-8", "text":
return string(data), nil
default:
return "", fmt.Errorf("unsupported byte encoding: %s", encoding)
}
}
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
parsed := &runtimeBlockCipherOptions{
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")),
OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")),
Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")),
}
if parsed.Algorithm == "" {
return nil, fmt.Errorf("algorithm is required")
}
if parsed.Mode == "" {
return nil, fmt.Errorf("mode is required")
}
key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8"))
if err != nil {
return nil, fmt.Errorf("invalid key: %w", err)
}
if len(key) == 0 {
return nil, fmt.Errorf("key is required")
}
parsed.Key = key
iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8"))
if err != nil {
return nil, fmt.Errorf("invalid iv: %w", err)
}
parsed.IV = iv
return parsed, nil
}
func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) {
switch options.Algorithm {
case "blowfish":
return blowfish.NewCipher(options.Key)
case "aes":
return aes.NewCipher(options.Key)
default:
return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm)
}
}
func applyPKCS7Padding(data []byte, blockSize int) []byte {
padding := blockSize - (len(data) % blockSize)
if padding == 0 {
padding = blockSize
}
out := make([]byte, len(data)+padding)
copy(out, data)
for i := len(data); i < len(out); i++ {
out[i] = byte(padding)
}
return out
}
func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, fmt.Errorf("invalid padded payload length")
}
padding := int(data[len(data)-1])
if padding <= 0 || padding > blockSize || padding > len(data) {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
for i := len(data) - padding; i < len(data); i++ {
if int(data[i]) != padding {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
}
return data[:len(data)-padding], nil
}
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "data and options are required",
})
}
options := parseRuntimeOptionsArgument(call, 1)
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
if parsedOptions.Mode != "cbc" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
})
}
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
block, err := newRuntimeBlockCipher(parsedOptions)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
if len(parsedOptions.IV) != block.BlockSize() {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
})
}
data := inputData
if !decrypt && parsedOptions.Padding == "pkcs7" {
data = applyPKCS7Padding(data, block.BlockSize())
}
if len(data)%block.BlockSize() != 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
})
}
output := make([]byte, len(data))
if decrypt {
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
if parsedOptions.Padding == "pkcs7" {
output, err = removePKCS7Padding(output, block.BlockSize())
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
}
} else {
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
}
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": encoded,
"block_size": block.BlockSize(),
})
}
func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value {
return r.transformBlockCipher(call, false)
}
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
return r.transformBlockCipher(call, true)
}
-185
View File
@@ -1,185 +0,0 @@
package gobackend
import (
"encoding/json"
"testing"
"github.com/dop251/goja"
)
func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime {
t.Helper()
ext := &loadedExtension{
ID: "binary-test-ext",
Manifest: &ExtensionManifest{
Name: "binary-test-ext",
Permissions: ExtensionPermissions{
File: withFilePermission,
},
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
vm := goja.New()
runtime.RegisterAPIs(vm)
return vm
}
func decodeJSONResult[T any](t *testing.T, value goja.Value) T {
t.Helper()
var decoded T
if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil {
t.Fatalf("failed to decode JSON result: %v", err)
}
return decoded
}
func TestExtensionRuntime_FileByteAPIs(t *testing.T) {
vm := newBinaryTestRuntime(t, true)
result, err := vm.RunString(`
(function() {
var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true});
if (!first.success) throw new Error(first.error);
var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true});
if (!second.success) throw new Error(second.error);
var all = file.readBytes("bytes.bin", {encoding: "hex"});
if (!all.success) throw new Error(all.error);
var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"});
if (!slice.success) throw new Error(slice.error);
var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"});
if (!tail.success) throw new Error(tail.error);
return JSON.stringify({
all: all.data,
slice: slice.data,
size: all.size,
sliceBytes: slice.bytes_read,
sliceEof: slice.eof,
tailBytes: tail.bytes_read,
tailEof: tail.eof
});
})()
`)
if err != nil {
t.Fatalf("file byte APIs failed: %v", err)
}
decoded := decodeJSONResult[struct {
All string `json:"all"`
Slice string `json:"slice"`
Size int64 `json:"size"`
SliceBytes int `json:"sliceBytes"`
SliceEof bool `json:"sliceEof"`
TailBytes int `json:"tailBytes"`
TailEof bool `json:"tailEof"`
}](t, result)
if decoded.All != "0001020304ff" {
t.Fatalf("all = %q", decoded.All)
}
if decoded.Slice != "0203" {
t.Fatalf("slice = %q", decoded.Slice)
}
if decoded.Size != 6 {
t.Fatalf("size = %d", decoded.Size)
}
if decoded.SliceBytes != 2 {
t.Fatalf("slice bytes = %d", decoded.SliceBytes)
}
if decoded.SliceEof {
t.Fatal("slice should not be EOF")
}
if decoded.TailBytes != 0 || !decoded.TailEof {
t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof)
}
}
func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "blowfish",
mode: "cbc",
key: "0123456789ABCDEFF0E1D2C3B4A59687",
keyEncoding: "hex",
iv: "0001020304050607",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex",
padding: "none"
};
var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, options);
if (!dec.success) throw new Error(dec.error);
return JSON.stringify({enc: enc.data, dec: dec.data});
})()
`)
if err != nil {
t.Fatalf("blowfish block cipher failed: %v", err)
}
decoded := decodeJSONResult[struct {
Enc string `json:"enc"`
Dec string `json:"dec"`
}](t, result)
if decoded.Dec != "00112233445566778899aabbccddeeff" {
t.Fatalf("dec = %q", decoded.Dec)
}
if decoded.Enc == decoded.Dec {
t.Fatal("expected ciphertext to differ from plaintext")
}
}
func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "cbc",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0f0e0d0c0b0a09080706050403020100",
ivEncoding: "hex",
inputEncoding: "utf8",
outputEncoding: "base64",
padding: "pkcs7"
};
var enc = utils.encryptBlockCipher("hello generic cbc", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, {
algorithm: "aes",
mode: "cbc",
key: options.key,
keyEncoding: options.keyEncoding,
iv: options.iv,
ivEncoding: options.ivEncoding,
inputEncoding: "base64",
outputEncoding: "utf8",
padding: "pkcs7"
});
if (!dec.success) throw new Error(dec.error);
return dec.data;
})()
`)
if err != nil {
t.Fatalf("aes block cipher failed: %v", err)
}
if result.String() != "hello generic cbc" {
t.Fatalf("unexpected decrypted value: %q", result.String())
}
}
-200
View File
@@ -166,7 +166,6 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -347,104 +346,6 @@ func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
options := parseRuntimeOptionsArgument(call, 1)
offset := runtimeOptionInt64(options, "offset", 0)
length := runtimeOptionInt64(options, "length", -1)
encoding := runtimeOptionString(options, "encoding", "base64")
if offset < 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "offset must be >= 0",
})
}
file, err := os.Open(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
size := info.Size()
if offset > size {
offset = size
}
if _, err := file.Seek(offset, io.SeekStart); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to seek file: %v", err),
})
}
var data []byte
switch {
case length == 0:
data = []byte{}
case length > 0:
buf := make([]byte, int(length))
n, readErr := file.Read(buf)
if readErr != nil && readErr != io.EOF {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read file: %v", readErr),
})
}
data = buf[:n]
default:
data, err = io.ReadAll(file)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read file: %v", err),
})
}
}
encoded, err := encodeRuntimeBytes(data, encoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": encoded,
"bytes_read": len(data),
"offset": offset,
"size": size,
"eof": offset+int64(len(data)) >= size,
})
}
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -485,107 +386,6 @@ func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
})
}
func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path and data are required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
options := parseRuntimeOptionsArgument(call, 2)
appendMode := runtimeOptionBool(options, "append", false)
truncate := runtimeOptionBool(options, "truncate", false)
hasOffset := runtimeOptionHasKey(options, "offset")
offset := runtimeOptionInt64(options, "offset", 0)
encoding := runtimeOptionString(options, "encoding", "base64")
if appendMode && hasOffset {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "append and offset cannot be used together",
})
}
if offset < 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "offset must be >= 0",
})
}
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
flags := os.O_CREATE | os.O_WRONLY
if appendMode {
flags |= os.O_APPEND
}
if truncate {
flags |= os.O_TRUNC
}
file, err := os.OpenFile(fullPath, flags, 0644)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
defer file.Close()
if hasOffset && !appendMode {
if _, err := file.Seek(offset, io.SeekStart); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to seek file: %v", err),
})
}
}
written, err := file.Write(data)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
info, statErr := file.Stat()
size := int64(0)
if statErr == nil {
size = info.Size()
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullPath,
"bytes_written": written,
"size": size,
})
}
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
-4
View File
@@ -81,7 +81,6 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -176,7 +175,6 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -286,7 +284,6 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -413,7 +410,6 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -69,7 +69,6 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if err != nil {
return r.createFetchError(err.Error())
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
-63
View File
@@ -249,69 +249,6 @@ func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent())
}
func (r *extensionRuntime) appVersion(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(GetAppVersion())
}
func (r *extensionRuntime) appUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(appUserAgent())
}
func (r *extensionRuntime) sleep(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(true)
}
sleepMs := 0
switch value := call.Arguments[0].Export().(type) {
case int64:
sleepMs = int(value)
case int32:
sleepMs = int(value)
case int:
sleepMs = value
case float64:
sleepMs = int(value)
default:
sleepMs = 0
}
if sleepMs <= 0 {
return r.vm.ToValue(true)
}
if sleepMs > 5*60*1000 {
sleepMs = 5 * 60 * 1000
}
itemID := r.getActiveDownloadItemID()
deadline := time.Now().Add(time.Duration(sleepMs) * time.Millisecond)
for {
if itemID != "" && isDownloadCancelled(itemID) {
return r.vm.ToValue(false)
}
remaining := time.Until(deadline)
if remaining <= 0 {
return r.vm.ToValue(true)
}
step := 100 * time.Millisecond
if remaining < step {
step = remaining
}
time.Sleep(step)
}
}
func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Value {
itemID := r.getActiveDownloadItemID()
if itemID == "" {
return r.vm.ToValue(false)
}
return r.vm.ToValue(isDownloadCancelled(itemID))
}
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
+7 -19
View File
@@ -26,6 +26,7 @@ type storeExtension struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"`
@@ -82,6 +83,7 @@ type storeExtensionResponse struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url"`
IconURL string `json:"icon_url,omitempty"`
@@ -101,6 +103,7 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
Name: e.Name,
DisplayName: e.getDisplayName(),
Version: e.Version,
Author: e.Author,
Description: e.Description,
DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(),
@@ -250,17 +253,7 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
client := NewHTTPClientWithTimeout(30 * time.Second)
req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionStore", "Failed to build registry request, using cached registry: %v", err)
return s.cache, nil
}
return nil, fmt.Errorf("failed to build registry request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
resp, err := client.Get(s.registryURL)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
@@ -355,13 +348,7 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := NewHTTPClientWithTimeout(5 * time.Minute)
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
if err != nil {
return fmt.Errorf("failed to build download request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
resp, err := client.Get(ext.getDownloadURL())
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
@@ -494,7 +481,8 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
if query != "" {
if !containsIgnoreCase(ext.Name, queryLower) &&
!containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) {
!containsIgnoreCase(ext.Description, queryLower) &&
!containsIgnoreCase(ext.Author, queryLower) {
found := false
for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) {
+3 -124
View File
@@ -1,10 +1,8 @@
package gobackend
import (
"net/http"
"path/filepath"
"testing"
"time"
"github.com/dop251/goja"
)
@@ -14,6 +12,7 @@ func TestParseManifest_Valid(t *testing.T) {
"name": "test-provider",
"displayName": "Test Provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"],
"permissions": {
@@ -47,6 +46,7 @@ func TestParseManifest_Valid(t *testing.T) {
func TestParseManifest_MissingName(t *testing.T) {
invalidManifest := `{
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"]
}`
@@ -61,6 +61,7 @@ func TestParseManifest_MissingType(t *testing.T) {
invalidManifest := `{
"name": "test-provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension"
}`
@@ -238,128 +239,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
if result.String() == "" {
t.Error("Expected non-empty JSON string")
}
result, err = vm.RunString(`utils.sleep(1)`)
if err != nil {
t.Fatalf("sleep failed: %v", err)
}
if !result.ToBoolean() {
t.Error("Expected sleep to complete successfully")
}
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
result, err = vm.RunString(`utils.isDownloadCancelled()`)
if err != nil {
t.Fatalf("isDownloadCancelled failed: %v", err)
}
if !result.ToBoolean() {
t.Error("Expected active download cancellation to be visible to JS")
}
SetAppVersion("4.2.2")
t.Cleanup(func() {
SetAppVersion("")
})
result, err = vm.RunString(`utils.appVersion()`)
if err != nil {
t.Fatalf("appVersion failed: %v", err)
}
if got := result.String(); got != "4.2.2" {
t.Fatalf("Expected appVersion 4.2.2, got %q", got)
}
result, err = vm.RunString(`utils.appUserAgent()`)
if err != nil {
t.Fatalf("appUserAgent failed: %v", err)
}
if got := result.String(); got != "SpotiFLAC-Mobile/4.2.2" {
t.Fatalf("Expected appUserAgent SpotiFLAC-Mobile/4.2.2, got %q", got)
}
result, err = vm.RunString(`utils.sleep(50)`)
if err != nil {
t.Fatalf("cancel-aware sleep failed: %v", err)
}
if result.ToBoolean() {
t.Error("Expected sleep to abort when download is cancelled")
}
}
func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
cancelDownload("test-item")
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected bound request context to be cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error after cancellation")
}
}
func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected pre-cancelled request context to stay cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error for pre-cancelled item")
}
}
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
+4 -29
View File
@@ -6,8 +6,6 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
var (
@@ -19,42 +17,19 @@ var (
)
func sanitizeFilename(filename string) string {
sanitized := strings.ReplaceAll(filename, "/", " ")
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
sanitized := invalidChars.ReplaceAllString(filename, "_")
var builder strings.Builder
for _, r := range sanitized {
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
builder.WriteRune(r)
}
sanitized = builder.String()
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ". ")
sanitized = strings.Join(strings.Fields(sanitized), " ")
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
sanitized = strings.Trim(sanitized, "_ ")
sanitized = strings.Trim(sanitized, ".")
if !utf8.ValidString(sanitized) {
sanitized = strings.ToValidUTF8(sanitized, "_")
}
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
if len(sanitized) > 200 {
sanitized = sanitized[:200]
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
sanitized = strings.Trim(sanitized, "_ ")
}
if sanitized == "" {
return "Unknown"
sanitized = "untitled"
}
return sanitized
-15
View File
@@ -83,18 +83,3 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestSanitizeFilenameMatchesDesktopSpacingBehavior(t *testing.T) {
got := sanitizeFilename(` "Text In Quotes"?%* / Demo `)
want := "Text In Quotes % Demo"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
got := sanitizeFilename(`<>:"/\|?*`)
if got != "Unknown" {
t.Fatalf("expected %q, got %q", "Unknown", got)
}
}
+2 -15
View File
@@ -16,19 +16,6 @@ import (
"time"
)
func userAgentForURL(u *url.URL) string {
if u == nil {
return getRandomUserAgent()
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
if host == "api.zarz.moe" {
return appUserAgent()
}
return getRandomUserAgent()
}
func getRandomUserAgent() string {
chromeVersion := rand.Intn(26) + 120
chromeBuild := rand.Intn(1500) + 6000
@@ -238,7 +225,7 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
}
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
@@ -268,7 +255,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(reqCopy)
if err != nil {
+1 -1
View File
@@ -11,7 +11,7 @@ func GetCloudflareBypassClient() *http.Client {
}
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
+3 -3
View File
@@ -101,7 +101,7 @@ func GetCloudflareBypassClient() *http.Client {
}
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req)
if err == nil {
@@ -129,7 +129,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
return cloudflareBypassClient.Do(reqCopy)
}
@@ -155,7 +155,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
return cloudflareBypassClient.Do(reqCopy)
}
-26
View File
@@ -39,34 +39,8 @@ var DefaultLyricsProviders = []string{
var (
lyricsProvidersMu sync.RWMutex
lyricsProviders []string // ordered list of enabled providers
appVersionMu sync.RWMutex
appVersion string
)
func SetAppVersion(version string) {
normalized := strings.TrimSpace(version)
appVersionMu.Lock()
defer appVersionMu.Unlock()
appVersion = normalized
}
func GetAppVersion() string {
appVersionMu.RLock()
defer appVersionMu.RUnlock()
return appVersion
}
func appUserAgent() string {
version := GetAppVersion()
if version == "" {
return "SpotiFLAC-Mobile"
}
return "SpotiFLAC-Mobile/" + version
}
type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
+2 -3
View File
@@ -114,7 +114,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
@@ -147,8 +147,7 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+1 -1
View File
@@ -72,7 +72,7 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+2 -2
View File
@@ -70,7 +70,7 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
for k, v := range neteaseHeaders {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -109,7 +109,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
for k, v := range neteaseHeaders {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+1 -1
View File
@@ -54,7 +54,7 @@ func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, dura
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+3 -345
View File
@@ -9,7 +9,6 @@ import (
_ "image/jpeg"
_ "image/png"
"io"
"math"
"os"
"path/filepath"
"regexp"
@@ -1245,281 +1244,6 @@ func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string
return nameValue, dataValue, nil
}
type m4aMetadataPath struct {
moov atomHeader
udta *atomHeader
meta atomHeader
ilst atomHeader
}
func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return m4aMetadataPath{}, fmt.Errorf("moov not found")
}
moovBodyStart := moov.offset + moov.headerSize
moovBodySize := moov.size - moov.headerSize
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
udtaCopy := udta
return m4aMetadataPath{
moov: moov,
udta: &udtaCopy,
meta: meta,
ilst: ilst,
}, nil
}
}
}
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
return m4aMetadataPath{
moov: moov,
meta: meta,
ilst: ilst,
}, nil
}
}
return m4aMetadataPath{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
func buildM4AAtom(typ string, payload []byte) []byte {
size := int64(8 + len(payload))
buf := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(buf[0:4], uint32(size))
copy(buf[4:8], []byte(typ))
copy(buf[8:], payload)
return buf
}
func buildM4AFreeformAtom(name, value string) []byte {
meanPayload := append([]byte{0, 0, 0, 0}, []byte("com.apple.iTunes")...)
namePayload := append([]byte{0, 0, 0, 0}, []byte(name)...)
dataPayload := make([]byte, 8+len(value))
binary.BigEndian.PutUint32(dataPayload[0:4], 1) // UTF-8 text
copy(dataPayload[8:], []byte(value))
payload := append([]byte{}, buildM4AAtom("mean", meanPayload)...)
payload = append(payload, buildM4AAtom("name", namePayload)...)
payload = append(payload, buildM4AAtom("data", dataPayload)...)
return buildM4AAtom("----", payload)
}
func buildITunNORMTag(trackGain, trackPeak string) string {
gainDb, ok := parseReplayGainDb(trackGain)
if !ok {
return ""
}
peakLinear, ok := parseReplayGainPeak(trackPeak)
if !ok {
return ""
}
clamp := func(v int64) int64 {
if v < 0 {
return 0
}
if v > 65534 {
return 65534
}
return v
}
g1 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 1000.0)))
g2 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 2500.0)))
peak := clamp(int64(math.Round(peakLinear * 32768.0)))
values := []int64{g1, g1, g2, g2, 0, 0, peak, peak, 0, 0}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strings.ToUpper(fmt.Sprintf("%08x", value)))
}
return strings.Join(parts, " ")
}
func parseReplayGainDb(value string) (float64, bool) {
match := regexp.MustCompile(`([+-]?\d+(?:\.\d+)?)`).FindStringSubmatch(strings.TrimSpace(value))
if len(match) < 2 {
return 0, false
}
parsed, err := strconv.ParseFloat(match[1], 64)
if err != nil {
return 0, false
}
return parsed, true
}
func parseReplayGainPeak(value string) (float64, bool) {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || parsed <= 0 {
return 0, false
}
return parsed, true
}
func collectM4AReplayGainFields(fields map[string]string) map[string]string {
result := map[string]string{}
if value := strings.TrimSpace(fields["replaygain_track_gain"]); value != "" {
result["replaygain_track_gain"] = value
}
if value := strings.TrimSpace(fields["replaygain_track_peak"]); value != "" {
result["replaygain_track_peak"] = value
}
if value := strings.TrimSpace(fields["replaygain_album_gain"]); value != "" {
result["replaygain_album_gain"] = value
}
if value := strings.TrimSpace(fields["replaygain_album_peak"]); value != "" {
result["replaygain_album_peak"] = value
}
if norm := buildITunNORMTag(result["replaygain_track_gain"], result["replaygain_track_peak"]); norm != "" {
result["iTunNORM"] = norm
}
return result
}
func writeAtomSize(buf []byte, header atomHeader, newSize int64) error {
if newSize <= 0 {
return fmt.Errorf("invalid size for %s", header.typ)
}
if header.headerSize == 16 {
if int(header.offset)+16 > len(buf) {
return io.ErrUnexpectedEOF
}
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], 1)
binary.BigEndian.PutUint64(buf[header.offset+8:header.offset+16], uint64(newSize))
return nil
}
if newSize > math.MaxUint32 {
return fmt.Errorf("atom %s too large for 32-bit header", header.typ)
}
if int(header.offset)+8 > len(buf) {
return io.ErrUnexpectedEOF
}
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], uint32(newSize))
return nil
}
func EditM4AReplayGain(filePath string, fields map[string]string) error {
replayGainFields := collectM4AReplayGainFields(fields)
if len(replayGainFields) == 0 {
return nil
}
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
path, err := findM4AMetadataPath(f, info.Size())
if err != nil {
return err
}
data, err := os.ReadFile(filePath)
if err != nil {
return err
}
bodyStart := path.ilst.offset + path.ilst.headerSize
bodyEnd := path.ilst.offset + path.ilst.size
newBody := make([]byte, 0, int(path.ilst.size))
targets := map[string]struct{}{
"REPLAYGAIN_TRACK_GAIN": {},
"REPLAYGAIN_TRACK_PEAK": {},
"REPLAYGAIN_ALBUM_GAIN": {},
"REPLAYGAIN_ALBUM_PEAK": {},
"ITUNNORM": {},
}
for pos := bodyStart; pos+8 <= bodyEnd; {
header, readErr := readAtomHeaderAt(f, pos, info.Size())
if readErr != nil {
return readErr
}
if header.size == 0 {
header.size = bodyEnd - pos
}
if header.size < header.headerSize {
return fmt.Errorf("invalid atom size for %s", header.typ)
}
keep := true
if header.typ == "----" {
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
if freeformErr == nil {
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
keep = false
}
}
}
if keep {
newBody = append(newBody, data[pos:pos+header.size]...)
}
pos += header.size
}
order := []string{
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
"iTunNORM",
}
for _, key := range order {
value := strings.TrimSpace(replayGainFields[key])
if value == "" {
continue
}
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
}
newIlst := buildM4AAtom("ilst", newBody)
updated := append([]byte{}, data[:path.ilst.offset]...)
updated = append(updated, newIlst...)
updated = append(updated, data[path.ilst.offset+path.ilst.size:]...)
delta := int64(len(newIlst)) - path.ilst.size
if err := writeAtomSize(updated, path.ilst, path.ilst.size+delta); err != nil {
return err
}
if err := writeAtomSize(updated, path.meta, path.meta.size+delta); err != nil {
return err
}
if path.udta != nil {
if err := writeAtomSize(updated, *path.udta, path.udta.size+delta); err != nil {
return err
}
}
if err := writeAtomSize(updated, path.moov, path.moov.size+delta); err != nil {
return err
}
return os.WriteFile(filePath, updated, 0o644)
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
@@ -1699,82 +1423,16 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
// [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := int(buf[22])<<8 | int(buf[23])
if atomType == "alac" {
if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
if alacBitDepth > 0 {
bitDepth = alacBitDepth
}
if alacSampleRate > 0 {
sampleRate = alacSampleRate
}
}
}
if bitDepth <= 0 {
bitDepth = 16
if atomType == "alac" {
bitDepth = 24
}
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
if sampleOffset < 4 {
return 0, 0, false
}
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
if err != nil {
return 0, 0, false
}
childStart := sampleOffset + 32
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
if childStart >= childEnd {
return 0, 0, false
}
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "alac", fileSize)
if err != nil || !found {
return 0, 0, false
}
payloadSize := configHeader.size - configHeader.headerSize
if payloadSize <= 0 {
return 0, 0, false
}
payload := make([]byte, payloadSize)
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
return 0, 0, false
}
return parseALACSpecificConfig(payload)
}
func parseALACSpecificConfig(payload []byte) (int, int, bool) {
if len(payload) < 24 {
return 0, 0, false
}
bitDepth := int(payload[5])
sampleRate := int(binary.BigEndian.Uint32(payload[20:24]))
if bitDepth > 0 && sampleRate > 0 {
return bitDepth, sampleRate, true
}
// Some encoders prepend 4 bytes before the ALACSpecificConfig payload.
if len(payload) >= 28 {
bitDepth = int(payload[9])
sampleRate = int(binary.BigEndian.Uint32(payload[24:28]))
if bitDepth > 0 && sampleRate > 0 {
return bitDepth, sampleRate, true
}
}
return 0, 0, false
}
type atomHeader struct {
offset int64
size int64
-49
View File
@@ -1,49 +0,0 @@
package gobackend
import "testing"
func TestParseALACSpecificConfigStandardPayload(t *testing.T) {
payload := make([]byte, 24)
payload[5] = 24
payload[20] = 0x00
payload[21] = 0x00
payload[22] = 0xac
payload[23] = 0x44
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
if !ok {
t.Fatal("expected standard ALAC payload to parse")
}
if bitDepth != 24 {
t.Fatalf("bitDepth = %d, want 24", bitDepth)
}
if sampleRate != 44100 {
t.Fatalf("sampleRate = %d, want 44100", sampleRate)
}
}
func TestParseALACSpecificConfigPayloadWithLeadingFourBytes(t *testing.T) {
payload := make([]byte, 28)
payload[9] = 16
payload[24] = 0x00
payload[25] = 0x00
payload[26] = 0xbb
payload[27] = 0x80
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
if !ok {
t.Fatal("expected offset ALAC payload to parse")
}
if bitDepth != 16 {
t.Fatalf("bitDepth = %d, want 16", bitDepth)
}
if sampleRate != 48000 {
t.Fatalf("sampleRate = %d, want 48000", sampleRate)
}
}
func TestParseALACSpecificConfigRejectsShortPayload(t *testing.T) {
if _, _, ok := parseALACSpecificConfig(make([]byte, 12)); ok {
t.Fatal("expected short ALAC payload to be rejected")
}
}
+10 -10
View File
@@ -55,7 +55,6 @@ const (
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
qobuzZarzDownloadAPIURL = "https://api.zarz.moe/dl/qbz"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
@@ -106,10 +105,6 @@ type QobuzTrack struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"performer"`
Composer struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"composer"`
}
type qobuzImageSet struct {
@@ -354,7 +349,6 @@ func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata {
AlbumID: qobuzPrefixedID(track.Album.ID),
ArtistID: qobuzTrackArtistID(track),
AlbumType: qobuzTrackAlbumType(track),
Composer: strings.TrimSpace(track.Composer.Name),
}
}
@@ -379,7 +373,6 @@ func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata {
AlbumID: qobuzPrefixedID(track.Album.ID),
AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)),
AlbumType: qobuzTrackAlbumType(track),
Composer: strings.TrimSpace(track.Composer.Name),
}
}
@@ -1140,7 +1133,6 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
func (q *QobuzDownloader) GetAvailableAPIs() []string {
return []string{
qobuzDownloadAPIURL,
qobuzZarzDownloadAPIURL,
qobuzDabMusicAPIURL,
qobuzDeebAPIURL,
qobuzAfkarAPIURL,
@@ -1162,7 +1154,6 @@ const (
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
return []qobuzAPIProvider{
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
{Name: "zarz", URL: qobuzZarzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
@@ -2655,8 +2646,17 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
}
}
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
if track == nil {
errMsg := "could not find matching track on Qobuz without identifier match"
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) {
track = nil
}
}
if track == nil {
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
if err != nil {
errMsg = err.Error()
}
+11 -44
View File
@@ -241,13 +241,12 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
func TestQobuzAvailableProviders(t *testing.T) {
providers := NewQobuzDownloader().GetAvailableProviders()
if len(providers) != 6 {
t.Fatalf("expected 6 Qobuz providers, got %d", len(providers))
if len(providers) != 5 {
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
}
want := map[string]string{
"musicdl": qobuzAPIKindMusicDL,
"zarz": qobuzAPIKindMusicDL,
"dabmusic": qobuzAPIKindStandard,
"deeb": qobuzAPIKindStandard,
"qbz": qobuzAPIKindStandard,
@@ -429,9 +428,11 @@ func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
t.Fatal("ISRC fallback should not run without an ISRC")
return nil, nil
}
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
t.Fatal("metadata fallback should not run")
return nil, nil
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
}
return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
}
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
@@ -446,11 +447,11 @@ func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
}
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
if err == nil {
t.Fatalf("expected error, got track %+v", track)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if track != nil {
t.Fatalf("expected nil track, got %+v", track)
if track == nil || track.ID != 444 || track.Title != "Taste Back" {
t.Fatalf("unexpected resolved track: %+v", track)
}
}
@@ -517,37 +518,3 @@ func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
}
}
func TestQobuzTrackMetadataIncludesComposer(t *testing.T) {
track := &QobuzTrack{
ID: 40681594,
Title: "Sign of the Times",
ISRC: "USSM11703595",
Duration: 340,
TrackNumber: 1,
MediaNumber: 1,
}
track.Performer.ID = 729886
track.Performer.Name = "Harry Styles"
track.Composer.ID = 729886
track.Composer.Name = "Harry Styles"
track.Album.ID = "0886446451985"
track.Album.Title = "Harry Styles"
track.Album.ReleaseDate = "2017-05-12"
track.Album.TracksCount = 10
track.Album.ReleaseType = "album"
track.Album.ProductType = "album"
track.Album.Artist.ID = 729886
track.Album.Artist.Name = "Harry Styles"
track.Album.Artists = []qobuzArtistRef{{ID: 729886, Name: "Harry Styles"}}
trackMeta := qobuzTrackToTrackMetadata(track)
if trackMeta.Composer != "Harry Styles" {
t.Fatalf("track composer = %q", trackMeta.Composer)
}
albumTrackMeta := qobuzTrackToAlbumTrackMetadata(track)
if albumTrackMeta.Composer != "Harry Styles" {
t.Fatalf("album track composer = %q", albumTrackMeta.Composer)
}
}
+3 -4
View File
@@ -147,7 +147,6 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
return nil, fmt.Errorf("failed to create resolve request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := s.client.Do(req)
if err != nil {
@@ -165,9 +164,9 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
}
var resolveResp struct {
Success bool `json:"success"`
ISRC string `json:"isrc"`
SongUrls map[string]json.RawMessage `json:"songUrls"`
Success bool `json:"success"`
ISRC string `json:"isrc"`
SongUrls map[string]json.RawMessage `json:"songUrls"`
}
if err := json.Unmarshal(body, &resolveResp); err != nil {
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
+15 -39
View File
@@ -51,7 +51,6 @@ type TidalTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
ISRC string `json:"isrc"`
Copyright string `json:"copyright"`
AudioQuality string `json:"audioQuality"`
TrackNumber int `json:"trackNumber"`
VolumeNumber int `json:"volumeNumber"`
@@ -136,7 +135,6 @@ type tidalPublicAlbum struct {
Type string `json:"type"`
Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"`
Copyright string `json:"copyright"`
URL string `json:"url"`
NumberOfTracks int `json:"numberOfTracks"`
Explicit bool `json:"explicit"`
@@ -308,29 +306,6 @@ func tidalTrackArtistsDisplay(track *TidalTrack) string {
return strings.TrimSpace(track.Artist.Name)
}
func tidalTrackAlbumArtistDisplay(track *TidalTrack) string {
if track == nil {
return ""
}
if len(track.Artists) > 0 {
names := make([]string, 0, len(track.Artists))
for _, artist := range track.Artists {
if strings.ToUpper(strings.TrimSpace(artist.Type)) != "MAIN" {
continue
}
if trimmed := strings.TrimSpace(artist.Name); trimmed != "" {
names = append(names, trimmed)
}
}
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
return strings.TrimSpace(track.Artist.Name)
}
func tidalAlbumArtistsDisplay(album *tidalPublicAlbum) string {
if album == nil {
return ""
@@ -379,7 +354,7 @@ func tidalTrackToTrackMetadata(track *TidalTrack) TrackMetadata {
Artists: tidalTrackArtistsDisplay(track),
Name: strings.TrimSpace(track.Title),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: tidalTrackAlbumArtistDisplay(track),
AlbumArtist: strings.TrimSpace(track.Artist.Name),
DurationMS: track.Duration * 1000,
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
@@ -402,7 +377,7 @@ func tidalTrackToAlbumTrackMetadata(track *TidalTrack) AlbumTrackMetadata {
Artists: tidalTrackArtistsDisplay(track),
Name: strings.TrimSpace(track.Title),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: tidalTrackAlbumArtistDisplay(track),
AlbumArtist: strings.TrimSpace(track.Artist.Name),
DurationMS: track.Duration * 1000,
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
@@ -432,7 +407,6 @@ func tidalAlbumToAlbumInfo(album *tidalPublicAlbum) AlbumInfoMetadata {
Artists: tidalAlbumArtistsDisplay(album),
ArtistId: artistID,
Images: tidalImageURL(album.Cover, "1280x1280"),
Copyright: strings.TrimSpace(album.Copyright),
}
}
@@ -714,10 +688,6 @@ func findTidalArtistPageModule(page *tidalPublicArtistPage, moduleType string) *
func (t *TidalDownloader) GetAvailableAPIs() []string {
return []string{
"https://eu-central.monochrome.tf",
"https://us-west.monochrome.tf",
"https://api.monochrome.tf",
"https://monochrome-api.samidy.com",
"https://tidal-api.binimum.org",
"https://tidal.kinoplus.online",
"https://triton.squid.wtf",
@@ -1766,7 +1736,6 @@ type TidalDownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
Copyright string
LyricsLRC string // LRC content for embedding in converted files
}
@@ -2080,6 +2049,18 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
}
}
if !gotTidalID && req.ISRC != "" {
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
trackID = directTrack.ID
gotTidalID = true
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
} else if directErr != nil {
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
}
}
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
@@ -2375,10 +2356,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if actualDiscNumber == 0 {
actualDiscNumber = track.VolumeNumber
}
copyright := strings.TrimSpace(req.Copyright)
if copyright == "" {
copyright = strings.TrimSpace(track.Copyright)
}
metadata := Metadata{
Title: req.TrackName,
@@ -2394,7 +2371,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: copyright,
Copyright: req.Copyright,
Composer: req.Composer,
}
@@ -2505,7 +2482,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
Copyright: copyright,
LyricsLRC: lyricsLRC,
}, nil
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 70 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+10 -60
View File
@@ -22,9 +22,6 @@ import Gobackend // Import Go framework
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
GobackendSetAppVersion(version)
}
let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(
@@ -69,59 +66,9 @@ import Gobackend // Import Go framework
)
GeneratedPluginRegistrant.register(with: self)
if let url = launchOptions?[.url] as? URL {
_ = handleExtensionOAuthRedirect(url: url)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
@discardableResult
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
let host = (url.host ?? "").lowercased()
let path = url.path.lowercased()
let ok =
host == "callback" || host == "spotify-callback" || path.contains("callback")
guard ok else { return false }
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return false
}
let q = components.queryItems ?? []
let code =
q.first { $0.name == "code" }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
let state =
q.first { $0.name == "state" }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
if code.isEmpty { return false }
if state.isEmpty {
NSLog("SpotiFLAC: Extension OAuth redirect missing state (extension id)")
return false
}
streamQueue.async {
var err: NSError?
GobackendSetExtensionAuthCodeByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
if let err = err {
NSLog(
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
}
}
return true
}
override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if handleExtensionOAuthRedirect(url: url) {
return true
}
return super.application(app, open: url, options: options)
}
deinit {
stopDownloadProgressStream()
stopLibraryScanProgressStream()
@@ -424,6 +371,16 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "searchDeezerAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let filter = args["filter"] as? String ?? ""
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error }
return response
case "searchTidalAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
@@ -650,13 +607,6 @@ import Gobackend // Import Go framework
let response = GobackendGetProviderPriorityJSON(&error)
if let error = error { throw error }
return response
case "setDownloadFallbackExtensionIds":
let args = call.arguments as! [String: Any]
let extensionIdsJson = args["extension_ids"] as? String ?? ""
GobackendSetExtensionFallbackProviderIDsJSON(extensionIdsJson, &error)
if let error = error { throw error }
return nil
case "setMetadataProviderPriority":
let args = call.arguments as! [String: Any]
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '4.3.1';
static const String buildNumber = '126';
static const String version = '4.2.1';
static const String buildNumber = '122';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
-48
View File
@@ -352,18 +352,6 @@ abstract class AppLocalizations {
/// **'Using extension: {extensionName}'**
String optionsUsingExtension(String extensionName);
/// Title for the preferred default search tab setting
///
/// In en, this message translates to:
/// **'Default Search Tab'**
String get optionsDefaultSearchTab;
/// Subtitle for the preferred default search tab setting
///
/// In en, this message translates to:
/// **'Choose which tab opens first for new search results.'**
String get optionsDefaultSearchTabSubtitle;
/// Hint to switch back to built-in providers
///
/// In en, this message translates to:
@@ -730,12 +718,6 @@ abstract class AppLocalizations {
/// **'PC source code'**
String get aboutPCSource;
/// Link to Keep Android Open campaign website
///
/// In en, this message translates to:
/// **'Keep Android Open'**
String get aboutKeepAndroidOpen;
/// Link to report bugs
///
/// In en, this message translates to:
@@ -1756,24 +1738,6 @@ abstract class AppLocalizations {
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
String get providerPriorityInfo;
/// Section title for choosing which download extensions can be used as fallback providers
///
/// In en, this message translates to:
/// **'Extension Fallback'**
String get providerPriorityFallbackExtensionsTitle;
/// Section description for extension fallback selection
///
/// In en, this message translates to:
/// **'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.'**
String get providerPriorityFallbackExtensionsDescription;
/// Hint below the extension fallback selection list
///
/// In en, this message translates to:
/// **'Only enabled extensions with download-provider capability are listed here.'**
String get providerPriorityFallbackExtensionsHint;
/// Label for built-in providers (Tidal/Qobuz)
///
/// In en, this message translates to:
@@ -2680,18 +2644,6 @@ abstract class AppLocalizations {
/// **'Set download service order'**
String get extensionsDownloadPrioritySubtitle;
/// Setting and page title for choosing which download extensions can be used during fallback
///
/// In en, this message translates to:
/// **'Fallback Extensions'**
String get extensionsFallbackTitle;
/// Subtitle for download fallback extensions menu
///
/// In en, this message translates to:
/// **'Choose which installed download extensions can be used as fallback'**
String get extensionsFallbackSubtitle;
/// Empty state - no download providers
///
/// In en, this message translates to:
-28
View File
@@ -129,13 +129,6 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Erweiterung verwenden: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tippe auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln';
@@ -348,9 +341,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutPCSource => 'PC Quellcode';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Problem melden';
@@ -950,17 +940,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get providerPriorityInfo =>
'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Integriert';
@@ -1459,13 +1438,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle =>
'Download-Service-Reihenfolge festlegen';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'Keine Erweiterungen mit Download-Provider';
-28
View File
@@ -127,13 +127,6 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -341,9 +334,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -936,17 +926,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1436,13 +1415,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
-31
View File
@@ -127,13 +127,6 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -341,9 +334,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -936,17 +926,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1436,13 +1415,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
@@ -3717,9 +3689,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get aboutPCSource => 'Código fuente de PC';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Reportar un problema';
-28
View File
@@ -128,13 +128,6 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Utilisation de l\'extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
@@ -343,9 +336,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -938,17 +928,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1438,13 +1417,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
-28
View File
@@ -127,13 +127,6 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -341,9 +334,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -936,17 +926,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1436,13 +1415,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
-28
View File
@@ -129,13 +129,6 @@ class AppLocalizationsId extends AppLocalizations {
return 'Menggunakan ekstensi: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Tab Pencarian Default';
@override
String get optionsDefaultSearchTabSubtitle =>
'Pilih tab yang dibuka lebih dulu untuk hasil pencarian baru.';
@override
String get optionsSwitchBack =>
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
@@ -344,9 +337,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutPCSource => 'Kode sumber PC';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Laporkan masalah';
@@ -940,17 +930,6 @@ class AppLocalizationsId extends AppLocalizations {
String get providerPriorityInfo =>
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Fallback Ekstensi';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.';
@override
String get providerBuiltIn => 'Bawaan';
@@ -1444,13 +1423,6 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle =>
'Atur urutan layanan unduhan';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback';
@override
String get extensionsNoDownloadProvider =>
'Tidak ada ekstensi dengan provider unduhan';
-28
View File
@@ -127,13 +127,6 @@ class AppLocalizationsJa extends AppLocalizations {
return '拡張の使用: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -337,9 +330,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutPCSource => 'PC 版のソースコード';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => '問題を報告する';
@@ -930,17 +920,6 @@ class AppLocalizationsJa extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => '内蔵';
@@ -1430,13 +1409,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません';
-28
View File
@@ -125,13 +125,6 @@ class AppLocalizationsKo extends AppLocalizations {
return '확장 기능을 사용: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
@@ -330,9 +323,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutPCSource => 'PC 소스 코드';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => '문제 신고';
@@ -918,17 +908,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1416,13 +1395,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
-28
View File
@@ -127,13 +127,6 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -341,9 +334,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -936,17 +926,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1436,13 +1415,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
-31
View File
@@ -127,13 +127,6 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -341,9 +334,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -936,17 +926,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1436,13 +1415,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
@@ -3717,9 +3689,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get aboutPCSource => 'Código-fonte do app desktop';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Reportar um problema';
-28
View File
@@ -129,13 +129,6 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Используется расширение: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Нажмите Deezer или Spotify для возврата с расширения';
@@ -347,9 +340,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutPCSource => 'Исходный код ПК версии';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Сообщить о проблеме';
@@ -950,17 +940,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get providerPriorityInfo =>
'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Встроенные';
@@ -1460,13 +1439,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle =>
'Установка порядок сервисов скачивания';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'Нет расширений с провайдером загрузки';
File diff suppressed because it is too large Load Diff
-34
View File
@@ -127,13 +127,6 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -341,9 +334,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -936,17 +926,6 @@ class AppLocalizationsZh extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1436,13 +1415,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
@@ -3699,9 +3671,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get aboutPCSource => '桌面版本源代码';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => '报告一个问题';
@@ -6096,9 +6065,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
-4
View File
@@ -378,10 +378,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Problem melden",
"@aboutReportIssue": {
"description": "Link to report bugs"
-32
View File
@@ -158,14 +158,6 @@
}
}
},
"optionsDefaultSearchTab": "Default Search Tab",
"@optionsDefaultSearchTab": {
"description": "Title for the preferred default search tab setting"
},
"optionsDefaultSearchTabSubtitle": "Choose which tab opens first for new search results.",
"@optionsDefaultSearchTabSubtitle": {
"description": "Subtitle for the preferred default search tab setting"
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
@@ -430,10 +422,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
@@ -1215,18 +1203,6 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerPriorityFallbackExtensionsTitle": "Extension Fallback",
"@providerPriorityFallbackExtensionsTitle": {
"description": "Section title for choosing which download extensions can be used as fallback providers"
},
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
"@providerPriorityFallbackExtensionsDescription": {
"description": "Section description for extension fallback selection"
},
"providerPriorityFallbackExtensionsHint": "Only enabled extensions with download-provider capability are listed here.",
"@providerPriorityFallbackExtensionsHint": {
"description": "Hint below the extension fallback selection list"
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
@@ -1881,14 +1857,6 @@
"@extensionsDownloadPrioritySubtitle": {
"description": "Subtitle for download priority"
},
"extensionsFallbackTitle": "Fallback Extensions",
"@extensionsFallbackTitle": {
"description": "Setting and page title for choosing which download extensions can be used during fallback"
},
"extensionsFallbackSubtitle": "Choose which installed download extensions can be used as fallback",
"@extensionsFallbackSubtitle": {
"description": "Subtitle for download fallback extensions menu"
},
"extensionsNoDownloadProvider": "No extensions with download provider",
"@extensionsNoDownloadProvider": {
"description": "Empty state - no download providers"
-4
View File
@@ -362,10 +362,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
-4
View File
@@ -378,10 +378,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Reportar un problema",
"@aboutReportIssue": {
"description": "Link to report bugs"
-4
View File
@@ -378,10 +378,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
-4
View File
@@ -378,10 +378,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
-32
View File
@@ -150,14 +150,6 @@
}
}
},
"optionsDefaultSearchTab": "Tab Pencarian Default",
"@optionsDefaultSearchTab": {
"description": "Title for the preferred default search tab setting"
},
"optionsDefaultSearchTabSubtitle": "Pilih tab yang dibuka lebih dulu untuk hasil pencarian baru.",
"@optionsDefaultSearchTabSubtitle": {
"description": "Subtitle for the preferred default search tab setting"
},
"optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
@@ -390,10 +382,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Laporkan masalah",
"@aboutReportIssue": {
"description": "Link to report bugs"
@@ -1131,18 +1119,6 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerPriorityFallbackExtensionsTitle": "Fallback Ekstensi",
"@providerPriorityFallbackExtensionsTitle": {
"description": "Section title for choosing which download extensions can be used as fallback providers"
},
"providerPriorityFallbackExtensionsDescription": "Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.",
"@providerPriorityFallbackExtensionsDescription": {
"description": "Section description for extension fallback selection"
},
"providerPriorityFallbackExtensionsHint": "Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.",
"@providerPriorityFallbackExtensionsHint": {
"description": "Hint below the extension fallback selection list"
},
"providerBuiltIn": "Bawaan",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
@@ -1737,14 +1713,6 @@
"@extensionsDownloadPrioritySubtitle": {
"description": "Subtitle for download priority"
},
"extensionsFallbackTitle": "Fallback Extensions",
"@extensionsFallbackTitle": {
"description": "Setting and page title for choosing which download extensions can be used during fallback"
},
"extensionsFallbackSubtitle": "Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback",
"@extensionsFallbackSubtitle": {
"description": "Subtitle for download fallback extensions menu"
},
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
"@extensionsNoDownloadProvider": {
"description": "Empty state - no download providers"
-4
View File
@@ -378,10 +378,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "問題を報告する",
"@aboutReportIssue": {
"description": "Link to report bugs"
-4
View File
@@ -378,10 +378,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "문제 신고",
"@aboutReportIssue": {
"description": "Link to report bugs"
-4
View File
@@ -378,10 +378,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
-4
View File
@@ -362,10 +362,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
-4
View File
@@ -378,10 +378,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Reportar um problema",
"@aboutReportIssue": {
"description": "Link to report bugs"
-4
View File
@@ -378,10 +378,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Сообщить о проблеме",
"@aboutReportIssue": {
"description": "Link to report bugs"
+587 -1444
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -362,10 +362,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
-4
View File
@@ -378,10 +378,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "报告一个问题",
"@aboutReportIssue": {
"description": "Link to report bugs"
-4
View File
@@ -378,10 +378,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
-11
View File
@@ -33,9 +33,7 @@ class AppSettings {
final bool askQualityBeforeDownload;
final bool enableLogging;
final bool useExtensionProviders;
final List<String>? downloadFallbackExtensionIds;
final String? searchProvider;
final String defaultSearchTab;
final String? homeFeedProvider;
final bool separateSingles;
final String singleFilenameFormat;
@@ -110,9 +108,7 @@ class AppSettings {
this.askQualityBeforeDownload = true,
this.enableLogging = false,
this.useExtensionProviders = true,
this.downloadFallbackExtensionIds,
this.searchProvider,
this.defaultSearchTab = 'all',
this.homeFeedProvider,
this.separateSingles = false,
this.singleFilenameFormat = '{title} - {artist}',
@@ -174,11 +170,8 @@ class AppSettings {
bool? askQualityBeforeDownload,
bool? enableLogging,
bool? useExtensionProviders,
List<String>? downloadFallbackExtensionIds,
bool clearDownloadFallbackExtensionIds = false,
String? searchProvider,
bool clearSearchProvider = false,
String? defaultSearchTab,
String? homeFeedProvider,
bool clearHomeFeedProvider = false,
bool? separateSingles,
@@ -239,13 +232,9 @@ class AppSettings {
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders:
useExtensionProviders ?? this.useExtensionProviders,
downloadFallbackExtensionIds: clearDownloadFallbackExtensionIds
? null
: (downloadFallbackExtensionIds ?? this.downloadFallbackExtensionIds),
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
defaultSearchTab: defaultSearchTab ?? this.defaultSearchTab,
homeFeedProvider: clearHomeFeedProvider
? null
: (homeFeedProvider ?? this.homeFeedProvider),
-7
View File
@@ -35,12 +35,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
downloadFallbackExtensionIds:
(json['downloadFallbackExtensionIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
searchProvider: json['searchProvider'] as String?,
defaultSearchTab: json['defaultSearchTab'] as String? ?? 'all',
homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
singleFilenameFormat:
@@ -110,9 +105,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders,
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
'searchProvider': instance.searchProvider,
'defaultSearchTab': instance.defaultSearchTab,
'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles,
'singleFilenameFormat': instance.singleFilenameFormat,
File diff suppressed because it is too large Load Diff
+18 -115
View File
@@ -20,6 +20,7 @@ class Extension {
final String name;
final String displayName;
final String version;
final String author;
final String description;
final bool enabled;
final String status;
@@ -44,6 +45,7 @@ class Extension {
required this.name,
required this.displayName,
required this.version,
required this.author,
required this.description,
required this.enabled,
required this.status,
@@ -71,6 +73,7 @@ class Extension {
displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '',
enabled: json['enabled'] as bool? ?? false,
status: json['status'] as String? ?? 'loaded',
@@ -121,6 +124,7 @@ class Extension {
String? name,
String? displayName,
String? version,
String? author,
String? description,
bool? enabled,
String? status,
@@ -145,6 +149,7 @@ class Extension {
name: name ?? this.name,
displayName: displayName ?? this.displayName,
version: version ?? this.version,
author: author ?? this.author,
description: description ?? this.description,
enabled: enabled ?? this.enabled,
status: status ?? this.status,
@@ -173,26 +178,6 @@ class Extension {
bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
String? get preferredDownloadOutputExtension {
final value = capabilities['downloadOutputExtension'];
if (value is! String) return null;
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
List<String> get preservedNativeOutputExtensions {
final value = capabilities['preserveNativeOutputExtensions'];
if (value is! List) return const [];
final normalized = <String>[];
for (final item in value) {
if (item is! String) continue;
final trimmed = item.trim().toLowerCase();
if (trimmed.isEmpty) continue;
normalized.add(trimmed.startsWith('.') ? trimmed : '.$trimmed');
}
return normalized;
}
}
class SearchFilter {
@@ -496,10 +481,8 @@ class ExtensionState {
}
class ExtensionNotifier extends Notifier<ExtensionState> {
static const _builtInMetadataProviders = ['qobuz', 'tidal'];
AppLifecycleListener? _appLifecycleListener;
bool _cleanupInFlight = false;
Completer<void>? _initializationCompleter;
@override
ExtensionState build() {
@@ -537,13 +520,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> initialize(String extensionsDir, String dataDir) async {
if (state.isInitialized) return;
if (_initializationCompleter != null) {
await _initializationCompleter!.future;
return;
}
final completer = Completer<void>();
_initializationCompleter = completer;
state = state.copyWith(isLoading: true, error: null);
@@ -555,8 +531,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
error: null,
);
_log.i('Extension system disabled on this platform');
completer.complete();
_initializationCompleter = null;
return;
}
@@ -570,32 +544,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} catch (e) {
_log.e('Failed to initialize extension system: $e');
state = state.copyWith(isLoading: false, error: e.toString());
} finally {
if (!completer.isCompleted) {
completer.complete();
}
if (identical(_initializationCompleter, completer)) {
_initializationCompleter = null;
}
}
}
Future<void> waitForInitialization({
Duration timeout = const Duration(seconds: 30),
}) async {
if (state.isInitialized || !PlatformBridge.supportsExtensionSystem) {
return;
}
final future = _initializationCompleter?.future;
if (future == null) {
return;
}
try {
await future.timeout(timeout);
} on TimeoutException {
_log.w('Timed out waiting for extension initialization after $timeout');
}
}
@@ -618,7 +566,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
final list = await PlatformBridge.getInstalledExtensions();
final extensions = list.map((e) => Extension.fromJson(e)).toList();
state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
_log.d('Loaded ${extensions.length} extensions');
for (final ext in extensions) {
@@ -714,7 +661,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}).toList();
state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
if (!enabled && ext != null) {
final settings = ref.read(settingsProvider);
@@ -739,23 +685,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
Future<void> _reconcileDownloadProviderPriority() async {
if (state.providerPriority.isEmpty) {
return;
}
final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority);
if (jsonEncode(sanitized) == jsonEncode(state.providerPriority)) {
return;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
await PlatformBridge.setProviderPriority(sanitized);
state = state.copyWith(providerPriority: sanitized);
_log.d('Reconciled provider priority after extension update: $sanitized');
}
Future<bool> ensureSpotifyWebExtensionReady({
bool setAsSearchProvider = true,
}) async {
@@ -883,7 +812,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> _sanitizeDownloadProviderPriority(List<String> input) {
final allowed = getAllDownloadProviders().toSet();
final preferredOrder = getAllDownloadProviders();
final result = <String>[];
for (final provider in input) {
@@ -892,7 +820,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
for (final provider in preferredOrder) {
for (final provider in const ['tidal', 'qobuz', 'deezer']) {
if (!result.contains(provider)) {
result.add(provider);
}
@@ -919,15 +847,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
);
await PlatformBridge.setMetadataProviderPriority(priority);
} else {
final backendPriority =
await PlatformBridge.getMetadataProviderPriority();
priority = _sanitizeMetadataProviderPriority(backendPriority);
_log.d('Using default metadata provider priority: $priority');
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(priority),
priority = _sanitizeMetadataProviderPriority(
await PlatformBridge.getMetadataProviderPriority(),
);
await PlatformBridge.setMetadataProviderPriority(priority);
_log.d('Using default metadata provider priority: $priority');
}
state = state.copyWith(metadataProviderPriority: priority);
@@ -973,7 +896,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz'];
final providers = ['tidal', 'qobuz', 'deezer'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasDownloadProvider) {
providers.add(ext.id);
@@ -983,26 +906,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllMetadataProviders() {
final metadataExtensions = state.extensions
.where((ext) => ext.enabled && ext.hasMetadataProvider)
.toList();
final primarySearchMetadataExtensions = metadataExtensions
.where((ext) => ext.searchBehavior?.primary == true)
.map((ext) => ext.id);
final otherMetadataExtensions = metadataExtensions
.where((ext) => ext.searchBehavior?.primary != true)
.map((ext) => ext.id);
return [
...primarySearchMetadataExtensions,
..._builtInMetadataProviders,
...otherMetadataExtensions,
];
final providers = ['deezer', 'qobuz', 'tidal'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasMetadataProvider) {
providers.add(ext.id);
}
}
return providers;
}
List<String> _sanitizeMetadataProviderPriority(List<String> input) {
final allowed = getAllMetadataProviders().toSet();
final preferredOrder = getAllMetadataProviders();
final result = <String>[];
for (final provider in input) {
@@ -1011,18 +925,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
final hasPreferredExtension = preferredOrder.any(
(provider) => !_builtInMetadataProviders.contains(provider),
);
final hasSavedExtension = result.any(
(provider) => !_builtInMetadataProviders.contains(provider),
);
if (!hasSavedExtension && hasPreferredExtension) {
return List<String>.from(preferredOrder);
}
for (final provider in preferredOrder) {
for (final provider in const ['deezer', 'qobuz', 'tidal']) {
if (!result.contains(provider)) {
result.add(provider);
}
+4 -69
View File
@@ -12,13 +12,12 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 10;
const _currentMigrationVersion = 9;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
static const Set<String> _searchTabValues = {'all', 'track', 'artist', 'album'};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -36,23 +35,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
final prefs = await _prefs;
final json = prefs.getString(_settingsKey);
if (json != null) {
final loaded = AppSettings.fromJson(
state = AppSettings.fromJson(
Map<String, dynamic>.from(jsonDecode(json) as Map),
);
final sanitizedDownloadFallbackExtensionIds =
_sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds,
);
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
loaded.defaultSearchTab,
);
state = loaded.copyWith(
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null,
defaultSearchTab: sanitizedDefaultSearchTab,
);
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
@@ -65,7 +50,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_syncLyricsSettingsToBackend();
_syncNetworkCompatibilitySettingsToBackend();
_syncExtensionFallbackSettingsToBackend();
}
void _syncLyricsSettingsToBackend() {
@@ -99,16 +83,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
});
}
void _syncExtensionFallbackSettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setDownloadFallbackExtensionIds(
state.downloadFallbackExtensionIds,
).catchError((Object e) {
_log.w('Failed to sync extension fallback settings to backend: $e');
});
}
Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
@@ -137,9 +111,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
// Migration 7/10: retired built-in services reset back to Tidal
if (state.defaultService == 'youtube' ||
state.defaultService == 'deezer') {
// Migration 7: YouTube is no longer a built-in service reset to Tidal
if (state.defaultService == 'youtube') {
state = state.copyWith(defaultService: 'tidal');
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
@@ -192,12 +165,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
return 'US';
}
String _normalizeDefaultSearchTab(String value) {
final normalized = value.trim().toLowerCase();
if (_searchTabValues.contains(normalized)) return normalized;
return 'all';
}
Future<void> _normalizeSongLinkRegionIfNeeded() async {
final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
if (normalized == state.songLinkRegion) return;
@@ -205,22 +172,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
List<String>? _sanitizeDownloadFallbackExtensionIds(List<String>? ids) {
if (ids == null) {
return null;
}
final result = <String>[];
for (final id in ids) {
final normalized = id.trim();
if (normalized.isEmpty || result.contains(normalized)) {
continue;
}
result.add(normalized);
}
return result;
}
Future<void> _cleanupRetiredSpotifySettings() async {
final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
@@ -419,11 +370,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setDefaultSearchTab(String tab) {
state = state.copyWith(defaultSearchTab: _normalizeDefaultSearchTab(tab));
_saveSettings();
}
void setHomeFeedProvider(String? provider) {
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearHomeFeedProvider: true);
@@ -444,17 +390,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setDownloadFallbackExtensionIds(List<String>? extensionIds) {
final sanitized = _sanitizeDownloadFallbackExtensionIds(extensionIds);
state = state.copyWith(
downloadFallbackExtensionIds: sanitized,
clearDownloadFallbackExtensionIds:
extensionIds == null && state.downloadFallbackExtensionIds != null,
);
_saveSettings();
_syncExtensionFallbackSettingsToBackend();
}
void setSeparateSingles(bool enabled) {
state = state.copyWith(separateSingles: enabled);
_saveSettings();
+4
View File
@@ -63,6 +63,7 @@ class StoreExtension {
final String name;
final String displayName;
final String version;
final String author;
final String description;
final String downloadUrl;
final String? iconUrl;
@@ -80,6 +81,7 @@ class StoreExtension {
required this.name,
required this.displayName,
required this.version,
required this.author,
required this.description,
required this.downloadUrl,
this.iconUrl,
@@ -100,6 +102,7 @@ class StoreExtension {
displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '',
downloadUrl: json['download_url'] as String? ?? '',
iconUrl: json['icon_url'] as String?,
@@ -191,6 +194,7 @@ class StoreState {
e.name.toLowerCase().contains(query) ||
e.displayName.toLowerCase().contains(query) ||
e.description.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query)),
)
.toList();
+38 -141
View File
@@ -7,7 +7,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('TrackProvider');
const _extensionInitRetryTimeout = Duration(seconds: 30);
class TrackState {
final List<Track> tracks;
@@ -204,36 +203,13 @@ class TrackNotifier extends Notifier<TrackState> {
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
bool _usesBuiltInUrlResolver(String url) {
final normalized = url.toLowerCase();
return normalized.contains('deezer.com') ||
normalized.contains('deezer.page.link') ||
normalized.contains('qobuz.com') ||
normalized.startsWith('qobuzapp://') ||
normalized.contains('tidal.com');
}
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
final requestId = ++_currentRequestId;
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
var extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler == null && !_usesBuiltInUrlResolver(url)) {
final extensionState = ref.read(extensionProvider);
if (!extensionState.isInitialized && extensionState.isLoading) {
_log.i(
'Extension URL handlers not ready yet, waiting for initialization...',
);
await ref
.read(extensionProvider.notifier)
.waitForInitialization(timeout: _extensionInitRetryTimeout);
if (!_isRequestValid(requestId)) return;
extensionHandler = await PlatformBridge.findURLHandler(url);
}
}
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
@@ -583,99 +559,8 @@ class TrackNotifier extends Notifier<TrackState> {
String? builtInSearchProvider,
}) async {
final requestId = ++_currentRequestId;
final currentFilter = filterOverride ?? state.selectedSearchFilter;
final requestFilter = currentFilter == 'all' ? null : currentFilter;
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
String? resolvedProvider = builtInSearchProvider;
if (resolvedProvider == null || resolvedProvider.isEmpty) {
final explicitProvider = settings.searchProvider?.trim();
if (explicitProvider != null && explicitProvider.isNotEmpty) {
resolvedProvider = explicitProvider;
} else {
resolvedProvider =
extensionState.extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.map((ext) => ext.id)
.firstOrNull ??
extensionState.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.map((ext) => ext.id)
.firstOrNull;
}
resolvedProvider ??= 'tidal';
}
final isEnabledExtensionProvider =
resolvedProvider.isNotEmpty &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.id == resolvedProvider,
);
if (resolvedProvider.isNotEmpty &&
resolvedProvider != 'tidal' &&
resolvedProvider != 'qobuz' &&
!isEnabledExtensionProvider &&
settings.searchProvider?.trim() == resolvedProvider) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
resolvedProvider =
extensionState.extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.map((ext) => ext.id)
.firstOrNull ??
extensionState.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.map((ext) => ext.id)
.firstOrNull;
resolvedProvider ??= 'tidal';
}
if (resolvedProvider.isNotEmpty &&
resolvedProvider != 'tidal' &&
resolvedProvider != 'qobuz' &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.id == resolvedProvider,
)) {
final resolvedFilter = requestFilter ?? 'track';
Map<String, dynamic>? options;
options = {'filter': resolvedFilter};
await customSearch(
resolvedProvider,
query,
options: options,
selectedFilter: resolvedFilter,
);
return;
}
final effectiveBuiltInProvider =
resolvedProvider == 'tidal' || resolvedProvider == 'qobuz'
? resolvedProvider
: (builtInSearchProvider?.isNotEmpty == true
? builtInSearchProvider
: 'tidal');
if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) {
state = TrackState(
isLoading: false,
error: 'No active search provider available',
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
);
return;
}
state = TrackState(
isLoading: true,
@@ -685,21 +570,42 @@ class TrackNotifier extends Notifier<TrackState> {
);
try {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final hasActiveMetadataExtensions = extensionState.extensions.any(
(e) => e.enabled && e.hasMetadataProvider,
);
final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions;
final effectiveProvider = effectiveBuiltInProvider;
final effectiveProvider = builtInSearchProvider ?? 'deezer';
_log.i(
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter',
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
);
Map<String, dynamic> results;
List<Map<String, dynamic>> metadataTrackResults = [];
if (effectiveProvider == 'deezer') {
try {
_log.d('Calling metadata provider search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
_log.i(
'Metadata providers returned ${metadataTrackResults.length} tracks',
);
} catch (e) {
_log.w(
'Metadata provider search failed, falling back to Deezer tracks: $e',
);
}
}
switch (effectiveProvider) {
case 'tidal':
_log.d('Calling Tidal search API...');
@@ -707,7 +613,7 @@ class TrackNotifier extends Notifier<TrackState> {
query,
trackLimit: 20,
artistLimit: 2,
filter: requestFilter,
filter: currentFilter,
);
break;
case 'qobuz':
@@ -716,23 +622,17 @@ class TrackNotifier extends Notifier<TrackState> {
query,
trackLimit: 20,
artistLimit: 2,
filter: requestFilter,
filter: currentFilter,
);
break;
default:
_log.d('Calling metadata provider track search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
results = const <String, List<dynamic>>{
'tracks': <dynamic>[],
'artists': <dynamic>[],
'albums': <dynamic>[],
'playlists': <dynamic>[],
};
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
break;
}
_log.i(
@@ -841,16 +741,14 @@ class TrackNotifier extends Notifier<TrackState> {
String extensionId,
String query, {
Map<String, dynamic>? options,
String? selectedFilter,
}) async {
final requestId = ++_currentRequestId;
final currentFilter = selectedFilter ?? state.selectedSearchFilter;
state = TrackState(
isLoading: true,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
selectedSearchFilter: state.selectedSearchFilter,
);
try {
@@ -890,7 +788,7 @@ class TrackNotifier extends Notifier<TrackState> {
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
searchExtensionId: extensionId,
selectedSearchFilter: currentFilter,
selectedSearchFilter: state.selectedSearchFilter,
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
@@ -900,7 +798,6 @@ class TrackNotifier extends Notifier<TrackState> {
error: e.toString(),
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
);
}
}
@@ -1011,7 +908,7 @@ class TrackNotifier extends Notifier<TrackState> {
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date'] as String?,
albumType: normalizeOptionalString(data['album_type']?.toString()),
albumType: data['album_type'] as String?,
totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
);
@@ -1048,7 +945,7 @@ class TrackNotifier extends Notifier<TrackState> {
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
source: effectiveSource,
albumType: normalizeOptionalString(data['album_type']?.toString()),
albumType: data['album_type']?.toString(),
composer: data['composer']?.toString(),
itemType: itemType,
);
+20 -77
View File
@@ -75,8 +75,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
String? _error;
bool _showTitleInAppBar = false;
String? _artistId;
String? _albumType;
int? _albumTotalTracks;
final ScrollController _scrollController = ScrollController();
@override
@@ -114,8 +112,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_tracks = _AlbumCache.get(widget.albumId);
}
_artistId = widget.artistId;
_albumType = _tracks?.firstOrNull?.albumType;
_albumTotalTracks = _tracks?.firstOrNull?.totalTracks;
if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks();
@@ -183,22 +179,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
deezerAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks);
@@ -206,8 +193,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() {
_tracks = tracks;
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false;
});
}
@@ -219,22 +204,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
qobuzAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks);
@@ -242,8 +218,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() {
_tracks = tracks;
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false;
});
}
@@ -255,22 +229,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
tidalAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks);
@@ -278,8 +243,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() {
_tracks = tracks;
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false;
});
}
@@ -292,22 +255,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = result['album'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks);
@@ -315,8 +269,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() {
_tracks = tracks;
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false;
});
}
@@ -332,11 +284,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
Track _parseTrack(
Map<String, dynamic> data, {
String? albumTypeFallback,
int? totalTracksFallback,
}) {
Track _parseTrack(Map<String, dynamic> data) {
return Track(
id: data['spotify_id'] as String? ?? '',
name: data['name'] as String? ?? '',
@@ -353,14 +301,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date'] as String?,
albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
albumTypeFallback ??
_albumType,
totalTracks:
data['total_tracks'] as int? ??
totalTracksFallback ??
_albumTotalTracks,
albumType: data['album_type'] as String?,
totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
);
}
@@ -371,6 +313,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
if (widget.albumId.startsWith('tidal:')) return 'tidal';
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
if (widget.albumId.startsWith('deezer:')) return 'deezer';
return null;
}
+3 -7
View File
@@ -159,6 +159,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
if (widget.artistId.startsWith('tidal:')) return 'tidal';
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
if (widget.artistId.startsWith('deezer:')) return 'deezer';
return null;
}
@@ -411,9 +412,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(),
albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
album?.albumType,
albumType: data['album_type']?.toString() ?? album?.albumType,
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
composer: data['composer']?.toString(),
source: data['provider_id']?.toString() ?? widget.extensionId,
@@ -1058,10 +1057,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
final parsedTracks = tracksList
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
return parsedTracks;
}
} else if (album.id.startsWith('deezer:')) {
final deezerId = album.id.replaceFirst('deezer:', '');
@@ -1936,8 +1934,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
initialAlbumType: album.albumType,
initialTotalTracks: album.totalTracks,
),
),
);
+5 -34
View File
@@ -299,11 +299,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
});
}
Future<void> _navigateToMetadataScreen(
DownloadHistoryItem item, {
required List<DownloadHistoryItem> navigationItems,
required int navigationIndex,
}) async {
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
final navigator = Navigator.of(context);
_precacheCover(item.coverUrl);
final beforeModTime =
@@ -313,13 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (!mounted) return;
final result = await navigator.push(
slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath,
@@ -701,13 +691,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: _buildTrackItem(
context,
colorScheme,
track,
tracks,
index,
),
child: _buildTrackItem(context, colorScheme, track),
),
);
}, childCount: tracks.length),
@@ -725,19 +709,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
for (final track in discTracks) {
final navigationIndex = tracks.indexOf(track);
children.add(
KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: revealIndex++,
child: _buildTrackItem(
context,
colorScheme,
track,
tracks,
navigationIndex,
),
child: _buildTrackItem(context, colorScheme, track),
),
),
);
@@ -797,8 +774,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
BuildContext context,
ColorScheme colorScheme,
DownloadHistoryItem track,
List<DownloadHistoryItem> navigationItems,
int navigationIndex,
) {
final isSelected = _selectedIds.contains(track.id);
@@ -816,11 +791,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(
track,
navigationItems: navigationItems,
navigationIndex: navigationIndex,
),
: () => _navigateToMetadataScreen(track),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
+43 -345
View File
@@ -426,18 +426,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
String? currentSearchProvider,
List<Extension> extensions,
) {
final resolvedSearchProvider = _resolveSearchProvider(
currentSearchProvider,
extensions,
);
final isUsingExtensionSearch =
resolvedSearchProvider != null &&
resolvedSearchProvider.isNotEmpty &&
extensions.any((e) => e.id == resolvedSearchProvider && e.enabled);
currentSearchProvider != null &&
currentSearchProvider.isNotEmpty &&
extensions.any((e) => e.id == currentSearchProvider && e.enabled);
if (isUsingExtensionSearch) {
final currentSearchExtension = extensions
.where((e) => e.id == resolvedSearchProvider && e.enabled)
.where((e) => e.id == currentSearchProvider && e.enabled)
.firstOrNull;
final filters = currentSearchExtension?.searchBehavior?.filters;
if (filters != null && filters.isNotEmpty) {
@@ -453,153 +449,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
];
}
Extension? _defaultSearchExtension(List<Extension> extensions) {
return extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.firstOrNull ??
extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.firstOrNull;
}
String? _resolveSearchProvider(
String? explicitSearchProvider,
List<Extension> extensions,
) {
final explicit = explicitSearchProvider?.trim();
if (explicit != null &&
explicit.isNotEmpty &&
(_builtInSearchProviders.contains(explicit) ||
extensions.any(
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
))) {
return explicit;
}
return _defaultSearchExtension(extensions)?.id ?? 'tidal';
}
String? _sanitizeSearchFilterForProvider(
String? filter,
String? currentSearchProvider,
List<Extension> extensions,
) {
if (filter == null || filter.isEmpty) {
return null;
}
final canonicalFilter = _canonicalSearchFilterId(filter);
if (currentSearchProvider == null ||
currentSearchProvider.isEmpty ||
_builtInSearchProviders.contains(currentSearchProvider)) {
switch (canonicalFilter) {
case 'track':
case 'artist':
case 'album':
case 'playlist':
return canonicalFilter;
default:
return null;
}
}
final extension = extensions
.where((e) => e.id == currentSearchProvider && e.enabled)
.firstOrNull;
final filters = extension?.searchBehavior?.filters;
if (filters == null || filters.isEmpty) {
return null;
}
final match = filters
.where(
(candidate) =>
_canonicalSearchFilterId(candidate.id) == canonicalFilter ||
(candidate.label != null &&
_canonicalSearchFilterId(candidate.label!) ==
canonicalFilter) ||
(candidate.icon != null &&
_canonicalSearchFilterId(candidate.icon!) == canonicalFilter),
)
.firstOrNull;
return match?.id;
}
String _canonicalSearchFilterId(String value) {
final normalized = value.trim().toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]+'),
'',
);
switch (normalized) {
case 'track':
case 'tracks':
case 'song':
case 'songs':
case 'music':
return 'track';
case 'artist':
case 'artists':
return 'artist';
case 'album':
case 'albums':
return 'album';
case 'playlist':
case 'playlists':
return 'playlist';
default:
return normalized;
}
}
String? _preferredSearchFilter(
String preferredSearchTab,
String? currentSearchProvider,
List<Extension> extensions,
) {
final preferred = switch (preferredSearchTab) {
'track' => 'track',
'artist' => 'artist',
'album' => 'album',
_ => null,
};
return _sanitizeSearchFilterForProvider(
preferred,
currentSearchProvider,
extensions,
);
}
String _displaySearchFilterSelection(
String? selectedSearchFilter,
String preferredSearchTab,
String? currentSearchProvider,
List<Extension> extensions,
) {
if (selectedSearchFilter == 'all') {
return 'all';
}
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
return _sanitizeSearchFilterForProvider(
selectedSearchFilter,
currentSearchProvider,
extensions,
) ??
'all';
}
return _preferredSearchFilter(
preferredSearchTab,
currentSearchProvider,
extensions,
) ??
'all';
}
_SearchResultBuckets _getSearchResultBuckets(List<Track> tracks) {
final cached = _searchBucketsCache;
if (cached != null && identical(tracks, _searchBucketsSourceTracks)) {
@@ -681,10 +530,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
bool _isLiveSearchEnabled() {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
);
final searchProvider = settings.searchProvider;
if (searchProvider == null || searchProvider.isEmpty) return false;
@@ -753,32 +599,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
Future<void> _performSearch(String query, {String? filterOverride}) async {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
);
final storedFilter = ref.read(trackProvider).selectedSearchFilter;
final selectedFilter = switch (filterOverride) {
'all' => null,
final explicit? => _sanitizeSearchFilterForProvider(
explicit,
searchProvider,
extState.extensions,
),
null => switch (storedFilter) {
'all' => null,
final stored? => _sanitizeSearchFilterForProvider(
stored,
searchProvider,
extState.extensions,
),
null => _preferredSearchFilter(
settings.defaultSearchTab,
searchProvider,
extState.extensions,
),
},
};
final searchProvider = settings.searchProvider;
final selectedFilter =
filterOverride ?? ref.read(trackProvider).selectedSearchFilter;
final searchKey =
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
@@ -804,12 +627,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
await ref
.read(trackProvider.notifier)
.customSearch(
searchProvider,
query,
options: options,
selectedFilter: selectedFilter,
);
.customSearch(searchProvider, query, options: options);
} else if (isBuiltInProvider) {
await ref
.read(trackProvider.notifier)
@@ -1244,9 +1062,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
final hasSearchedBefore = ref.watch(
settingsProvider.select((s) => s.hasSearchedBefore),
);
final defaultSearchTab = ref.watch(
settingsProvider.select((s) => s.defaultSearchTab),
);
final hasExploreContent = ref.watch(
exploreProvider.select((s) => s.sections.isNotEmpty),
@@ -1288,29 +1103,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
(hasHomeFeedExtension || hasExploreContent) &&
hasExploreContent;
ref.listen<String>(settingsProvider.select((s) => s.defaultSearchTab), (
previous,
next,
) {
if (previous == next) return;
final selectedSearchFilter = ref.read(
trackProvider.select((s) => s.selectedSearchFilter),
);
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
return;
}
final text = _urlController.text.trim();
if (text.isEmpty || text.length < _minLiveSearchChars) return;
if (text.startsWith('http') || text.startsWith('spotify:')) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_lastSearchQuery = null;
_performSearch(text);
});
});
if (hasActualResults &&
isShowingRecentAccess &&
hasSearchInput &&
@@ -1454,12 +1246,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return SliverToBoxAdapter(
child: _buildSearchFilterBar(
searchFilters,
_displaySearchFilterSelection(
selectedSearchFilter,
defaultSearchTab,
currentSearchProvider,
extensions,
),
selectedSearchFilter,
colorScheme,
),
);
@@ -1656,13 +1443,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
button: true,
label: 'Open track ${item.trackName} by ${item.artistName}',
child: GestureDetector(
onTap: () => _navigateToMetadataScreen(
item,
navigationItems: items
.take(itemCount)
.toList(growable: false),
navigationIndex: index,
),
onTap: () => _navigateToMetadataScreen(item),
child: Container(
width: coverSize,
margin: const EdgeInsets.only(right: 12),
@@ -2305,25 +2086,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
bool _isEnabledMetadataExtension(String? providerId) {
final normalized = providerId?.trim();
if (normalized == null || normalized.isEmpty) return false;
return ref
.read(extensionProvider)
.extensions
.any(
(ext) =>
ext.enabled && ext.hasMetadataProvider && ext.id == normalized,
);
}
void _navigateToRecentItem(RecentAccessItem item) {
_searchFocusNode.unfocus();
switch (item.type) {
case RecentAccessType.artist:
if (_isEnabledMetadataExtension(item.providerId)) {
if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute<void>(
@@ -2360,7 +2133,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
);
} else if (_isEnabledMetadataExtension(item.providerId)) {
} else if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute<void>(
@@ -2405,7 +2183,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
return;
}
if (_isEnabledMetadataExtension(item.providerId)) {
if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute<void>(
@@ -2434,11 +2217,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
}
Future<void> _navigateToMetadataScreen(
DownloadHistoryItem item, {
List<DownloadHistoryItem>? navigationItems,
int? navigationIndex,
}) async {
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
final navigator = Navigator.of(context);
_precacheCover(item.coverUrl);
final beforeModTime =
@@ -2447,13 +2226,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
if (!mounted) return;
final result = await navigator.push(
slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath,
@@ -3242,8 +3015,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
albumId: albumItem.id,
albumName: albumItem.name,
coverUrl: albumItem.coverUrl,
initialAlbumType: albumItem.albumType,
initialTotalTracks: albumItem.totalTracks,
),
),
);
@@ -3322,11 +3093,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
String _getSearchHint() {
final settings = ref.read(settingsProvider);
final searchProvider = settings.searchProvider;
final extState = ref.read(extensionProvider);
final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
);
if (!extState.isInitialized) {
return 'Paste supported URL or search...';
@@ -3368,10 +3136,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(context.l10n.historyFilterAll),
selected: selectedFilter == 'all',
selected: selectedFilter == null,
onSelected: (_) {
ref.read(trackProvider.notifier).setSearchFilter('all');
_triggerSearchWithFilter('all');
ref.read(trackProvider.notifier).setSearchFilter(null);
_triggerSearchWithFilter(null);
},
showCheckmark: false,
),
@@ -3527,23 +3295,9 @@ class _SearchProviderDropdown extends ConsumerWidget {
const _SearchProviderDropdown({this.onProviderChanged});
Extension? _defaultSearchExtension(List<Extension> extensions) {
return extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.firstOrNull ??
extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.firstOrNull;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final rawCurrentProvider = ref.watch(
final currentProvider = ref.watch(
settingsProvider.select((s) => s.searchProvider),
);
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
@@ -3552,19 +3306,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
final searchProviders = extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList();
final primarySearchExtension = _defaultSearchExtension(searchProviders);
final defaultProviderTarget =
primarySearchExtension?.displayName ?? 'Tidal';
final defaultProviderLabel =
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
final defaultProviderIconPath = primarySearchExtension?.iconPath;
final currentProvider =
rawCurrentProvider != null &&
rawCurrentProvider.isNotEmpty &&
({'tidal', 'qobuz'}.contains(rawCurrentProvider) ||
searchProviders.any((e) => e.id == rawCurrentProvider))
? rawCurrentProvider
: null;
Extension? currentExt;
if (currentProvider != null && currentProvider.isNotEmpty) {
@@ -3584,19 +3325,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
if (currentExt.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
}
} else if (primarySearchExtension?.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(
primarySearchExtension!.searchBehavior!.icon!,
);
iconPath = defaultProviderIconPath;
} else if (defaultProviderIconPath != null &&
defaultProviderIconPath.isNotEmpty) {
iconPath = defaultProviderIconPath;
if (primarySearchExtension?.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(
primarySearchExtension!.searchBehavior!.icon!,
);
}
} else if (isBuiltInProvider) {
displayIcon = Icons.music_note;
}
@@ -3651,7 +3379,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
defaultProviderLabel,
'Deezer',
style: TextStyle(
fontWeight:
currentProvider == null || currentProvider.isEmpty
@@ -4571,8 +4299,6 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
final String albumId;
final String albumName;
final String? coverUrl;
final String? initialAlbumType;
final int? initialTotalTracks;
const ExtensionAlbumScreen({
super.key,
@@ -4580,8 +4306,6 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
required this.albumId,
required this.albumName,
this.coverUrl,
this.initialAlbumType,
this.initialTotalTracks,
});
@override
@@ -4595,14 +4319,10 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
String? _error;
String? _artistId;
String? _artistName;
String? _albumType;
int? _albumTotalTracks;
@override
void initState() {
super.initState();
_albumType = normalizeOptionalString(widget.initialAlbumType);
_albumTotalTracks = widget.initialTotalTracks;
_fetchTracks();
}
@@ -4636,28 +4356,17 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
return;
}
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
final artistName = result['artists'] as String?;
final albumType =
normalizeOptionalString(result['album_type']?.toString()) ??
_albumType;
final totalTracks = result['total_tracks'] as int? ?? _albumTotalTracks;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
setState(() {
_tracks = tracks;
_artistId = artistId;
_artistName = artistName;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false;
});
} catch (e) {
@@ -4669,11 +4378,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
}
}
Track _parseTrack(
Map<String, dynamic> data, {
String? albumTypeFallback,
int? totalTracksFallback,
}) {
Track _parseTrack(Map<String, dynamic> data) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
@@ -4701,14 +4406,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(),
albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
albumTypeFallback ??
_albumType,
totalTracks:
data['total_tracks'] as int? ??
totalTracksFallback ??
_albumTotalTracks,
totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
source: widget.extensionId,
);
+15
View File
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
@@ -93,6 +94,20 @@ class _MainShellState extends ConsumerState<MainShell>
}
Future<void> _handleSharedUrl(String url) async {
// Wait for extensions to be initialized before handling URL
final extState = ref.read(extensionProvider);
if (!extState.isInitialized) {
_log.d('Waiting for extensions to initialize before handling URL...');
for (int i = 0; i < 50; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
if (!mounted) return;
if (ref.read(extensionProvider).isInitialized) {
_log.d('Extensions initialized, proceeding with URL handling');
break;
}
}
}
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
+2
View File
@@ -61,6 +61,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
if (playlistId != null) {
if (playlistId.startsWith('tidal:')) return 'tidal';
if (playlistId.startsWith('qobuz:')) return 'qobuz';
if (playlistId.startsWith('deezer:')) return 'deezer';
}
final source = _tracks.firstOrNull?.source;
@@ -71,6 +72,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final trackId = _tracks.firstOrNull?.id ?? '';
if (trackId.startsWith('tidal:')) return 'tidal';
if (trackId.startsWith('qobuz:')) return 'qobuz';
if (trackId.startsWith('deezer:')) return 'deezer';
return null;
}
+13 -147
View File
@@ -2963,23 +2963,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
Future<void> _navigateToHistoryMetadataScreen(
DownloadHistoryItem item, {
List<DownloadHistoryItem>? navigationItems,
int? navigationIndex,
}) async {
DownloadHistoryItem item,
) async {
final navigator = Navigator.of(context);
_precacheCover(item.coverUrl);
_searchFocusNode.unfocus();
final beforeModTime = await _readFileModTimeMillis(item.filePath);
if (!mounted) return;
final result = await navigator.push(
slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
);
_searchFocusNode.unfocus();
if (result == true) {
@@ -2996,21 +2988,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
void _navigateToLocalMetadataScreen(
LocalLibraryItem item, {
List<LocalLibraryItem>? navigationItems,
int? navigationIndex,
}) {
void _navigateToLocalMetadataScreen(LocalLibraryItem item) {
_searchFocusNode.unfocus();
Navigator.push(
context,
slidePageRoute<void>(
page: TrackMetadataScreen(
localItem: item,
localNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
slidePageRoute<void>(page: TrackMetadataScreen(localItem: item)),
).then((_) => _searchFocusNode.unfocus());
}
@@ -4245,25 +4227,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final filteredUnifiedItems = filterData.filteredUnifiedItems;
final totalTrackCount = filterData.totalTrackCount;
final totalAlbumCount = filterData.totalAlbumCount;
final downloadedNavigationItems = <DownloadHistoryItem>[];
final downloadedNavigationIndexByUnifiedId = <String, int>{};
final localNavigationItems = <LocalLibraryItem>[];
final localNavigationIndexByUnifiedId = <String, int>{};
for (final item in filteredUnifiedItems) {
final historyItem = item.historyItem;
if (historyItem != null) {
downloadedNavigationIndexByUnifiedId[item.id] =
downloadedNavigationItems.length;
downloadedNavigationItems.add(historyItem);
}
final localItem = item.localItem;
if (localItem != null) {
localNavigationIndexByUnifiedId[item.id] = localNavigationItems.length;
localNavigationItems.add(localItem);
}
}
return CustomScrollView(
slivers: [
@@ -4333,40 +4296,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
if (filterMode == 'all' &&
totalTrackCount == 0 &&
!showFilteringIndicator &&
(_activeFilterCount > 0 || unifiedItems.isNotEmpty))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
const Spacer(),
if (!_isSelectionMode)
_buildFilterButton(context, unifiedItems),
],
),
),
),
if (filterMode == 'singles' &&
totalTrackCount == 0 &&
!showFilteringIndicator &&
(_activeFilterCount > 0 || unifiedItems.isNotEmpty))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
const Spacer(),
if (!_isSelectionMode)
_buildFilterButton(context, unifiedItems),
],
),
),
),
if (historyItems.isNotEmpty && hasQueueItems)
SliverToBoxAdapter(
child: Padding(
@@ -4490,26 +4419,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context,
item,
colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
),
),
child: _buildUnifiedGridItem(
context,
item,
colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
),
),
);
@@ -4557,25 +4472,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context,
item,
colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
),
),
child: _buildUnifiedLibraryItem(
context,
item,
colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
),
),
);
@@ -4638,12 +4540,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context,
item,
colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
),
);
}, childCount: filteredUnifiedItems.length),
@@ -4658,12 +4554,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context,
item,
colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
),
);
}, childCount: filteredUnifiedItems.length),
@@ -6719,12 +6609,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Widget _buildUnifiedLibraryItem(
BuildContext context,
UnifiedLibraryItem item,
ColorScheme colorScheme, {
required List<DownloadHistoryItem> downloadedNavigationItems,
required int? downloadedNavigationIndex,
required List<LocalLibraryItem> localNavigationItems,
required int? localNavigationIndex,
}) {
ColorScheme colorScheme,
) {
final fileExistsListenable = _fileExistsListenable(item.filePath);
final isSelected = _selectedIds.contains(item.id);
final date = item.addedAt;
@@ -6754,17 +6640,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onTap: _isSelectionMode
? () => _toggleSelection(item.id)
: isDownloaded
? () => _navigateToHistoryMetadataScreen(
item.historyItem!,
navigationItems: downloadedNavigationItems,
navigationIndex: downloadedNavigationIndex,
)
? () => _navigateToHistoryMetadataScreen(item.historyItem!)
: item.localItem != null
? () => _navigateToLocalMetadataScreen(
item.localItem!,
navigationItems: localNavigationItems,
navigationIndex: localNavigationIndex,
)
? () => _navigateToLocalMetadataScreen(item.localItem!)
: () => _openFile(
item.filePath,
title: item.trackName,
@@ -6938,12 +6816,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Widget _buildUnifiedGridItem(
BuildContext context,
UnifiedLibraryItem item,
ColorScheme colorScheme, {
required List<DownloadHistoryItem> downloadedNavigationItems,
required int? downloadedNavigationIndex,
required List<LocalLibraryItem> localNavigationItems,
required int? localNavigationIndex,
}) {
ColorScheme colorScheme,
) {
final fileExistsListenable = _fileExistsListenable(item.filePath);
final isSelected = _selectedIds.contains(item.id);
final isDownloaded = item.source == LibraryItemSource.downloaded;
@@ -6952,17 +6826,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onTap: _isSelectionMode
? () => _toggleSelection(item.id)
: isDownloaded
? () => _navigateToHistoryMetadataScreen(
item.historyItem!,
navigationItems: downloadedNavigationItems,
navigationIndex: downloadedNavigationIndex,
)
? () => _navigateToHistoryMetadataScreen(item.historyItem!)
: item.localItem != null
? () => _navigateToLocalMetadataScreen(
item.localItem!,
navigationItems: localNavigationItems,
navigationIndex: localNavigationIndex,
)
? () => _navigateToLocalMetadataScreen(item.localItem!)
: () => _openFile(
item.filePath,
title: item.trackName,
+7
View File
@@ -789,6 +789,13 @@ class _ExtensionItem extends StatelessWidget {
),
],
),
const SizedBox(height: 2),
Text(
'by ${extension.author}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (extension.requiresNewerApp) ...[
const SizedBox(height: 4),
Container(
-7
View File
@@ -182,13 +182,6 @@ class AboutPage extends StatelessWidget {
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.campaign_outlined,
title: context.l10n.aboutKeepAndroidOpen,
subtitle: 'keepandroidopen.org',
onTap: () => _launchUrl('https://keepandroidopen.org/'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.bug_report_outlined,
title: context.l10n.aboutReportIssue,
+1 -9
View File
@@ -164,15 +164,7 @@ class _RecentDonorsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>[
'Ldav',
'Nico',
'Feuerstern',
'R4ND0MIZ3D',
'Isra',
'bigJr48',
'Mick',
];
const donorNames = <String>['R4ND0MIZ3D', 'Isra', 'bigJr48'];
// Match SettingsGroup color logic
final cardColor = isDark
@@ -1,250 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadFallbackExtensionsPage extends ConsumerStatefulWidget {
const DownloadFallbackExtensionsPage({super.key});
@override
ConsumerState<DownloadFallbackExtensionsPage> createState() =>
_DownloadFallbackExtensionsPageState();
}
class _DownloadFallbackExtensionsPageState
extends ConsumerState<DownloadFallbackExtensionsPage> {
late List<Extension> _extensions;
late Set<String> _selectedExtensionIds;
bool _hasChanges = false;
@override
void initState() {
super.initState();
_loadExtensions();
}
void _loadExtensions() {
final extState = ref.read(extensionProvider);
final settings = ref.read(settingsProvider);
_extensions = extState.extensions
.where(
(extension) => extension.enabled && extension.hasDownloadProvider,
)
.toList();
final savedIds = settings.downloadFallbackExtensionIds;
if (savedIds == null) {
_selectedExtensionIds = _extensions
.map((extension) => extension.id)
.toSet();
} else {
final allowedIds = _extensions.map((extension) => extension.id).toSet();
_selectedExtensionIds = savedIds
.where((extensionId) => allowedIds.contains(extensionId))
.toSet();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: !_hasChanges,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _confirmDiscard(context);
if (shouldPop && context.mounted) {
Navigator.pop(context);
}
},
child: Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () async {
if (_hasChanges) {
final shouldPop = await _confirmDiscard(context);
if (shouldPop && context.mounted) {
Navigator.pop(context);
}
} else {
Navigator.pop(context);
}
},
),
actions: [
if (_hasChanges)
TextButton(
onPressed: _saveChanges,
child: Text(context.l10n.dialogSave),
),
],
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.extensionsFallbackTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.providerPriorityFallbackExtensionsDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
if (_extensions.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
if (_extensions.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: SettingsGroup(
margin: EdgeInsets.zero,
children: List.generate(_extensions.length, (index) {
final extension = _extensions[index];
final isSelected = _selectedExtensionIds.contains(
extension.id,
);
return SettingsSwitchItem(
icon: Icons.extension_rounded,
title: extension.displayName,
subtitle: extension.id,
value: isSelected,
showDivider: index != _extensions.length - 1,
onChanged: (value) {
setState(() {
if (value) {
_selectedExtensionIds.add(extension.id);
} else {
_selectedExtensionIds.remove(extension.id);
}
_hasChanges = true;
});
},
);
}),
),
),
),
if (_extensions.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
context.l10n.providerPriorityFallbackExtensionsHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
Future<bool> _confirmDiscard(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.dialogUnsavedChanges),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogDiscard),
),
],
),
);
return result ?? false;
}
void _saveChanges() {
final allExtensionIds = _extensions
.map((extension) => extension.id)
.toList();
final selectedExtensionIds = allExtensionIds
.where(_selectedExtensionIds.contains)
.toList();
final fallbackExtensionIds =
selectedExtensionIds.length == allExtensionIds.length
? null
: selectedExtensionIds;
ref
.read(settingsProvider.notifier)
.setDownloadFallbackExtensionIds(fallbackExtensionIds);
setState(() {
_hasChanges = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
);
}
}
@@ -24,7 +24,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
}
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz'];
static const _builtInServices = ['tidal', 'qobuz', 'deezer'];
static const _songLinkRegions = [
'AD',
'AE',
@@ -2053,7 +2053,7 @@ class _ServiceSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final builtInServiceIds = ['tidal', 'qobuz'];
final builtInServiceIds = ['tidal', 'qobuz', 'deezer'];
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
+14 -105
View File
@@ -8,7 +8,6 @@ import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:url_launcher/url_launcher.dart';
class ExtensionDetailPage extends ConsumerStatefulWidget {
final String extensionId;
@@ -50,6 +49,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
name: '',
displayName: 'Unknown',
version: '0.0.0',
author: 'Unknown',
description: '',
enabled: false,
status: 'error',
@@ -205,6 +205,10 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
const SizedBox(height: 16),
_InfoRow(
label: context.l10n.extensionAuthor,
value: extension.author,
),
_InfoRow(
label: context.l10n.extensionId,
value: extension.id,
@@ -400,7 +404,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
onChanged: (value) =>
_updateSetting(setting.key, value),
extensionId: widget.extensionId,
onActionPayload: _handleExtensionActionPayload,
);
}).toList(),
),
@@ -442,27 +445,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
.setExtensionSettings(widget.extensionId, _settings);
}
/// Extensions may return `setting_updates` from button actions (e.g. OAuth URL field).
Future<void> _handleExtensionActionPayload(
Map<String, dynamic> payload,
) async {
final raw = payload['setting_updates'];
if (raw is! Map) return;
final partial = <String, dynamic>{};
for (final entry in raw.entries) {
partial[entry.key.toString()] = entry.value;
}
if (partial.isEmpty) return;
final merged = Map<String, dynamic>.from(_settings);
merged.addAll(partial);
await ref
.read(extensionProvider.notifier)
.setExtensionSettings(widget.extensionId, merged);
if (mounted) {
setState(() => _settings = merged);
}
}
Future<void> _confirmRemove(BuildContext context) async {
final colorScheme = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
@@ -496,41 +478,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
}
}
/// Long OAuth URLs: selectable text so users can copy without relying on snackbars.
class _OauthLoginLinkPreview extends StatelessWidget {
final String? value;
final ColorScheme colorScheme;
const _OauthLoginLinkPreview({
required this.value,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return Text(
'Tap Connect to Spotify to fill this field.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
);
}
return SelectionArea(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontFamily: 'monospace',
fontSize: 11,
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
@@ -698,14 +645,12 @@ class _SettingItem extends StatefulWidget {
final bool showDivider;
final ValueChanged<dynamic> onChanged;
final String extensionId;
final Future<void> Function(Map<String, dynamic> payload)? onActionPayload;
const _SettingItem({
required this.setting,
required this.value,
required this.onChanged,
required this.extensionId,
this.onActionPayload,
this.showDivider = true,
});
@@ -827,17 +772,11 @@ class _SettingItemState extends State<_SettingItem> {
if (widget.setting.type == 'string' ||
widget.setting.type == 'number') ...[
const SizedBox(height: 4),
if (widget.setting.key == 'oauth_login_url')
_OauthLoginLinkPreview(
value: widget.value?.toString(),
colorScheme: colorScheme,
)
else
Text(
widget.value?.toString() ?? 'Not set',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
Text(
widget.value?.toString() ?? 'Not set',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
],
],
),
@@ -876,45 +815,15 @@ class _SettingItemState extends State<_SettingItem> {
);
if (context.mounted) {
// Go may return either a flat map or { success, result: { ... } }.
Map<String, dynamic> payload = result;
final nested = result['result'];
if (nested is Map) {
payload = Map<String, dynamic>.from(nested);
}
final success = payload['success'] as bool? ?? false;
final success = result['success'] as bool? ?? false;
if (!success) {
final error =
payload['error'] as String? ??
result['error'] as String? ??
'Action failed';
final error = result['error'] as String? ?? 'Action failed';
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
} else {
if (widget.onActionPayload != null) {
await widget.onActionPayload!(payload);
}
final openAuth = payload['open_auth_url'] as String?;
if (openAuth != null && openAuth.isNotEmpty) {
final uri = Uri.parse(openAuth);
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarError('Could not open browser'),
),
),
);
}
}
final message = payload['message'] as String?;
if (message != null && message.isNotEmpty && context.mounted) {
final message = result['message'] as String?;
if (message != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
+2 -71
View File
@@ -8,10 +8,9 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/download_fallback_extensions_page.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -152,7 +151,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
child: SettingsGroup(
children: [
_DownloadPriorityItem(),
_DownloadFallbackItem(),
_MetadataPriorityItem(),
_SearchProviderSelector(),
_HomeFeedProviderSelector(),
@@ -425,7 +423,7 @@ class _ExtensionItem extends StatelessWidget {
hasError
? extension.errorMessage ??
context.l10n.extensionsErrorLoading
: 'v${extension.version}',
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasError
? colorScheme.error
@@ -590,73 +588,6 @@ class _MetadataPriorityItem extends ConsumerWidget {
}
}
class _DownloadFallbackItem extends ConsumerWidget {
const _DownloadFallbackItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final hasDownloadExtensions = extState.extensions.any(
(e) => e.enabled && e.hasDownloadProvider,
);
return InkWell(
onTap: hasDownloadExtensions
? () => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => const DownloadFallbackExtensionsPage(),
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.alt_route,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.extensionsFallbackTitle,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasDownloadExtensions ? null : colorScheme.outline,
),
),
const SizedBox(height: 2),
Text(
hasDownloadExtensions
? context.l10n.extensionsFallbackSubtitle
: context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
],
),
),
);
}
}
class _SearchProviderSelector extends ConsumerWidget {
const _SearchProviderSelector();
@@ -225,8 +225,8 @@ class _MetadataProviderItem extends StatelessWidget {
return _MetadataProviderInfo(
name: 'Deezer',
icon: Icons.album,
description: context.l10n.providerExtension,
isBuiltIn: false,
description: context.l10n.metadataNoRateLimits,
isBuiltIn: true,
);
case 'qobuz':
return _MetadataProviderInfo(

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