Compare commits

...

32 Commits

Author SHA1 Message Date
zarzet 580e2b6ab8 chore: bump version to 4.3.0 and fix SAF document file race condition
- Bump app version to 4.3.0 (build 125)
- Extract createOrReuseDocumentFile() to handle SAF auto-rename races
  between findFile() and createFile(), preferring the exact-named sibling
  and discarding duplicate documents
2026-04-13 23:19:43 +07:00
zarzet 298b89acf1 feat: propagate download cancel to extension HTTP requests and fix SAF filename extension mismatch
- Bind cancel context to all extension HTTP calls (fetch, httpGet, httpPost,
  httpRequest, fileDownload, authExchangeCodeWithPKCE) so in-flight requests
  are aborted when user cancels a download
- Make initDownloadCancel idempotent: return existing context if entry already
  exists and preserve pre-cancelled state
- Force SAF output filename to match actual file extension when extension
  returns a different format than requested (e.g. FLAC requested but M4A produced)
- Map ALAC/AAC quality to .m4a instead of falling through to default .flac
2026-04-13 22:20:17 +07:00
zarzet b6e2675b86 fix: handle extension oauth callback on ios 2026-04-13 15:55:47 +07:00
Alex 7786501cd1 Extension OAuth + store: flatten action JSON, open auth URLs, spotiflac:// callback
Third-party extensions (e.g. Spotify PKCE addons) need three things the current app does not fully provide:

Extension button results – The Go runtime returned { success, result: { message, open_auth_url, … } } while Flutter read message / open_auth_url only on the outer map, so OAuth buttons appeared to do nothing. InvokeAction now merges the extension’s return object onto the top-level JSON (arrays/non-objects still use result).

Flutter – extension_detail_page: unwrap nested result for compatibility, merge setting_updates into saved extension settings (for copyable OAuth URLs), and launchUrl when open_auth_url is set.

Mobile OAuth return – spotiflac://callback?code=…&state=<extension_id> was not handled on Android (manifest + MainActivity) or iOS (AppDelegate open URL + cold-start launchOptions). This wires SetExtensionAuthCodeByID + invokeExtensionAction(..., "completeSpotifyLogin") so PKCE extensions can finish login after the browser redirect.

Extension store HTTP – Add Cache-Control: no-cache on registry and extension package downloads to reduce stale CDN/proxy responses.

Testing: Install a metadata extension that uses PKCE; tap Connect; confirm browser opens, return via spotiflac://callback, and tokens complete without pasting the code manually.

extension InvokeAction JSON was nested under result while the Flutter settings UI only read the top level, so OAuth-related buttons never showed messages or opened the browser. This PR flattens that payload, merges optional setting_updates, launches open_auth_url, adds spotiflac://callback handling on Android and iOS, and sends no-cache on store HTTP fetches. Needed for extensions like SpoitiLists (Spotify Web API + PKCE).

(cherry picked from commit 3fc371b8c4)
2026-04-13 15:51:48 +07:00
zarzet bc4b5a5b17 feat: native M4A ReplayGain tag writing and SAF picker error handling 2026-04-13 05:01:02 +07:00
zarzet 5d160f71f1 refactor: remove author field from extension manifest and UI 2026-04-13 04:09:01 +07:00
zarzet 20cf7d49e5 fix: align default search tab layout with primary provider selector using Row+Expanded 2026-04-13 03:50:31 +07:00
zarzet 88d22477d5 fix: preserve existing M4A metadata during embed and enable BuildConfig generation 2026-04-13 02:47:59 +07:00
zarzet b77def62f4 feat: expose extension utils, preserve M4A native container, and bump to v4.2.3+124 2026-04-13 02:04:11 +07:00
zarzet a15313e573 feat: add artist search filter and normalize search filter handling 2026-04-13 00:44:35 +07:00
zarzet 4a90d3f38a fix: improve ALAC M4A quality parsing 2026-04-12 04:53:37 +07:00
zarzet d4e56567a2 refactor: move deezer search flow to extension 2026-04-12 04:24:23 +07:00
zarzet 277a7f24fa fix: stabilize shared extension link handling 2026-04-11 21:18:23 +07:00
zarzet 3735aaf3bd feat: add default search tab preference 2026-04-11 16:42:29 +07:00
zarzet 3bbe8553ab fix: fallback extra metadata genre 2026-04-11 16:42:22 +07:00
zarzet ca0cfa4524 chore: thank Ldav Nico and Feuerstern on donate page 2026-04-09 16:59:46 +07:00
zarzet 8b185e964a feat: add keep android open link 2026-04-09 16:55:03 +07:00
zarzet c104a5d8a3 fix: align metadata sanitization and lyrics editing 2026-04-09 16:53:08 +07:00
zarzet 8615cde898 chore: bump app to v4.2.2 2026-04-06 14:21:54 +07:00
zarzet 207c0653cc refactor: move deezer to extension 2026-04-06 14:15:44 +07:00
zarzet de756e5d86 fix: preserve flat singles output for extension releases 2026-04-06 04:27:37 +07:00
zarzet fd5db3f7b6 fix: align re-enrich matching with autofill metadata 2026-04-06 03:39:35 +07:00
zarzet d087da9409 fix: persist downloaded metadata and refine metadata navigation 2026-04-06 03:20:04 +07:00
zarzet 43469a7ef2 feat: add configurable extension download fallback 2026-04-06 03:00:17 +07:00
zarzet add4af831e fix: preserve composer metadata across qobuz and history 2026-04-06 01:58:36 +07:00
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
124 changed files with 7479 additions and 2162 deletions
+6 -1
View File
@@ -164,13 +164,18 @@ jobs:
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
build-ios: build-ios:
runs-on: macos-latest runs-on: macos-15
needs: get-version # Only depends on version, NOT android build! needs: get-version # Only depends on version, NOT android build!
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Select Xcode 26.1.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.1.app
xcodebuild -version
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
+19
View File
@@ -20,6 +20,10 @@ android {
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
buildFeatures {
buildConfig = true
}
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
@@ -57,6 +61,18 @@ android {
} }
buildTypes { buildTypes {
getByName("debug") {
ndk {
debugSymbolLevel = "FULL"
}
}
getByName("profile") {
ndk {
debugSymbolLevel = "FULL"
}
}
release { release {
// For local builds: use release signing if key.properties exists // For local builds: use release signing if key.properties exists
// For CI builds: APK is signed by GitHub Action after build // For CI builds: APK is signed by GitHub Action after build
@@ -71,6 +87,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
ndk {
debugSymbolLevel = "FULL"
}
} }
} }
+14 -18
View File
@@ -86,6 +86,20 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="music.youtube.com" /> <data android:scheme="https" android:host="music.youtube.com" />
</intent-filter> </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> </activity>
<!-- Download Service --> <!-- Download Service -->
@@ -94,24 +108,6 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<!-- Audio playback service for media notification / background audio -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- flutter_local_notifications receivers --> <!-- flutter_local_notifications receivers -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" /> <receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"> <receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
@@ -307,8 +308,40 @@ 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 { private fun sanitizeFilename(name: String): String {
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim() 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
} }
private fun sanitizeRelativeDir(relativeDir: String): String { private fun sanitizeRelativeDir(relativeDir: String): String {
@@ -368,6 +401,43 @@ class MainActivity: FlutterFragmentActivity() {
return current 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() { private fun resetSafScanProgress() {
synchronized(safScanLock) { synchronized(safScanLock) {
safScanProgress = SafScanProgress() safScanProgress = SafScanProgress()
@@ -599,12 +669,12 @@ class MainActivity: FlutterFragmentActivity() {
private fun buildSafFileName(req: JSONObject, outputExt: String): String { private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "") val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return sanitizeFilename(provided) if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
val trackName = req.optString("track_name", "track") val trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "") val artistName = req.optString("artist_name", "")
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
return sanitizeFilename(baseName) + outputExt return forceFilenameExt(baseName, outputExt)
} }
private fun errorJson(message: String): String { private fun errorJson(message: String): String {
@@ -918,8 +988,7 @@ class MainActivity: FlutterFragmentActivity() {
val targetDir = ensureDocumentDir(treeUri, relativeDir) val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory") ?: return errorJson("Failed to access SAF directory")
val existingFile = targetDir.findFile(fileName) var document = createOrReuseDocumentFile(targetDir, mimeType, fileName)
val document = existingFile ?: targetDir.createFile(mimeType, fileName)
?: return errorJson("Failed to create SAF file") ?: return errorJson("Failed to create SAF file")
val pfd = contentResolver.openFileDescriptor(document.uri, "rw") val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
@@ -944,16 +1013,34 @@ class MainActivity: FlutterFragmentActivity() {
) { ) {
try { try {
val srcFile = java.io.File(goFilePath) val srcFile = java.io.File(goFilePath)
if (srcFile.exists() && srcFile.length() > 0) { if (!srcFile.exists() || srcFile.length() <= 0) {
contentResolver.openOutputStream(document.uri, "wt")?.use { output -> throw IllegalStateException("extension output missing or empty: $goFilePath")
srcFile.inputStream().use { input ->
input.copyTo(output)
}
}
srcFile.delete()
} }
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
}
}
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) { } catch (e: Exception) {
document.delete()
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}") 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()) respObj.put("file_path", document.uri.toString())
@@ -1931,9 +2018,54 @@ class MainActivity: FlutterFragmentActivity() {
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService. // We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
override fun shouldHandleDeeplinking(): Boolean = false override fun shouldHandleDeeplinking(): Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleExtensionOAuthIntent(intent)
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(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() { override fun onDestroy() {
@@ -1949,6 +2081,7 @@ class MainActivity: FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
Gobackend.setAppVersion(BuildConfig.VERSION_NAME)
// Always-enabled back callback to ensure back presses reach Flutter. // Always-enabled back callback to ensure back presses reach Flutter.
// Nested tab navigators can incorrectly set frameworkHandlesBack(false), // Nested tab navigators can incorrectly set frameworkHandlesBack(false),
@@ -2133,7 +2266,6 @@ class MainActivity: FlutterFragmentActivity() {
result.error("saf_pending", "SAF picker already active", null) result.error("saf_pending", "SAF picker already active", null)
return@launch return@launch
} }
pendingSafTreeResult = result
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags( intent.addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION or
@@ -2141,7 +2273,24 @@ class MainActivity: FlutterFragmentActivity() {
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
) )
safTreeLauncher.launch(intent) 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
)
}
} }
"safExists" -> { "safExists" -> {
val uriStr = call.argument<String>("uri") ?: "" val uriStr = call.argument<String>("uri") ?: ""
@@ -2216,7 +2365,8 @@ class MainActivity: FlutterFragmentActivity() {
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
val existing = dir.findFile(fileName) val existing = dir.findFile(fileName)
val createdNew = existing == null val createdNew = existing == null
val doc = existing ?: dir.createFile(mimeType, fileName) ?: return@withContext null val doc = createOrReuseDocumentFile(dir, mimeType, fileName)
?: return@withContext null
if (!writeUriFromPath(doc.uri, srcPath)) { if (!writeUriFromPath(doc.uri, srcPath)) {
if (createdNew) { if (createdNew) {
doc.delete() doc.delete()
@@ -2714,16 +2864,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(null) 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" -> { "searchTidalAll" -> {
val query = call.argument<String>("query") ?: "" val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15 val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2965,6 +3105,13 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
"setDownloadFallbackExtensionIds" -> {
val extensionIdsJson = call.argument<String>("extension_ids") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setExtensionFallbackProviderIDsJSON(extensionIdsJson)
}
result.success(null)
}
"setMetadataProviderPriority" -> { "setMetadataProviderPriority" -> {
val priorityJson = call.argument<String>("priority") ?: "[]" val priorityJson = call.argument<String>("priority") ?: "[]"
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
+12 -10
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"strconv"
"strings" "strings"
) )
@@ -367,12 +366,9 @@ func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
case "DATE": case "DATE":
metadata.Date = value metadata.Date = value
case "TRACK", "TRACKNUMBER": case "TRACK", "TRACKNUMBER":
// APE track format can be "3" or "3/12" metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
trackNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
metadata.TrackNumber = trackNum
case "DISC", "DISCNUMBER": case "DISC", "DISCNUMBER":
discNum, _ := strconv.Atoi(strings.Split(value, "/")[0]) metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
metadata.DiscNumber = discNum
case "ISRC": case "ISRC":
metadata.ISRC = value metadata.ISRC = value
case "LYRICS", "UNSYNCEDLYRICS": case "LYRICS", "UNSYNCEDLYRICS":
@@ -425,10 +421,10 @@ func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
addItem("Year", metadata.Year) addItem("Year", metadata.Year)
} }
if metadata.TrackNumber > 0 { if metadata.TrackNumber > 0 {
addItem("Track", strconv.Itoa(metadata.TrackNumber)) addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
} }
if metadata.DiscNumber > 0 { if metadata.DiscNumber > 0 {
addItem("Disc", strconv.Itoa(metadata.DiscNumber)) addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
} }
addItem("ISRC", metadata.ISRC) addItem("ISRC", metadata.ISRC)
addItem("Lyrics", metadata.Lyrics) addItem("Lyrics", metadata.Lyrics)
@@ -453,7 +449,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
"artist": "ARTIST", "artist": "ARTIST",
"album": "ALBUM", "album": "ALBUM",
"album_artist": "ALBUM ARTIST", "album_artist": "ALBUM ARTIST",
"date": "YEAR", "date": "DATE",
"genre": "GENRE", "genre": "GENRE",
"track_number": "TRACK", "track_number": "TRACK",
"disc_number": "DISC", "disc_number": "DISC",
@@ -475,7 +471,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
} }
} }
// Some fields have reader aliases that must also be cleared when the // Some fields have reader aliases that must also be cleared when the
// canonical key is updated (e.g. "Year" writer ↔ DATE/YEAR reader, // canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST, // DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS). // LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
if _, present := fields["date"]; present { if _, present := fields["date"]; present {
@@ -484,9 +480,15 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
if _, present := fields["disc_number"]; present { if _, present := fields["disc_number"]; present {
result["DISCNUMBER"] = struct{}{} result["DISCNUMBER"] = struct{}{}
} }
if _, present := fields["disc_total"]; present {
result["DISCNUMBER"] = struct{}{}
}
if _, present := fields["track_number"]; present { if _, present := fields["track_number"]; present {
result["TRACKNUMBER"] = struct{}{} result["TRACKNUMBER"] = struct{}{}
} }
if _, present := fields["track_total"]; present {
result["TRACKNUMBER"] = struct{}{}
}
if _, present := fields["album_artist"]; present { if _, present := fields["album_artist"]; present {
result["ALBUMARTIST"] = struct{}{} result["ALBUMARTIST"] = struct{}{}
} }
+27 -11
View File
@@ -21,7 +21,9 @@ type AudioMetadata struct {
Year string Year string
Date string Date string
TrackNumber int TrackNumber int
TotalTracks int
DiscNumber int DiscNumber int
TotalDiscs int
ISRC string ISRC string
Lyrics string Lyrics string
Label string Label string
@@ -173,9 +175,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
case "TCO": case "TCO":
metadata.Genre = cleanGenre(value) metadata.Genre = cleanGenre(value)
case "TRK": case "TRK":
metadata.TrackNumber = parseTrackNumber(value) metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "TPA": case "TPA":
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "TCM": case "TCM":
metadata.Composer = value metadata.Composer = value
case "TPB": case "TPB":
@@ -292,9 +294,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
case "TCON": case "TCON":
metadata.Genre = cleanGenre(value) metadata.Genre = cleanGenre(value)
case "TRCK": case "TRCK":
metadata.TrackNumber = parseTrackNumber(value) metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "TPOS": case "TPOS":
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "TSRC": case "TSRC":
metadata.ISRC = value metadata.ISRC = value
case "TCOM": case "TCOM":
@@ -580,14 +582,28 @@ func cleanGenre(genre string) string {
} }
func parseTrackNumber(s string) int { func parseTrackNumber(s string) int {
s = strings.TrimSpace(s) num, _ := parseIndexPair(s)
if idx := strings.Index(s, "/"); idx > 0 {
s = s[:idx]
}
num, _ := strconv.Atoi(s)
return num return num
} }
func parseIndexPair(s string) (int, int) {
s = strings.TrimSpace(s)
if s == "" {
return 0, 0
}
first := s
second := ""
if idx := strings.Index(s, "/"); idx > 0 {
first = s[:idx]
second = s[idx+1:]
}
num, _ := strconv.Atoi(strings.TrimSpace(first))
total, _ := strconv.Atoi(strings.TrimSpace(second))
return num, total
}
func removeUnsync(data []byte) []byte { func removeUnsync(data []byte) []byte {
if len(data) == 0 { if len(data) == 0 {
return data return data
@@ -1037,9 +1053,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
case "GENRE": case "GENRE":
metadata.Genre = value metadata.Genre = value
case "TRACKNUMBER", "TRACK": case "TRACKNUMBER", "TRACK":
metadata.TrackNumber = parseTrackNumber(value) metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "DISCNUMBER", "DISC": case "DISCNUMBER", "DISC":
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "ISRC": case "ISRC":
metadata.ISRC = value metadata.ISRC = value
case "COMPOSER": case "COMPOSER":
+14
View File
@@ -10,6 +10,7 @@ import (
var ErrDownloadCancelled = errors.New("download cancelled") var ErrDownloadCancelled = errors.New("download cancelled")
type cancelEntry struct { type cancelEntry struct {
ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
canceled bool canceled bool
} }
@@ -27,8 +28,21 @@ func initDownloadCancel(itemID string) context.Context {
cancelMu.Lock() cancelMu.Lock()
defer cancelMu.Unlock() 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()) ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{ cancelMap[itemID] = &cancelEntry{
ctx: ctx,
cancel: cancel, cancel: cancel,
canceled: false, canceled: false,
} }
+8
View File
@@ -513,6 +513,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
album = "Unknown Album" album = "Unknown Album"
} }
composer := track.Composer
if composer == "" {
composer = sheet.Composer
}
var duration int var duration int
if i+1 < len(sheet.Tracks) { if i+1 < len(sheet.Tracks) {
nextStart := sheet.Tracks[i+1].StartTime nextStart := sheet.Tracks[i+1].StartTime
@@ -539,12 +544,15 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
ScannedAt: scanTime, ScannedAt: scanTime,
ISRC: track.ISRC, ISRC: track.ISRC,
TrackNumber: track.Number, TrackNumber: track.Number,
TotalTracks: len(sheet.Tracks),
DiscNumber: 1, DiscNumber: 1,
TotalDiscs: 1,
Duration: duration, Duration: duration,
ReleaseDate: sheet.Date, ReleaseDate: sheet.Date,
BitDepth: bitDepth, BitDepth: bitDepth,
SampleRate: sampleRate, SampleRate: sampleRate,
Genre: sheet.Genre, Genre: sheet.Genre,
Composer: composer,
Format: "cue+" + strings.TrimPrefix(audioExt, "."), Format: "cue+" + strings.TrimPrefix(audioExt, "."),
} }
+7
View File
@@ -630,6 +630,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
} }
isrcMap := c.fetchISRCsParallel(ctx, allTracks) isrcMap := c.fetchISRCsParallel(ctx, allTracks)
totalDiscs := 0
for _, track := range allTracks {
if track.DiskNumber > totalDiscs {
totalDiscs = track.DiskNumber
}
}
tracks := make([]AlbumTrackMetadata, 0, len(allTracks)) tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
albumType := album.RecordType albumType := album.RecordType
@@ -658,6 +664,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
TrackNumber: trackNum, TrackNumber: trackNum,
TotalTracks: album.NbTracks, TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber, DiscNumber: track.DiskNumber,
TotalDiscs: totalDiscs,
ExternalURL: track.Link, ExternalURL: track.Link,
ISRC: isrc, ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", album.ID), AlbumID: fmt.Sprintf("deezer:%d", album.ID),
-444
View File
@@ -1,444 +0,0 @@
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,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
}
if isSafOutput || !req.EmbedMetadata {
if !req.EmbedMetadata {
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
} else {
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
}
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
}
}
}
}
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
}
bitDepth, sampleRate := 0, 0
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
}
lyricsLRC := ""
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return DeezerDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
}, nil
}
+470 -172
View File
File diff suppressed because it is too large Load Diff
+213 -7
View File
@@ -1,6 +1,24 @@
package gobackend package gobackend
import "testing" 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)
}
}
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) { func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{ req := DownloadRequest{
@@ -114,6 +132,124 @@ 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) { func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
req := reEnrichRequest{ req := reEnrichRequest{
SpotifyID: "spotify-track-id", SpotifyID: "spotify-track-id",
@@ -195,13 +331,11 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
metadata := buildReEnrichFFmpegMetadata(&req, "") metadata := buildReEnrichFFmpegMetadata(&req, "")
// Title and Artist are never written by re-enrich (they are search keys if metadata["TITLE"] != "Song" {
// preserved as-is from the file). t.Fatalf("title = %q", metadata["TITLE"])
if _, exists := metadata["TITLE"]; exists {
t.Fatalf("TITLE should not be in metadata: %#v", metadata)
} }
if _, exists := metadata["ARTIST"]; exists { if metadata["ARTIST"] != "Artist" {
t.Fatalf("ARTIST should not be in metadata: %#v", metadata) t.Fatalf("artist = %q", metadata["ARTIST"])
} }
if metadata["ALBUM"] != "Album" { if metadata["ALBUM"] != "Album" {
t.Fatalf("album = %q", metadata["ALBUM"]) t.Fatalf("album = %q", metadata["ALBUM"])
@@ -224,3 +358,75 @@ 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,
TotalDiscs: 3,
Composer: "Composer",
})
if req.TrackNumber != 7 || req.TotalTracks != 12 {
t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks)
}
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)
}
}
func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) {
req := reEnrichRequest{
TrackNumber: 7,
TotalTracks: 12,
DiscNumber: 2,
TotalDiscs: 3,
Composer: "Composer",
}
metadata := buildReEnrichFFmpegMetadata(&req, "")
if metadata["TRACKNUMBER"] != "7/12" {
t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"])
}
if metadata["DISCNUMBER"] != "2/3" {
t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"])
}
if metadata["COMPOSER"] != "Composer" {
t.Fatalf("COMPOSER = %q", metadata["COMPOSER"])
}
}
+54 -42
View File
@@ -43,12 +43,12 @@ func compareVersions(v1, v2 string) int {
return 0 return 0
} }
type LoadedExtension struct { type loadedExtension struct {
ID string `json:"id"` ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"` Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"` VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"` VMMu sync.Mutex `json:"-"`
runtime *ExtensionRuntime runtime *extensionRuntime
initialized bool initialized bool
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
@@ -73,7 +73,7 @@ func getExtensionInitSettings(extensionID string) map[string]interface{} {
return filtered return filtered
} }
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error { func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
if ext.VM == nil || ext.runtime == nil { if ext.VM == nil || ext.runtime == nil {
if err := initializeVMLocked(ext); err != nil { if err := initializeVMLocked(ext); err != nil {
ext.Error = err.Error() ext.Error = err.Error()
@@ -100,14 +100,14 @@ func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) er
return nil return nil
} }
func (ext *LoadedExtension) ensureRuntimeReady() error { func (ext *loadedExtension) ensureRuntimeReady() error {
ext.VMMu.Lock() ext.VMMu.Lock()
defer ext.VMMu.Unlock() defer ext.VMMu.Unlock()
return ensureRuntimeReadyLocked(ext, true) return ensureRuntimeReadyLocked(ext, true)
} }
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) { func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
ext.VMMu.Lock() ext.VMMu.Lock()
if err := ensureRuntimeReadyLocked(ext, true); err != nil { if err := ensureRuntimeReadyLocked(ext, true); err != nil {
ext.VMMu.Unlock() ext.VMMu.Unlock()
@@ -116,28 +116,28 @@ func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
return ext.VM, nil return ext.VM, nil
} }
type ExtensionManager struct { type extensionManager struct {
mu sync.RWMutex mu sync.RWMutex
extensions map[string]*LoadedExtension extensions map[string]*loadedExtension
extensionsDir string extensionsDir string
dataDir string dataDir string
} }
var ( var (
globalExtManager *ExtensionManager globalExtManager *extensionManager
globalExtManagerOnce sync.Once globalExtManagerOnce sync.Once
) )
func GetExtensionManager() *ExtensionManager { func getExtensionManager() *extensionManager {
globalExtManagerOnce.Do(func() { globalExtManagerOnce.Do(func() {
globalExtManager = &ExtensionManager{ globalExtManager = &extensionManager{
extensions: make(map[string]*LoadedExtension), extensions: make(map[string]*loadedExtension),
} }
}) })
return globalExtManager return globalExtManager
} }
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error { func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -154,7 +154,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
return nil return nil
} }
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) { func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
@@ -272,7 +272,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return nil, fmt.Errorf("failed to create extension data directory: %w", err) return nil, fmt.Errorf("failed to create extension data directory: %w", err)
} }
ext := &LoadedExtension{ ext := &loadedExtension{
ID: manifest.Name, ID: manifest.Name,
Manifest: manifest, Manifest: manifest,
Enabled: false, // New extensions start disabled Enabled: false, // New extensions start disabled
@@ -292,7 +292,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil return ext, nil
} }
func initializeVMLocked(ext *LoadedExtension) error { func initializeVMLocked(ext *loadedExtension) error {
ext.VM = nil ext.VM = nil
ext.runtime = nil ext.runtime = nil
ext.initialized = false ext.initialized = false
@@ -305,7 +305,7 @@ func initializeVMLocked(ext *LoadedExtension) error {
return fmt.Errorf("failed to read index.js: %w", err) return fmt.Errorf("failed to read index.js: %w", err)
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
ext.runtime = runtime ext.runtime = runtime
runtime.RegisterAPIs(vm) runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm) runtime.RegisterGoBackendAPIs(vm)
@@ -342,14 +342,14 @@ func initializeVMLocked(ext *LoadedExtension) error {
return nil return nil
} }
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { func (m *extensionManager) initializeVM(ext *loadedExtension) error {
ext.VMMu.Lock() ext.VMMu.Lock()
defer ext.VMMu.Unlock() defer ext.VMMu.Unlock()
return initializeVMLocked(ext) return initializeVMLocked(ext)
} }
func initializeExtensionWithSettingsLocked( func initializeExtensionWithSettingsLocked(
ext *LoadedExtension, ext *loadedExtension,
settings map[string]interface{}, settings map[string]interface{},
) error { ) error {
if ext.VM == nil { if ext.VM == nil {
@@ -405,7 +405,7 @@ func initializeExtensionWithSettingsLocked(
return nil return nil
} }
func runCleanupLocked(ext *LoadedExtension) error { func runCleanupLocked(ext *loadedExtension) error {
if ext.VM != nil { if ext.VM != nil {
script := ` script := `
(function() { (function() {
@@ -446,7 +446,7 @@ func runCleanupLocked(ext *LoadedExtension) error {
return nil return nil
} }
func teardownVMLocked(ext *LoadedExtension) { func teardownVMLocked(ext *loadedExtension) {
if err := runCleanupLocked(ext); err != nil { if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err) GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
} }
@@ -461,7 +461,7 @@ func teardownVMLocked(ext *LoadedExtension) {
ext.initialized = false ext.initialized = false
} }
func validateExtensionLoad(ext *LoadedExtension) error { func validateExtensionLoad(ext *loadedExtension) error {
ext.VMMu.Lock() ext.VMMu.Lock()
defer ext.VMMu.Unlock() defer ext.VMMu.Unlock()
@@ -472,7 +472,7 @@ func validateExtensionLoad(ext *LoadedExtension) error {
return nil return nil
} }
func (m *ExtensionManager) UnloadExtension(extensionID string) error { func (m *extensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -491,7 +491,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return nil return nil
} }
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) { func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -502,18 +502,18 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
return ext, nil return ext, nil
} }
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension { func (m *extensionManager) GetAllExtensions() []*loadedExtension {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
result := make([]*LoadedExtension, 0, len(m.extensions)) result := make([]*loadedExtension, 0, len(m.extensions))
for _, ext := range m.extensions { for _, ext := range m.extensions {
result = append(result, ext) result = append(result, ext)
} }
return result return result
} }
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error { func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -547,7 +547,7 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return nil return nil
} }
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) { func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
var loaded []string var loaded []string
var errors []error var errors []error
@@ -585,7 +585,7 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
return loaded, errors return loaded, errors
} }
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) { func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -615,7 +615,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return nil, fmt.Errorf("failed to create extension data directory: %w", err) return nil, fmt.Errorf("failed to create extension data directory: %w", err)
} }
ext := &LoadedExtension{ ext := &loadedExtension{
ID: manifest.Name, ID: manifest.Name,
Manifest: manifest, Manifest: manifest,
Enabled: false, // Will be restored from settings store Enabled: false, // Will be restored from settings store
@@ -643,7 +643,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return ext, nil return ext, nil
} }
func (m *ExtensionManager) RemoveExtension(extensionID string) error { func (m *extensionManager) RemoveExtension(extensionID string) error {
ext, err := m.GetExtension(extensionID) ext, err := m.GetExtension(extensionID)
if err != nil { if err != nil {
return err return err
@@ -663,7 +663,7 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
} }
// Only allows upgrades (new version > current version), not downgrades // Only allows upgrades (new version > current version), not downgrades
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
@@ -777,7 +777,7 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
} }
} }
ext := &LoadedExtension{ ext := &loadedExtension{
ID: newManifest.Name, ID: newManifest.Name,
Manifest: newManifest, Manifest: newManifest,
Enabled: wasEnabled, // Preserve enabled state from before upgrade Enabled: wasEnabled, // Preserve enabled state from before upgrade
@@ -812,7 +812,7 @@ type ExtensionUpgradeInfo struct {
IsInstalled bool `json:"is_installed"` IsInstalled bool `json:"is_installed"`
} }
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) { func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
@@ -871,7 +871,7 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return info, nil return info, nil
} }
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) { func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
info, err := m.checkExtensionUpgradeInternal(filePath) info, err := m.checkExtensionUpgradeInternal(filePath)
if err != nil { if err != nil {
return "", err return "", err
@@ -885,7 +885,7 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions() extensions := m.GetAllExtensions()
type ExtensionInfo struct { type ExtensionInfo struct {
@@ -893,7 +893,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"` IconPath string `json:"icon_path,omitempty"`
@@ -951,7 +950,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
Name: ext.Manifest.Name, Name: ext.Manifest.Name,
DisplayName: ext.Manifest.DisplayName, DisplayName: ext.Manifest.DisplayName,
Version: ext.Manifest.Version, Version: ext.Manifest.Version,
Author: ext.Manifest.Author,
Description: ext.Manifest.Description, Description: ext.Manifest.Description,
Homepage: ext.Manifest.Homepage, Homepage: ext.Manifest.Homepage,
IconPath: iconPath, IconPath: iconPath,
@@ -982,7 +980,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error { func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -1000,7 +998,7 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return initializeExtensionWithSettingsLocked(ext, settings) return initializeExtensionWithSettingsLocked(ext, settings)
} }
func (m *ExtensionManager) CleanupExtension(extensionID string) error { func (m *extensionManager) CleanupExtension(extensionID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -1022,7 +1020,7 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return nil return nil
} }
func (m *ExtensionManager) UnloadAllExtensions() { func (m *extensionManager) UnloadAllExtensions() {
m.mu.Lock() m.mu.Lock()
extensionIDs := make([]string, 0, len(m.extensions)) extensionIDs := make([]string, 0, len(m.extensions))
for id := range m.extensions { for id := range m.extensions {
@@ -1037,7 +1035,7 @@ func (m *ExtensionManager) UnloadAllExtensions() {
GoLog("[Extension] All extensions unloaded\n") GoLog("[Extension] All extensions unloaded\n")
} }
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) { func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -1055,15 +1053,29 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
} }
defer ext.VMMu.Unlock() 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(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try { try {
var result = extension.%s(); var result = extension.%s();
if (result && typeof result.then === 'function') { if (result && typeof result.then === 'function') {
// Handle promise - return pending status
return { success: true, pending: true, message: 'Action started' }; 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 }; return { success: true, result: result };
} catch (e) { } catch (e) {
return { success: false, error: e.toString() }; return { success: false, error: e.toString() };
-5
View File
@@ -105,7 +105,6 @@ type ExtensionManifest struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
@@ -155,10 +154,6 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "version", Message: "version is required"} 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) == "" { if strings.TrimSpace(m.Description) == "" {
return &ManifestValidationError{Field: "description", Message: "description is required"} return &ManifestValidationError{Field: "description", Message: "description is required"}
} }
+347 -148
View File
@@ -1,7 +1,6 @@
package gobackend package gobackend
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -26,7 +25,9 @@ type ExtTrackMetadata struct {
Images string `json:"images,omitempty"` Images string `json:"images,omitempty"`
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"` TrackNumber int `json:"track_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
ProviderID string `json:"provider_id"` ProviderID string `json:"provider_id"`
ItemType string `json:"item_type,omitempty"` ItemType string `json:"item_type,omitempty"`
@@ -41,6 +42,7 @@ type ExtTrackMetadata struct {
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"` Copyright string `json:"copyright,omitempty"`
Genre string `json:"genre,omitempty"` Genre string `json:"genre,omitempty"`
Composer string `json:"composer,omitempty"`
} }
func (t *ExtTrackMetadata) ResolvedCoverURL() string { func (t *ExtTrackMetadata) ResolvedCoverURL() string {
@@ -93,6 +95,15 @@ type ExtDownloadURLResult struct {
SampleRate int `json:"sample_rate,omitempty"` 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 { type ExtDownloadResult struct {
Success bool `json:"success"` Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"` FilePath string `json:"file_path,omitempty"`
@@ -101,31 +112,105 @@ type ExtDownloadResult struct {
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"` ErrorType string `json:"error_type,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"` Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"` Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"` AlbumArtist string `json:"album_artist,omitempty"`
TrackNumber int `json:"track_number,omitempty"` TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"` DecryptionKey string `json:"decryption_key,omitempty"`
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
} }
type ExtensionProviderWrapper struct { const genericFFmpegMOVDecryptionStrategy = "ffmpeg.mov_key"
extension *LoadedExtension
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)
}
type extensionProviderWrapper struct {
extension *loadedExtension
vm *goja.Runtime vm *goja.Runtime
} }
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper { func newExtensionProviderWrapper(ext *loadedExtension) *extensionProviderWrapper {
return &ExtensionProviderWrapper{ return &extensionProviderWrapper{
extension: ext, extension: ext,
vm: ext.VM, vm: ext.VM,
} }
} }
func (p *ExtensionProviderWrapper) lockReadyVM() error { func (p *extensionProviderWrapper) lockReadyVM() error {
vm, err := p.extension.lockReadyVM() vm, err := p.extension.lockReadyVM()
if err != nil { if err != nil {
return err return err
@@ -134,7 +219,7 @@ func (p *ExtensionProviderWrapper) lockReadyVM() error {
return nil return nil
} }
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
} }
@@ -194,7 +279,7 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return &searchResult, nil return &searchResult, nil
} }
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) { func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
} }
@@ -243,7 +328,7 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return &track, nil return &track, nil
} }
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) { func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
} }
@@ -295,7 +380,7 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return &album, nil return &album, nil
} }
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) { func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
} }
@@ -350,7 +435,7 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return &artist, nil return &artist, nil
} }
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) { func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return track, nil return track, nil
} }
@@ -412,7 +497,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return &enrichedTrack, nil return &enrichedTrack, nil
} }
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) { func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) {
if !p.extension.Manifest.IsDownloadProvider() { if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
} }
@@ -460,7 +545,7 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return &availability, nil return &availability, nil
} }
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) { func (p *extensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
if !p.extension.Manifest.IsDownloadProvider() { if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
} }
@@ -510,7 +595,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
const ExtDownloadTimeout = DownloadTimeout const ExtDownloadTimeout = DownloadTimeout
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) { func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() { if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
} }
@@ -530,6 +615,10 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID
p.extension.runtime.setActiveDownloadItemID(itemID) p.extension.runtime.setActiveDownloadItemID(itemID)
defer p.extension.runtime.clearActiveDownloadItemID() defer p.extension.runtime.clearActiveDownloadItemID()
} }
if itemID != "" {
initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 { if len(call.Arguments) > 0 {
@@ -597,44 +686,52 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID
ErrorType: "internal_error", ErrorType: "internal_error",
}, nil }, nil
} }
downloadResult.Decryption = normalizeDownloadDecryptionInfo(
downloadResult.Decryption,
downloadResult.DecryptionKey,
)
downloadResult.DecryptionKey = normalizedDownloadDecryptionKey(
downloadResult.Decryption,
downloadResult.DecryptionKey,
)
return &downloadResult, nil return &downloadResult, nil
} }
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper { func (m *extensionManager) GetMetadataProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.IsMetadataProvider() && ext.Error == "" { if ext.Enabled && ext.Manifest.IsMetadataProvider() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
return providers return providers
} }
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper { func (m *extensionManager) GetDownloadProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.IsDownloadProvider() && ext.Error == "" { if ext.Enabled && ext.Manifest.IsDownloadProvider() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
return providers return providers
} }
func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) { func (m *extensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
providers := m.GetMetadataProviders() providers := m.GetMetadataProviders()
if len(providers) == 0 { if len(providers) == 0 {
return nil, nil return nil, nil
} }
providerByID := make(map[string]*ExtensionProviderWrapper, len(providers)) providerByID := make(map[string]*extensionProviderWrapper, len(providers))
orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers)) orderedProviders := make([]*extensionProviderWrapper, 0, len(providers))
for _, provider := range providers { for _, provider := range providers {
providerByID[provider.extension.ID] = provider providerByID[provider.extension.ID] = provider
} }
@@ -673,6 +770,9 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
var providerPriority []string var providerPriority []string
var providerPriorityMu sync.RWMutex var providerPriorityMu sync.RWMutex
var extensionFallbackProviderIDs []string
var extensionFallbackProviderIDsMu sync.RWMutex
var metadataProviderPriority []string var metadataProviderPriority []string
var metadataProviderPriorityMu sync.RWMutex var metadataProviderPriorityMu sync.RWMutex
@@ -681,8 +781,8 @@ var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
func SetProviderPriority(providerIDs []string) { func SetProviderPriority(providerIDs []string) {
providerPriorityMu.Lock() providerPriorityMu.Lock()
defer providerPriorityMu.Unlock() defer providerPriorityMu.Unlock()
providerPriority = providerIDs providerPriority = sanitizeDownloadProviderPriority(providerIDs)
GoLog("[Extension] Download provider priority set: %v\n", providerIDs) GoLog("[Extension] Download provider priority set: %v\n", providerPriority)
} }
func GetProviderPriority() []string { func GetProviderPriority() []string {
@@ -690,7 +790,7 @@ func GetProviderPriority() []string {
defer providerPriorityMu.RUnlock() defer providerPriorityMu.RUnlock()
if len(providerPriority) == 0 { if len(providerPriority) == 0 {
return []string{"tidal", "qobuz", "deezer"} return []string{"tidal", "qobuz"}
} }
result := make([]string, len(providerPriority)) result := make([]string, len(providerPriority))
@@ -698,11 +798,104 @@ func GetProviderPriority() []string {
return result 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) { func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock() metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock() defer metadataProviderPriorityMu.Unlock()
sanitized := make([]string, 0, len(providerIDs)+3) sanitized := make([]string, 0, len(providerIDs)+2)
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, providerID := range providerIDs { for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID) providerID = strings.TrimSpace(providerID)
@@ -715,7 +908,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
seen[providerID] = struct{}{} seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID) sanitized = append(sanitized, providerID)
} }
for _, providerID := range []string{"deezer", "qobuz", "tidal"} { for _, providerID := range []string{"qobuz", "tidal"} {
if _, exists := seen[providerID]; exists { if _, exists := seen[providerID]; exists {
continue continue
} }
@@ -732,7 +925,7 @@ func GetMetadataProviderPriority() []string {
defer metadataProviderPriorityMu.RUnlock() defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 { if len(metadataProviderPriority) == 0 {
return []string{"deezer", "qobuz", "tidal"} return []string{"qobuz", "tidal"}
} }
result := make([]string, len(metadataProviderPriority)) result := make([]string, len(metadataProviderPriority))
@@ -742,7 +935,16 @@ func GetMetadataProviderPriority() []string {
func isBuiltInProvider(providerID string) bool { func isBuiltInProvider(providerID string) bool {
switch providerID { switch providerID {
case "tidal", "qobuz", "deezer": case "tidal", "qobuz":
return true
default:
return false
}
}
func isBuiltInDownloadProvider(providerID string) bool {
switch providerID {
case "tidal", "qobuz":
return true return true
default: default:
return false return false
@@ -775,7 +977,9 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr
Images: track.Images, Images: track.Images,
ReleaseDate: track.ReleaseDate, ReleaseDate: track.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: track.TrackNumber,
TotalTracks: track.TotalTracks,
DiscNumber: track.DiscNumber, DiscNumber: track.DiscNumber,
TotalDiscs: track.TotalDiscs,
ISRC: track.ISRC, ISRC: track.ISRC,
ProviderID: providerID, ProviderID: providerID,
SpotifyID: prefixedID, SpotifyID: prefixedID,
@@ -783,6 +987,7 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr
TidalID: tidalID, TidalID: tidalID,
QobuzID: qobuzID, QobuzID: qobuzID,
AlbumType: track.AlbumType, AlbumType: track.AlbumType,
Composer: track.Composer,
} }
} }
@@ -801,20 +1006,6 @@ func metadataTrackDedupKey(track ExtTrackMetadata) string {
func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) { func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
switch providerID { 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": case "qobuz":
return NewQobuzDownloader().SearchTracks(query, limit) return NewQobuzDownloader().SearchTracks(query, limit)
case "tidal": case "tidal":
@@ -824,13 +1015,13 @@ func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrac
} }
} }
func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) { func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
priority := GetMetadataProviderPriority() priority := GetMetadataProviderPriority()
if limit <= 0 { if limit <= 0 {
limit = 20 limit = 20
} }
extensionProviders := make(map[string]*ExtensionProviderWrapper) extensionProviders := make(map[string]*extensionProviderWrapper)
if includeExtensions { if includeExtensions {
for _, provider := range m.GetMetadataProviders() { for _, provider := range m.GetMetadataProviders() {
extensionProviders[provider.extension.ID] = provider extensionProviders[provider.extension.ID] = provider
@@ -910,7 +1101,7 @@ func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
priority := GetProviderPriority() priority := GetProviderPriority()
extManager := GetExtensionManager() extManager := getExtensionManager()
strictMode := !req.UseFallback strictMode := !req.UseFallback
selectedProvider := strings.TrimSpace(req.Service) selectedProvider := strings.TrimSpace(req.Service)
@@ -924,7 +1115,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
} }
if !strictMode && req.Service != "" && isBuiltInProvider(strings.ToLower(req.Service)) { if !strictMode && req.Service != "" && isBuiltInDownloadProvider(strings.ToLower(req.Service)) {
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service) GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
newPriority := []string{req.Service} newPriority := []string{req.Service}
for _, p := range priority { for _, p := range priority {
@@ -934,7 +1125,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
priority = newPriority priority = newPriority
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority) GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
} else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) { } else if !strictMode && req.Service != "" && !isBuiltInDownloadProvider(strings.ToLower(req.Service)) {
found := false found := false
for _, p := range priority { for _, p := range priority {
if strings.EqualFold(p, req.Service) { if strings.EqualFold(p, req.Service) {
@@ -965,7 +1156,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() { if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source) GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
provider := NewExtensionProviderWrapper(ext) provider := newExtensionProviderWrapper(ext)
trackMeta := &ExtTrackMetadata{ trackMeta := &ExtTrackMetadata{
ID: req.SpotifyID, ID: req.SpotifyID,
Name: req.TrackName, Name: req.TrackName,
@@ -975,8 +1166,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ISRC: req.ISRC, ISRC: req.ISRC,
ReleaseDate: req.ReleaseDate, ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber, TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, DiscNumber: req.DiscNumber,
TotalDiscs: req.TotalDiscs,
ProviderID: req.Source, ProviderID: req.Source,
Composer: req.Composer,
} }
enrichedTrack, err := provider.EnrichTrack(trackMeta) enrichedTrack, err := provider.EnrichTrack(trackMeta)
@@ -1041,10 +1235,22 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber) GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber)
req.TrackNumber = enrichedTrack.TrackNumber req.TrackNumber = enrichedTrack.TrackNumber
} }
if enrichedTrack.TotalTracks > 0 && req.TotalTracks == 0 {
GoLog("[DownloadWithExtensionFallback] TotalTracks from enrichment: %d\n", enrichedTrack.TotalTracks)
req.TotalTracks = enrichedTrack.TotalTracks
}
if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 { if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 {
GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber) GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber)
req.DiscNumber = enrichedTrack.DiscNumber req.DiscNumber = enrichedTrack.DiscNumber
} }
if enrichedTrack.TotalDiscs > 0 && req.TotalDiscs == 0 {
GoLog("[DownloadWithExtensionFallback] TotalDiscs from enrichment: %d\n", enrichedTrack.TotalDiscs)
req.TotalDiscs = enrichedTrack.TotalDiscs
}
if enrichedTrack.Composer != "" && req.Composer == "" {
GoLog("[DownloadWithExtensionFallback] Composer from enrichment: %s\n", enrichedTrack.Composer)
req.Composer = enrichedTrack.Composer
}
} }
} }
} }
@@ -1077,9 +1283,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if track.TrackNumber > 0 && req.TrackNumber == 0 { if track.TrackNumber > 0 && req.TrackNumber == 0 {
req.TrackNumber = track.TrackNumber req.TrackNumber = track.TrackNumber
} }
if track.TotalTracks > 0 && req.TotalTracks == 0 {
req.TotalTracks = track.TotalTracks
}
if track.DiscNumber > 0 && req.DiscNumber == 0 { if track.DiscNumber > 0 && req.DiscNumber == 0 {
req.DiscNumber = track.DiscNumber req.DiscNumber = track.DiscNumber
} }
if track.TotalDiscs > 0 && req.TotalDiscs == 0 {
req.TotalDiscs = track.TotalDiscs
}
if track.Composer != "" && req.Composer == "" {
req.Composer = track.Composer
}
if track.CoverURL != "" && req.CoverURL == "" { if track.CoverURL != "" && req.CoverURL == "" {
req.CoverURL = track.CoverURL req.CoverURL = track.CoverURL
} }
@@ -1098,21 +1313,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.ISRC != "" && if req.ISRC != "" &&
(req.Genre == "" || req.Label == "" || req.Copyright == "") { (req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
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)
}
} }
} }
@@ -1125,7 +1326,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() { if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
skipBuiltIn = ext.Manifest.SkipBuiltInFallback skipBuiltIn = ext.Manifest.SkipBuiltInFallback
provider := NewExtensionProviderWrapper(ext) provider := newExtensionProviderWrapper(ext)
trackID := req.SpotifyID trackID := req.SpotifyID
@@ -1168,14 +1369,17 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey, DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
} }
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else { } else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) 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 { if ext.Manifest.SkipMetadataEnrichment {
@@ -1273,37 +1477,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue continue
} }
if skipBuiltIn && isBuiltInProvider(providerIDNormalized) { if skipBuiltIn && isBuiltInDownloadProvider(providerIDNormalized) {
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID) GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
continue 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) GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerIDNormalized) { if isBuiltInDownloadProvider(providerIDNormalized) {
if (req.Genre == "" || req.Label == "" || req.Copyright == "") && if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
req.ISRC != "" { req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) GoLog("[DownloadWithExtensionFallback] Enriching extra metadata from ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
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)
}
} }
result, err := tryBuiltInProvider(providerIDNormalized, req) result, err := tryBuiltInProvider(providerIDNormalized, req)
@@ -1346,7 +1536,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue continue
} }
provider := NewExtensionProviderWrapper(ext) provider := newExtensionProviderWrapper(ext)
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID) availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID)
if err != nil || !availability.Available { if err != nil || !availability.Available {
@@ -1394,14 +1584,17 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey, DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
} }
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else { } else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) 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 { if ext.Manifest.SkipMetadataEnrichment {
@@ -1534,24 +1727,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
} }
} }
err = 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,
}
}
err = deezerErr
default: default:
return nil, fmt.Errorf("unknown built-in provider: %s", providerID) return nil, fmt.Errorf("unknown built-in provider: %s", providerID)
} }
@@ -1579,6 +1754,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
Copyright: req.Copyright, Copyright: req.Copyright,
LyricsLRC: result.LyricsLRC, LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey, DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
}, nil }, nil
} }
@@ -1594,12 +1770,15 @@ func buildOutputPath(req DownloadRequest) string {
"album_artist": req.AlbumArtist, "album_artist": req.AlbumArtist,
"track": req.TrackNumber, "track": req.TrackNumber,
"track_number": req.TrackNumber, "track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"disc": req.DiscNumber, "disc": req.DiscNumber,
"disc_number": req.DiscNumber, "disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate), "year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate, "date": req.ReleaseDate,
"release_date": req.ReleaseDate, "release_date": req.ReleaseDate,
"isrc": req.ISRC, "isrc": req.ISRC,
"composer": req.Composer,
} }
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
@@ -1617,19 +1796,24 @@ func buildOutputPath(req DownloadRequest) string {
outputDir := req.OutputDir outputDir := req.OutputDir
if strings.TrimSpace(outputDir) == "" { if strings.TrimSpace(outputDir) == "" {
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads") 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) return filepath.Join(outputDir, filename+ext)
} }
func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) string { func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string {
if strings.TrimSpace(req.OutputPath) != "" { if strings.TrimSpace(req.OutputPath) != "" {
return strings.TrimSpace(req.OutputPath) outputPath := strings.TrimSpace(req.OutputPath)
AddAllowedDownloadDir(filepath.Dir(outputPath))
return outputPath
} }
if strings.TrimSpace(req.OutputDir) != "" { // 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) != "" {
return buildOutputPath(req) return buildOutputPath(req)
} }
@@ -1644,12 +1828,15 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
"album_artist": req.AlbumArtist, "album_artist": req.AlbumArtist,
"track": req.TrackNumber, "track": req.TrackNumber,
"track_number": req.TrackNumber, "track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"disc": req.DiscNumber, "disc": req.DiscNumber,
"disc_number": req.DiscNumber, "disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate), "year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate, "date": req.ReleaseDate,
"release_date": req.ReleaseDate, "release_date": req.ReleaseDate,
"isrc": req.ISRC, "isrc": req.ISRC,
"composer": req.Composer,
} }
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
@@ -1667,7 +1854,19 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
return filepath.Join(tempDir, filename+outputExt) return filepath.Join(tempDir, filename+outputExt)
} }
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { func canEmbedGenreLabel(filePath string) bool {
path := strings.TrimSpace(filePath)
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
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() { if !p.extension.Manifest.HasCustomSearch() {
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
} }
@@ -1749,7 +1948,7 @@ type ExtURLHandleResult struct {
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
} }
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) { func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
if !p.extension.Manifest.HasURLHandler() { if !p.extension.Manifest.HasURLHandler() {
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
} }
@@ -1835,7 +2034,7 @@ type MatchTrackResult struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
} }
func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) { func (p *extensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
if !p.extension.Manifest.HasCustomMatching() { if !p.extension.Manifest.HasCustomMatching() {
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
} }
@@ -1906,7 +2105,7 @@ type PostProcessInput struct {
const PostProcessTimeout = 2 * time.Minute const PostProcessTimeout = 2 * time.Minute
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() { if !p.extension.Manifest.HasPostProcessing() {
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
} }
@@ -1969,7 +2168,7 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return &postResult, nil return &postResult, nil
} }
func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() { if !p.extension.Manifest.HasPostProcessing() {
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
} }
@@ -2039,39 +2238,39 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
return &postResult, nil return &postResult, nil
} }
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper { func (m *extensionManager) GetSearchProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" { if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
return providers return providers
} }
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper { func (m *extensionManager) GetURLHandlers() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" { if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
return providers return providers
} }
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper { func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" { if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" {
return NewExtensionProviderWrapper(ext) return newExtensionProviderWrapper(ext)
} }
} }
return nil return nil
@@ -2082,7 +2281,7 @@ type ExtURLHandleResultWithExtID struct {
ExtensionID string ExtensionID string
} }
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) { func (m *extensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
handler := m.FindURLHandler(url) handler := m.FindURLHandler(url)
if handler == nil { if handler == nil {
return nil, fmt.Errorf("no extension found to handle URL: %s", url) return nil, fmt.Errorf("no extension found to handle URL: %s", url)
@@ -2102,20 +2301,20 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu
}, nil }, nil
} }
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper { func (m *extensionManager) GetPostProcessingProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.HasPostProcessing() && ext.Error == "" { if ext.Enabled && ext.Manifest.HasPostProcessing() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
return providers return providers
} }
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) { func (m *extensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders() providers := m.GetPostProcessingProviders()
if len(providers) == 0 { if len(providers) == 0 {
return &PostProcessResult{Success: true, NewFilePath: filePath}, nil return &PostProcessResult{Success: true, NewFilePath: filePath}, nil
@@ -2160,7 +2359,7 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
} }
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) { func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders() providers := m.GetPostProcessingProviders()
if len(providers) == 0 { if len(providers) == 0 {
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
@@ -2228,7 +2427,7 @@ type ExtLyricsLine struct {
EndTimeMs int64 `json:"endTimeMs"` EndTimeMs int64 `json:"endTimeMs"`
} }
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) { func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
if !p.extension.Manifest.IsLyricsProvider() { if !p.extension.Manifest.IsLyricsProvider() {
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
} }
@@ -2326,14 +2525,14 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
return response, nil return response, nil
} }
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper { func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" { if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
+189 -12
View File
@@ -1,6 +1,10 @@
package gobackend package gobackend
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) { func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
original := GetMetadataProviderPriority() original := GetMetadataProviderPriority()
@@ -8,7 +12,7 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
SetMetadataProviderPriority([]string{"tidal"}) SetMetadataProviderPriority([]string{"tidal"})
got := GetMetadataProviderPriority() got := GetMetadataProviderPriority()
want := []string{"tidal", "deezer", "qobuz"} want := []string{"tidal", "qobuz"}
if len(got) != len(want) { if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want) t.Fatalf("unexpected priority length: got %v want %v", got, want)
} }
@@ -19,6 +23,183 @@ 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)
}
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(tempFile) {
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
}
}
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) { func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
originalPriority := GetMetadataProviderPriority() originalPriority := GetMetadataProviderPriority()
originalSearch := searchBuiltInMetadataTracksFunc originalSearch := searchBuiltInMetadataTracksFunc
@@ -27,7 +208,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
searchBuiltInMetadataTracksFunc = originalSearch searchBuiltInMetadataTracksFunc = originalSearch
}() }()
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"}) SetMetadataProviderPriority([]string{"qobuz", "tidal"})
var calls []string var calls []string
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) { searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
@@ -42,27 +223,23 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"}, {ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"}, {ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
}, nil }, nil
case "deezer":
return []ExtTrackMetadata{
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
}, nil
default: default:
return nil, nil return nil, nil
} }
} }
manager := GetExtensionManager() manager := getExtensionManager()
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false) tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
if err != nil { if err != nil {
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err) t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
} }
if len(tracks) != 3 { if len(tracks) != 2 {
t.Fatalf("unexpected track count: got %d want 3", len(tracks)) t.Fatalf("unexpected track count: got %d want 2", len(tracks))
} }
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" { if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" {
t.Fatalf("unexpected track provider order: %+v", tracks) t.Fatalf("unexpected track provider order: %+v", tracks)
} }
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" { if len(calls) != 2 || calls[0] != "qobuz" || calls[1] != "tidal" {
t.Fatalf("unexpected provider call order: %v", calls) t.Fatalf("unexpected provider call order: %v", calls)
} }
} }
+30 -9
View File
@@ -80,7 +80,7 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
state.IsAuthenticated = accessToken != "" state.IsAuthenticated = accessToken != ""
} }
type ExtensionRuntime struct { type extensionRuntime struct {
extensionID string extensionID string
manifest *ExtensionManifest manifest *ExtensionManifest
settings map[string]interface{} settings map[string]interface{}
@@ -123,10 +123,10 @@ var (
privateIPCacheMu sync.RWMutex privateIPCacheMu sync.RWMutex
) )
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
jar, _ := newSimpleCookieJar() jar, _ := newSimpleCookieJar()
runtime := &ExtensionRuntime{ runtime := &extensionRuntime{
extensionID: ext.ID, extensionID: ext.ID,
manifest: ext.Manifest, manifest: ext.Manifest,
settings: make(map[string]interface{}), settings: make(map[string]interface{}),
@@ -142,25 +142,38 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
return runtime return runtime
} }
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) { func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
r.activeDownloadMu.Lock() r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock() defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = strings.TrimSpace(itemID) r.activeDownloadItemID = strings.TrimSpace(itemID)
} }
func (r *ExtensionRuntime) clearActiveDownloadItemID() { func (r *extensionRuntime) clearActiveDownloadItemID() {
r.activeDownloadMu.Lock() r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock() defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = "" r.activeDownloadItemID = ""
} }
func (r *ExtensionRuntime) getActiveDownloadItemID() string { func (r *extensionRuntime) getActiveDownloadItemID() string {
r.activeDownloadMu.RLock() r.activeDownloadMu.RLock()
defer r.activeDownloadMu.RUnlock() defer r.activeDownloadMu.RUnlock()
return r.activeDownloadItemID return r.activeDownloadItemID
} }
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client { 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 // Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g. // allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops. // spotify-web) will redirect http -> https and can end up in 301 loops.
@@ -329,11 +342,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
return j.cookies[u.Host] return j.cookies[u.Host]
} }
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings r.settings = settings
} }
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
r.vm = vm r.vm = vm
httpObj := vm.NewObject() httpObj := vm.NewObject()
@@ -377,7 +390,9 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
fileObj.Set("exists", r.fileExists) fileObj.Set("exists", r.fileExists)
fileObj.Set("delete", r.fileDelete) fileObj.Set("delete", r.fileDelete)
fileObj.Set("read", r.fileRead) fileObj.Set("read", r.fileRead)
fileObj.Set("readBytes", r.fileReadBytes)
fileObj.Set("write", r.fileWrite) fileObj.Set("write", r.fileWrite)
fileObj.Set("writeBytes", r.fileWriteBytes)
fileObj.Set("copy", r.fileCopy) fileObj.Set("copy", r.fileCopy)
fileObj.Set("move", r.fileMove) fileObj.Set("move", r.fileMove)
fileObj.Set("getSize", r.fileGetSize) fileObj.Set("getSize", r.fileGetSize)
@@ -407,8 +422,14 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("stringifyJSON", r.stringifyJSON) utilsObj.Set("stringifyJSON", r.stringifyJSON)
utilsObj.Set("encrypt", r.cryptoEncrypt) utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt) utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
utilsObj.Set("generateKey", r.cryptoGenerateKey) utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent) 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) vm.Set("utils", utilsObj)
logObj := vm.NewObject() logObj := vm.NewObject()
+11 -10
View File
@@ -52,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path) return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
} }
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -99,7 +99,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -111,7 +111,7 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(state.AuthCode) return r.vm.ToValue(state.AuthCode)
} }
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -149,7 +149,7 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
delete(extensionAuthState, r.extensionID) delete(extensionAuthState, r.extensionID)
extensionAuthStateMu.Unlock() extensionAuthStateMu.Unlock()
@@ -162,7 +162,7 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -178,7 +178,7 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(state.IsAuthenticated) return r.vm.ToValue(state.IsAuthenticated)
} }
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -228,7 +228,7 @@ func generatePKCEChallenge(verifier string) string {
return base64.RawURLEncoding.EncodeToString(hash[:]) return base64.RawURLEncoding.EncodeToString(hash[:])
} }
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
length := 64 length := 64
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 { if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
@@ -265,7 +265,7 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -281,7 +281,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -385,7 +385,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
}) })
} }
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -458,6 +458,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
+359
View File
@@ -0,0 +1,359 @@
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
@@ -0,0 +1,185 @@
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())
}
}
+3 -3
View File
@@ -50,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
delete(ffmpegCommands, commandID) delete(ffmpegCommands, commandID)
} }
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -107,7 +107,7 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
} }
} }
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -134,7 +134,7 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
+209 -9
View File
@@ -71,7 +71,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
return true return true
} }
func (r *ExtensionRuntime) validatePath(path string) (string, error) { func (r *extensionRuntime) validatePath(path string) (string, error) {
if !r.manifest.Permissions.File { if !r.manifest.Permissions.File {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission") return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
} }
@@ -106,7 +106,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return absPath, nil return absPath, nil
} }
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -166,6 +166,7 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
@@ -271,7 +272,7 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -286,7 +287,7 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(err == nil) return r.vm.ToValue(err == nil)
} }
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -315,7 +316,7 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -346,7 +347,105 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileWrite(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 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -386,7 +485,108 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileCopy(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 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -459,7 +659,7 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -507,7 +707,7 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
+13 -9
View File
@@ -17,7 +17,7 @@ type HTTPResponse struct {
Headers map[string]string `json:"headers"` Headers map[string]string `json:"headers"`
} }
func (r *ExtensionRuntime) validateDomain(urlStr string) error { func (r *extensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr) parsed, err := url.Parse(urlStr)
if err != nil { if err != nil {
return fmt.Errorf("invalid URL: %w", err) return fmt.Errorf("invalid URL: %w", err)
@@ -49,7 +49,7 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
return nil return nil
} }
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"error": "URL is required", "error": "URL is required",
@@ -81,6 +81,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
@@ -124,7 +125,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"error": "URL is required", "error": "URL is required",
@@ -175,6 +176,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
@@ -221,7 +223,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"error": "URL is required", "error": "URL is required",
@@ -284,6 +286,7 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
@@ -330,19 +333,19 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call) return r.httpMethodShortcut("PUT", call)
} }
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call) return r.httpMethodShortcut("DELETE", call)
} }
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call) return r.httpMethodShortcut("PATCH", call)
} }
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"error": "URL is required", "error": "URL is required",
@@ -410,6 +413,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
@@ -455,7 +459,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}) })
} }
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok { if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock() jar.mu.Lock()
jar.cookies = make(map[string][]*http.Cookie) jar.cookies = make(map[string][]*http.Cookie)
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(0.0) return r.vm.ToValue(0.0)
} }
@@ -22,7 +22,7 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
return r.vm.ToValue(similarity) return r.vm.ToValue(similarity)
} }
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -43,7 +43,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
return r.vm.ToValue(diff <= tolerance) return r.vm.ToValue(diff <= tolerance)
} }
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
+8 -7
View File
@@ -12,7 +12,7 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.createFetchError("URL is required") return r.createFetchError("URL is required")
} }
@@ -69,6 +69,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if err != nil { if err != nil {
return r.createFetchError(err.Error()) return r.createFetchError(err.Error())
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
@@ -133,7 +134,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return responseObj return responseObj
} }
func (r *ExtensionRuntime) createFetchError(message string) goja.Value { func (r *extensionRuntime) createFetchError(message string) goja.Value {
errorObj := r.vm.NewObject() errorObj := r.vm.NewObject()
errorObj.Set("ok", false) errorObj.Set("ok", false)
errorObj.Set("status", 0) errorObj.Set("status", 0)
@@ -148,7 +149,7 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
return errorObj return errorObj
} }
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -164,7 +165,7 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded)) return r.vm.ToValue(string(decoded))
} }
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -172,7 +173,7 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
} }
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) { func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object { vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
encoder := call.This encoder := call.This
encoder.Set("encoding", "utf-8") encoder.Set("encoding", "utf-8")
@@ -252,7 +253,7 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
}) })
} }
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) { func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object { vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
urlObj := call.This urlObj := call.This
@@ -416,7 +417,7 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
}) })
} }
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) { func (r *extensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
jsonScript := ` jsonScript := `
if (typeof JSON === 'undefined') { if (typeof JSON === 'undefined') {
var JSON = { var JSON = {
+23 -23
View File
@@ -21,7 +21,7 @@ const (
storageFlushRetryDelay = 2 * time.Second storageFlushRetryDelay = 2 * time.Second
) )
func (r *ExtensionRuntime) getStoragePath() string { func (r *extensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json") return filepath.Join(r.dataDir, "storage.json")
} }
@@ -36,7 +36,7 @@ func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
return dst return dst
} }
func (r *ExtensionRuntime) ensureStorageLoaded() error { func (r *extensionRuntime) ensureStorageLoaded() error {
r.storageMu.RLock() r.storageMu.RLock()
if r.storageLoaded { if r.storageLoaded {
r.storageMu.RUnlock() r.storageMu.RUnlock()
@@ -74,7 +74,7 @@ func (r *ExtensionRuntime) ensureStorageLoaded() error {
return nil return nil
} }
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
if err := r.ensureStorageLoaded(); err != nil { if err := r.ensureStorageLoaded(); err != nil {
return nil, err return nil, err
} }
@@ -84,7 +84,7 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
return cloneInterfaceMap(r.storageCache), nil return cloneInterfaceMap(r.storageCache), nil
} }
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) { func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
if r.storageClosed { if r.storageClosed {
return return
} }
@@ -94,7 +94,7 @@ func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync) r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
} }
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error { func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
data, err := json.Marshal(storage) data, err := json.Marshal(storage)
if err != nil { if err != nil {
return err return err
@@ -106,13 +106,13 @@ func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}
return os.WriteFile(r.getStoragePath(), data, 0600) return os.WriteFile(r.getStoragePath(), data, 0600)
} }
func (r *ExtensionRuntime) flushStorageDirtyAsync() { func (r *extensionRuntime) flushStorageDirtyAsync() {
if err := r.flushStorageDirty(); err != nil { if err := r.flushStorageDirty(); err != nil {
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err) GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
} }
} }
func (r *ExtensionRuntime) flushStorageDirty() error { func (r *extensionRuntime) flushStorageDirty() error {
r.storageMu.Lock() r.storageMu.Lock()
if r.storageClosed { if r.storageClosed {
r.storageTimer = nil r.storageTimer = nil
@@ -140,7 +140,7 @@ func (r *ExtensionRuntime) flushStorageDirty() error {
return nil return nil
} }
func (r *ExtensionRuntime) flushStorageNow() error { func (r *extensionRuntime) flushStorageNow() error {
r.storageMu.Lock() r.storageMu.Lock()
if r.storageTimer != nil { if r.storageTimer != nil {
r.storageTimer.Stop() r.storageTimer.Stop()
@@ -157,7 +157,7 @@ func (r *ExtensionRuntime) flushStorageNow() error {
return r.persistStorageSnapshot(snapshot) return r.persistStorageSnapshot(snapshot)
} }
func (r *ExtensionRuntime) closeStorageFlusher() { func (r *extensionRuntime) closeStorageFlusher() {
r.storageMu.Lock() r.storageMu.Lock()
r.storageClosed = true r.storageClosed = true
r.storageDirty = false r.storageDirty = false
@@ -168,7 +168,7 @@ func (r *ExtensionRuntime) closeStorageFlusher() {
r.storageMu.Unlock() r.storageMu.Unlock()
} }
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
} }
@@ -193,7 +193,7 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value) return r.vm.ToValue(value)
} }
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -225,7 +225,7 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -254,15 +254,15 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
func (r *ExtensionRuntime) getCredentialsPath() string { func (r *extensionRuntime) getCredentialsPath() string {
return filepath.Join(r.dataDir, ".credentials.enc") return filepath.Join(r.dataDir, ".credentials.enc")
} }
func (r *ExtensionRuntime) getSaltPath() string { func (r *extensionRuntime) getSaltPath() string {
return filepath.Join(r.dataDir, ".cred_salt") return filepath.Join(r.dataDir, ".cred_salt")
} }
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) { func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath() saltPath := r.getSaltPath()
salt, err := os.ReadFile(saltPath) salt, err := os.ReadFile(saltPath)
@@ -282,7 +282,7 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
return salt, nil return salt, nil
} }
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) { func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
salt, err := r.getOrCreateSalt() salt, err := r.getOrCreateSalt()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -293,7 +293,7 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
return hash[:], nil return hash[:], nil
} }
func (r *ExtensionRuntime) ensureCredentialsLoaded() error { func (r *extensionRuntime) ensureCredentialsLoaded() error {
r.credentialsMu.RLock() r.credentialsMu.RLock()
if r.credentialsLoaded { if r.credentialsLoaded {
r.credentialsMu.RUnlock() r.credentialsMu.RUnlock()
@@ -340,7 +340,7 @@ func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
return nil return nil
} }
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
if err := r.ensureCredentialsLoaded(); err != nil { if err := r.ensureCredentialsLoaded(); err != nil {
return nil, err return nil, err
} }
@@ -350,7 +350,7 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
return cloneInterfaceMap(r.credentialsCache), nil return cloneInterfaceMap(r.credentialsCache), nil
} }
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
data, err := json.Marshal(creds) data, err := json.Marshal(creds)
if err != nil { if err != nil {
return err return err
@@ -377,7 +377,7 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
return nil return nil
} }
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -414,7 +414,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
} }
@@ -439,7 +439,7 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value) return r.vm.ToValue(value)
} }
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -464,7 +464,7 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
+7 -7
View File
@@ -11,7 +11,7 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) { func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value interface{}) {
t.Helper() t.Helper()
result := runtime.storageSet(goja.FunctionCall{ result := runtime.storageSet(goja.FunctionCall{
Arguments: []goja.Value{ Arguments: []goja.Value{
@@ -39,7 +39,7 @@ func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
} }
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) { func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "storage-test", ID: "storage-test",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "storage-test", Name: "storage-test",
@@ -47,7 +47,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
DataDir: t.TempDir(), DataDir: t.TempDir(),
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
runtime.storageFlushDelay = 25 * time.Millisecond runtime.storageFlushDelay = 25 * time.Millisecond
runtime.RegisterAPIs(goja.New()) runtime.RegisterAPIs(goja.New())
@@ -86,7 +86,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
} }
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) { func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "unload-storage-test", ID: "unload-storage-test",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "unload-storage-test", Name: "unload-storage-test",
@@ -95,13 +95,13 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
VM: goja.New(), VM: goja.New(),
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
runtime.storageFlushDelay = time.Hour runtime.storageFlushDelay = time.Hour
runtime.RegisterAPIs(ext.VM) runtime.RegisterAPIs(ext.VM)
ext.runtime = runtime ext.runtime = runtime
manager := &ExtensionManager{ manager := &extensionManager{
extensions: map[string]*LoadedExtension{ extensions: map[string]*loadedExtension{
ext.ID: ext, ext.ID: ext,
}, },
} }
+83 -20
View File
@@ -16,7 +16,7 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -24,7 +24,7 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
} }
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -36,7 +36,7 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded)) return r.vm.ToValue(string(decoded))
} }
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -45,7 +45,7 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:])) return r.vm.ToValue(hex.EncodeToString(hash[:]))
} }
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -54,7 +54,7 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:])) return r.vm.ToValue(hex.EncodeToString(hash[:]))
} }
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -66,7 +66,7 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil))) return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
} }
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -78,7 +78,7 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil))) return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
} }
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue([]byte{}) return r.vm.ToValue([]byte{})
} }
@@ -130,7 +130,7 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(jsArray) return r.vm.ToValue(jsArray)
} }
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
} }
@@ -145,7 +145,7 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result) return r.vm.ToValue(result)
} }
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -160,7 +160,7 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(data)) return r.vm.ToValue(string(data))
} }
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -187,7 +187,7 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -222,7 +222,7 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
length := 32 length := 32
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok { if l, ok := call.Arguments[0].Export().(float64); ok {
@@ -245,35 +245,98 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
}) })
} }
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent()) return r.vm.ToValue(getRandomUserAgent())
} }
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value { 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) msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg) GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
return goja.Undefined() return goja.Undefined()
} }
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments) msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg) GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
return goja.Undefined() return goja.Undefined()
} }
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments) msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg) GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
return goja.Undefined() return goja.Undefined()
} }
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) logError(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments) msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg) GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
return goja.Undefined() return goja.Undefined()
} }
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string { func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
parts := make([]string, len(args)) parts := make([]string, len(args))
for i, arg := range args { for i, arg := range args {
parts[i] = fmt.Sprintf("%v", arg.Export()) parts[i] = fmt.Sprintf("%v", arg.Export())
@@ -281,7 +344,7 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
return strings.Join(parts, " ") return strings.Join(parts, " ")
} }
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -289,7 +352,7 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
return r.vm.ToValue(sanitizeFilename(input)) return r.vm.ToValue(sanitizeFilename(input))
} }
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
gobackendObj := vm.Get("gobackend") gobackendObj := vm.Get("gobackend")
if gobackendObj == nil || goja.IsUndefined(gobackendObj) { if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
gobackendObj = vm.NewObject() gobackendObj = vm.NewObject()
+20 -8
View File
@@ -26,7 +26,6 @@ type storeExtension struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"` IconURL string `json:"icon_url,omitempty"`
@@ -83,7 +82,6 @@ type storeExtensionResponse struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
DownloadURL string `json:"download_url"` DownloadURL string `json:"download_url"`
IconURL string `json:"icon_url,omitempty"` IconURL string `json:"icon_url,omitempty"`
@@ -103,7 +101,6 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
Name: e.Name, Name: e.Name,
DisplayName: e.getDisplayName(), DisplayName: e.getDisplayName(),
Version: e.Version, Version: e.Version,
Author: e.Author,
Description: e.Description, Description: e.Description,
DownloadURL: e.getDownloadURL(), DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(), IconURL: e.getIconURL(),
@@ -253,7 +250,17 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL) LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
client := NewHTTPClientWithTimeout(30 * time.Second) client := NewHTTPClientWithTimeout(30 * time.Second)
resp, err := client.Get(s.registryURL) 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)
if err != nil { if err != nil {
if s.cache != nil { if s.cache != nil {
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err) LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
@@ -295,7 +302,7 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
return nil, err return nil, err
} }
manager := GetExtensionManager() manager := getExtensionManager()
installed := make(map[string]string) // id -> version installed := make(map[string]string) // id -> version
if manager != nil { if manager != nil {
@@ -348,7 +355,13 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL()) LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := NewHTTPClientWithTimeout(5 * time.Minute) client := NewHTTPClientWithTimeout(5 * time.Minute)
resp, err := client.Get(ext.getDownloadURL()) 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)
if err != nil { if err != nil {
return fmt.Errorf("failed to download: %w", err) return fmt.Errorf("failed to download: %w", err)
} }
@@ -481,8 +494,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
if query != "" { if query != "" {
if !containsIgnoreCase(ext.Name, queryLower) && if !containsIgnoreCase(ext.Name, queryLower) &&
!containsIgnoreCase(ext.DisplayName, queryLower) && !containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) && !containsIgnoreCase(ext.Description, queryLower) {
!containsIgnoreCase(ext.Author, queryLower) {
found := false found := false
for _, tag := range ext.Tags { for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) { if containsIgnoreCase(tag, queryLower) {
+134 -13
View File
@@ -1,8 +1,10 @@
package gobackend package gobackend
import ( import (
"net/http"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
@@ -12,7 +14,6 @@ func TestParseManifest_Valid(t *testing.T) {
"name": "test-provider", "name": "test-provider",
"displayName": "Test Provider", "displayName": "Test Provider",
"version": "1.0.0", "version": "1.0.0",
"author": "Test Author",
"description": "A test extension", "description": "A test extension",
"type": ["metadata_provider"], "type": ["metadata_provider"],
"permissions": { "permissions": {
@@ -46,7 +47,6 @@ func TestParseManifest_Valid(t *testing.T) {
func TestParseManifest_MissingName(t *testing.T) { func TestParseManifest_MissingName(t *testing.T) {
invalidManifest := `{ invalidManifest := `{
"version": "1.0.0", "version": "1.0.0",
"author": "Test Author",
"description": "A test extension", "description": "A test extension",
"type": ["metadata_provider"] "type": ["metadata_provider"]
}` }`
@@ -61,7 +61,6 @@ func TestParseManifest_MissingType(t *testing.T) {
invalidManifest := `{ invalidManifest := `{
"name": "test-provider", "name": "test-provider",
"version": "1.0.0", "version": "1.0.0",
"author": "Test Author",
"description": "A test extension" "description": "A test extension"
}` }`
@@ -99,7 +98,7 @@ func TestIsDomainAllowed(t *testing.T) {
func TestExtensionRuntime_NetworkSandbox(t *testing.T) { func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
// Create a mock extension with limited network permissions // Create a mock extension with limited network permissions
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "test-ext", ID: "test-ext",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext", Name: "test-ext",
@@ -110,7 +109,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
DataDir: t.TempDir(), DataDir: t.TempDir(),
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil { if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err) t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
@@ -132,7 +131,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
func TestExtensionRuntime_FileSandbox(t *testing.T) { func TestExtensionRuntime_FileSandbox(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "test-ext", ID: "test-ext",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext", Name: "test-ext",
@@ -143,7 +142,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
DataDir: tempDir, DataDir: tempDir,
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
validPath, err := runtime.validatePath("test.txt") validPath, err := runtime.validatePath("test.txt")
if err != nil { if err != nil {
@@ -177,7 +176,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
t.Error("Expected absolute path to be blocked") t.Error("Expected absolute path to be blocked")
} }
extNoFile := &LoadedExtension{ extNoFile := &loadedExtension{
ID: "test-ext-no-file", ID: "test-ext-no-file",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext-no-file", Name: "test-ext-no-file",
@@ -187,7 +186,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
}, },
DataDir: tempDir, DataDir: tempDir,
} }
runtimeNoFile := NewExtensionRuntime(extNoFile) runtimeNoFile := newExtensionRuntime(extNoFile)
_, err = runtimeNoFile.validatePath("test.txt") _, err = runtimeNoFile.validatePath("test.txt")
if err == nil { if err == nil {
t.Error("Expected file access to be denied without file permission") t.Error("Expected file access to be denied without file permission")
@@ -195,7 +194,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
} }
func TestExtensionRuntime_UtilityFunctions(t *testing.T) { func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "test-ext", ID: "test-ext",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext", Name: "test-ext",
@@ -203,7 +202,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
DataDir: t.TempDir(), DataDir: t.TempDir(),
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
vm := goja.New() vm := goja.New()
runtime.RegisterAPIs(vm) runtime.RegisterAPIs(vm)
@@ -239,11 +238,133 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
if result.String() == "" { if result.String() == "" {
t.Error("Expected non-empty JSON 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) { func TestExtensionRuntime_SSRFProtection(t *testing.T) {
// Create extension with limited network permissions // Create extension with limited network permissions
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "test-ext", ID: "test-ext",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext", Name: "test-ext",
@@ -254,7 +375,7 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
DataDir: t.TempDir(), DataDir: t.TempDir(),
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
privateIPs := []string{ privateIPs := []string{
"http://localhost/admin", "http://localhost/admin",
+2 -2
View File
@@ -53,7 +53,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
IsTimeout: true, IsTimeout: true,
}} }}
} else { } else {
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack())) GoLog("[extensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)} resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
} }
} }
@@ -90,7 +90,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
case <-time.After(60 * time.Second): case <-time.After(60 * time.Second):
// Goroutine is truly stuck (e.g. HTTP read with no timeout). // Goroutine is truly stuck (e.g. HTTP read with no timeout).
// Log a warning — the VM should NOT be reused after this. // Log a warning — the VM should NOT be reused after this.
GoLog("[ExtensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n") GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
return nil, &JSExecutionError{ return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)", Message: "execution timeout exceeded (force)",
IsTimeout: true, IsTimeout: true,
+29 -4
View File
@@ -6,6 +6,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"unicode/utf8"
) )
var ( var (
@@ -17,19 +19,42 @@ var (
) )
func sanitizeFilename(filename string) string { func sanitizeFilename(filename string) string {
sanitized := invalidChars.ReplaceAllString(filename, "_") sanitized := strings.ReplaceAll(filename, "/", " ")
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
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.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".") sanitized = strings.Trim(sanitized, ". ")
sanitized = strings.Join(strings.Fields(sanitized), " ")
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_") sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
sanitized = strings.Trim(sanitized, "_ ")
if !utf8.ValidString(sanitized) {
sanitized = strings.ToValidUTF8(sanitized, "_")
}
if len(sanitized) > 200 { if len(sanitized) > 200 {
sanitized = sanitized[:200] sanitized = sanitized[:200]
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
sanitized = strings.Trim(sanitized, "_ ")
} }
if sanitized == "" { if sanitized == "" {
sanitized = "untitled" return "Unknown"
} }
return sanitized return sanitized
+15
View File
@@ -83,3 +83,18 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing
t.Fatalf("expected %q, got %q", expected, formatted) 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)
}
}
+15 -2
View File
@@ -16,6 +16,19 @@ import (
"time" "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 { func getRandomUserAgent() string {
chromeVersion := rand.Intn(26) + 120 chromeVersion := rand.Intn(26) + 120
chromeBuild := rand.Intn(1500) + 6000 chromeBuild := rand.Intn(1500) + 6000
@@ -225,7 +238,7 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
} }
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
@@ -255,7 +268,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
for attempt := 0; attempt <= config.MaxRetries; attempt++ { for attempt := 0; attempt <= config.MaxRetries; attempt++ {
reqCopy := req.Clone(req.Context()) reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent()) reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
resp, err := client.Do(reqCopy) resp, err := client.Do(reqCopy)
if err != nil { if err != nil {
+1 -1
View File
@@ -11,7 +11,7 @@ func GetCloudflareBypassClient() *http.Client {
} }
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := sharedClient.Do(req) resp, err := sharedClient.Do(req)
if err != nil { if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") 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) { func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := sharedClient.Do(req) resp, err := sharedClient.Do(req)
if err == nil { if err == nil {
@@ -129,7 +129,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...") LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
reqCopy := req.Clone(req.Context()) reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent()) reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
return cloudflareBypassClient.Do(reqCopy) 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) LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context()) reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent()) reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
return cloudflareBypassClient.Do(reqCopy) return cloudflareBypassClient.Do(reqCopy)
} }
+30
View File
@@ -24,13 +24,18 @@ type LibraryScanResult struct {
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"trackNumber,omitempty"` TrackNumber int `json:"trackNumber,omitempty"`
TotalTracks int `json:"totalTracks,omitempty"`
DiscNumber int `json:"discNumber,omitempty"` DiscNumber int `json:"discNumber,omitempty"`
TotalDiscs int `json:"totalDiscs,omitempty"`
Duration int `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
ReleaseDate string `json:"releaseDate,omitempty"` ReleaseDate string `json:"releaseDate,omitempty"`
BitDepth int `json:"bitDepth,omitempty"` BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"` SampleRate int `json:"sampleRate,omitempty"`
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis) Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
Genre string `json:"genre,omitempty"` Genre string `json:"genre,omitempty"`
Composer string `json:"composer,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Format string `json:"format,omitempty"` Format string `json:"format,omitempty"`
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"` MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
} }
@@ -365,9 +370,14 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
result.AlbumArtist = metadata.AlbumArtist result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.ReleaseDate = metadata.Date result.ReleaseDate = metadata.Date
result.Genre = metadata.Genre result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetAudioQuality(filePath) quality, err := GetAudioQuality(filePath)
if err == nil { if err == nil {
@@ -397,12 +407,17 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
result.AlbumArtist = metadata.AlbumArtist result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.ReleaseDate = metadata.Date result.ReleaseDate = metadata.Date
if result.ReleaseDate == "" { if result.ReleaseDate == "" {
result.ReleaseDate = metadata.Year result.ReleaseDate = metadata.Year
} }
result.Genre = metadata.Genre result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
} }
quality, err := GetM4AQuality(filePath) quality, err := GetM4AQuality(filePath)
@@ -427,7 +442,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
result.AlbumName = metadata.Album result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist result.AlbumArtist = metadata.AlbumArtist
result.TrackNumber = metadata.TrackNumber result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.Genre = metadata.Genre result.Genre = metadata.Genre
if metadata.Date != "" { if metadata.Date != "" {
result.ReleaseDate = metadata.Date result.ReleaseDate = metadata.Date
@@ -435,6 +452,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
result.ReleaseDate = metadata.Year result.ReleaseDate = metadata.Year
} }
result.ISRC = metadata.ISRC result.ISRC = metadata.ISRC
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetMP3Quality(filePath) quality, err := GetMP3Quality(filePath)
if err == nil { if err == nil {
@@ -464,9 +484,14 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
result.AlbumArtist = metadata.AlbumArtist result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.Genre = metadata.Genre result.Genre = metadata.Genre
result.ReleaseDate = metadata.Date result.ReleaseDate = metadata.Date
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetOggQuality(filePath) quality, err := GetOggQuality(filePath)
if err == nil { if err == nil {
@@ -501,13 +526,18 @@ func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint str
result.AlbumArtist = metadata.AlbumArtist result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.Genre = metadata.Genre result.Genre = metadata.Genre
if metadata.Date != "" { if metadata.Date != "" {
result.ReleaseDate = metadata.Date result.ReleaseDate = metadata.Date
} else { } else {
result.ReleaseDate = metadata.Year result.ReleaseDate = metadata.Year
} }
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
applyDefaultLibraryMetadata(filePath, displayNameHint, result) applyDefaultLibraryMetadata(filePath, displayNameHint, result)
+28 -2
View File
@@ -39,8 +39,34 @@ var DefaultLyricsProviders = []string{
var ( var (
lyricsProvidersMu sync.RWMutex lyricsProvidersMu sync.RWMutex
lyricsProviders []string // ordered list of enabled providers 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 { type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"` IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"` IncludeRomanizationNetease bool `json:"include_romanization_netease"`
@@ -385,8 +411,8 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
primaryArtist := normalizeArtistName(artistName) primaryArtist := normalizeArtistName(artistName)
fetchOptions := GetLyricsFetchOptions() fetchOptions := GetLyricsFetchOptions()
extManager := GetExtensionManager() extManager := getExtensionManager()
var extensionProviders []*ExtensionProviderWrapper var extensionProviders []*extensionProviderWrapper
if extManager != nil { if extManager != nil {
extensionProviders = extManager.GetLyricsProviders() extensionProviders = extManager.GetLyricsProviders()
} }
+3 -2
View File
@@ -114,7 +114,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
@@ -147,7 +147,8 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { 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) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { 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 { for k, v := range neteaseHeaders {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -109,7 +109,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
for k, v := range neteaseHeaders { for k, v := range neteaseHeaders {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { 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("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
+416 -29
View File
@@ -9,6 +9,7 @@ import (
_ "image/jpeg" _ "image/jpeg"
_ "image/png" _ "image/png"
"io" "io"
"math"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -110,6 +111,7 @@ type Metadata struct {
TrackNumber int TrackNumber int
TotalTracks int TotalTracks int
DiscNumber int DiscNumber int
TotalDiscs int
ISRC string ISRC string
Description string Description string
Lyrics string Lyrics string
@@ -273,23 +275,23 @@ func ReadMetadata(filePath string) (*Metadata, error) {
trackNum := getComment(cmt, "TRACKNUMBER") trackNum := getComment(cmt, "TRACKNUMBER")
if trackNum != "" { if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
} }
if metadata.TrackNumber == 0 { if metadata.TrackNumber == 0 {
trackNum = getComment(cmt, "TRACK") trackNum = getComment(cmt, "TRACK")
if trackNum != "" { if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
} }
} }
discNum := getComment(cmt, "DISCNUMBER") discNum := getComment(cmt, "DISCNUMBER")
if discNum != "" { if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
} }
if metadata.DiscNumber == 0 { if metadata.DiscNumber == 0 {
discNum = getComment(cmt, "DISC") discNum = getComment(cmt, "DISC")
if discNum != "" { if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
} }
} }
@@ -403,26 +405,39 @@ func EditFlacFields(filePath string, fields map[string]string) error {
removeCommentKey(cmt, "ALBUM_ARTIST") removeCommentKey(cmt, "ALBUM_ARTIST")
} }
// Track/disc numbers: present + empty → clear; present + "0" → clear. // Track/disc numbers: present + empty → clear; when only totals are edited,
if v, ok := fields["track_number"]; ok { // preserve the current index number and rewrite the combined value.
trackNum := 0 if _, ok := fields["track_number"]; ok || fields["track_total"] != "" || hasMapKey(fields, "track_total") {
if v != "" { currentTrackNum, currentTotalTracks := parseIndexPair(getComment(cmt, "TRACKNUMBER"))
fmt.Sscanf(v, "%d", &trackNum) if currentTrackNum == 0 && currentTotalTracks == 0 {
currentTrackNum, currentTotalTracks = parseIndexPair(getComment(cmt, "TRACK"))
} }
if trackNum > 0 { if v, ok := fields["track_number"]; ok {
setOrClearComment(cmt, "TRACKNUMBER", strconv.Itoa(trackNum)) currentTrackNum = parsePositiveInt(v)
}
if v, ok := fields["track_total"]; ok {
currentTotalTracks = parsePositiveInt(v)
}
if currentTrackNum > 0 {
setOrClearComment(cmt, "TRACKNUMBER", formatIndexValue(currentTrackNum, currentTotalTracks))
} else { } else {
removeCommentKey(cmt, "TRACKNUMBER") removeCommentKey(cmt, "TRACKNUMBER")
} }
removeCommentKey(cmt, "TRACK") // alias removeCommentKey(cmt, "TRACK") // alias
} }
if v, ok := fields["disc_number"]; ok { if _, ok := fields["disc_number"]; ok || fields["disc_total"] != "" || hasMapKey(fields, "disc_total") {
discNum := 0 currentDiscNum, currentTotalDiscs := parseIndexPair(getComment(cmt, "DISCNUMBER"))
if v != "" { if currentDiscNum == 0 && currentTotalDiscs == 0 {
fmt.Sscanf(v, "%d", &discNum) currentDiscNum, currentTotalDiscs = parseIndexPair(getComment(cmt, "DISC"))
} }
if discNum > 0 { if v, ok := fields["disc_number"]; ok {
setOrClearComment(cmt, "DISCNUMBER", strconv.Itoa(discNum)) currentDiscNum = parsePositiveInt(v)
}
if v, ok := fields["disc_total"]; ok {
currentTotalDiscs = parsePositiveInt(v)
}
if currentDiscNum > 0 {
setOrClearComment(cmt, "DISCNUMBER", formatIndexValue(currentDiscNum, currentTotalDiscs))
} else { } else {
removeCommentKey(cmt, "DISCNUMBER") removeCommentKey(cmt, "DISCNUMBER")
} }
@@ -478,15 +493,11 @@ func writeVorbisMetadata(cmt *flacvorbis.MetaDataBlockVorbisComment, metadata Me
setComment(cmt, "DATE", metadata.Date) setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 { if metadata.TrackNumber > 0 {
if metadata.TotalTracks > 0 { setComment(cmt, "TRACKNUMBER", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
} else {
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
}
} }
if metadata.DiscNumber > 0 { if metadata.DiscNumber > 0 {
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) setComment(cmt, "DISCNUMBER", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
} }
if metadata.ISRC != "" { if metadata.ISRC != "" {
@@ -953,9 +964,9 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) {
case "\xa9lyr": case "\xa9lyr":
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size()) metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
case "trkn": case "trkn":
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size()) metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fi.Size())
case "disk": case "disk":
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size()) metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fi.Size())
case "----": case "----":
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size()) name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
if freeformErr == nil { if freeformErr == nil {
@@ -1150,6 +1161,41 @@ func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, erro
return int(binary.BigEndian.Uint16(payload[2:4])), nil return int(binary.BigEndian.Uint16(payload[2:4])), nil
} }
func readM4AIndexPair(f *os.File, parent atomHeader, fileSize int64) (int, int, error) {
payload, err := readM4ADataPayload(f, parent, fileSize)
if err != nil {
return 0, 0, err
}
if len(payload) < 6 {
return 0, 0, fmt.Errorf("index payload too short in %s", parent.typ)
}
return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6])), nil
}
func parsePositiveInt(value string) int {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
n, _ := strconv.Atoi(value)
return n
}
func formatIndexValue(number, total int) string {
if number <= 0 {
return ""
}
if total > 0 {
return fmt.Sprintf("%d/%d", number, total)
}
return strconv.Itoa(number)
}
func hasMapKey(fields map[string]string, key string) bool {
_, ok := fields[key]
return ok
}
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) { func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
start := parent.offset + parent.headerSize start := parent.offset + parent.headerSize
end := parent.offset + parent.size end := parent.offset + parent.size
@@ -1199,6 +1245,281 @@ func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string
return nameValue, dataValue, nil 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) { func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath) ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext) base := strings.TrimSuffix(filePath, ext)
@@ -1378,16 +1699,82 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
// [28:32] samplerate (16.16 fixed-point) // [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29]) sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := int(buf[22])<<8 | int(buf[23]) bitDepth := int(buf[22])<<8 | int(buf[23])
if bitDepth <= 0 {
bitDepth = 16 if atomType == "alac" {
if atomType == "alac" { if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
bitDepth = 24 if alacBitDepth > 0 {
bitDepth = alacBitDepth
}
if alacSampleRate > 0 {
sampleRate = alacSampleRate
}
} }
} }
if bitDepth <= 0 {
bitDepth = 16
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil 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 { type atomHeader struct {
offset int64 offset int64
size int64 size int64
+49
View File
@@ -0,0 +1,49 @@
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")
}
}
+4
View File
@@ -23,11 +23,13 @@ type TrackMetadata struct {
TrackNumber int `json:"track_number"` TrackNumber int `json:"track_number"`
TotalTracks int `json:"total_tracks,omitempty"` TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ExternalURL string `json:"external_urls"` ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
AlbumID string `json:"album_id,omitempty"` AlbumID string `json:"album_id,omitempty"`
ArtistID string `json:"artist_id,omitempty"` ArtistID string `json:"artist_id,omitempty"`
AlbumType string `json:"album_type,omitempty"` AlbumType string `json:"album_type,omitempty"`
Composer string `json:"composer,omitempty"`
} }
type AlbumTrackMetadata struct { type AlbumTrackMetadata struct {
@@ -42,11 +44,13 @@ type AlbumTrackMetadata struct {
TrackNumber int `json:"track_number"` TrackNumber int `json:"track_number"`
TotalTracks int `json:"total_tracks,omitempty"` TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ExternalURL string `json:"external_urls"` ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
AlbumID string `json:"album_id,omitempty"` AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"` AlbumURL string `json:"album_url,omitempty"`
AlbumType string `json:"album_type,omitempty"` AlbumType string `json:"album_type,omitempty"`
Composer string `json:"composer,omitempty"`
} }
type AlbumInfoMetadata struct { type AlbumInfoMetadata struct {
+18
View File
@@ -55,6 +55,7 @@ const (
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzStoreBaseURL = "https://www.qobuz.com/us-en" qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download" qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
qobuzZarzDownloadAPIURL = "https://api.zarz.moe/dl/qbz"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/" qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
@@ -105,6 +106,10 @@ type QobuzTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} `json:"performer"` } `json:"performer"`
Composer struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"composer"`
} }
type qobuzImageSet struct { type qobuzImageSet struct {
@@ -349,6 +354,7 @@ func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata {
AlbumID: qobuzPrefixedID(track.Album.ID), AlbumID: qobuzPrefixedID(track.Album.ID),
ArtistID: qobuzTrackArtistID(track), ArtistID: qobuzTrackArtistID(track),
AlbumType: qobuzTrackAlbumType(track), AlbumType: qobuzTrackAlbumType(track),
Composer: strings.TrimSpace(track.Composer.Name),
} }
} }
@@ -373,6 +379,7 @@ func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata {
AlbumID: qobuzPrefixedID(track.Album.ID), AlbumID: qobuzPrefixedID(track.Album.ID),
AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)), AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)),
AlbumType: qobuzTrackAlbumType(track), AlbumType: qobuzTrackAlbumType(track),
Composer: strings.TrimSpace(track.Composer.Name),
} }
} }
@@ -1030,6 +1037,7 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
} }
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items)) tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
totalDiscs := 0
for i := range album.Tracks.Items { for i := range album.Tracks.Items {
track := &album.Tracks.Items[i] track := &album.Tracks.Items[i]
track.Album.ID = album.ID track.Album.ID = album.ID
@@ -1041,8 +1049,14 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
Large: album.Image.Large, Large: album.Image.Large,
} }
track.Album.TracksCount = album.TracksCount track.Album.TracksCount = album.TracksCount
if track.MediaNumber > totalDiscs {
totalDiscs = track.MediaNumber
}
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track)) tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
} }
for i := range tracks {
tracks[i].TotalDiscs = totalDiscs
}
return &AlbumResponsePayload{ return &AlbumResponsePayload{
AlbumInfo: qobuzAlbumToAlbumInfo(album), AlbumInfo: qobuzAlbumToAlbumInfo(album),
@@ -1126,6 +1140,7 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
func (q *QobuzDownloader) GetAvailableAPIs() []string { func (q *QobuzDownloader) GetAvailableAPIs() []string {
return []string{ return []string{
qobuzDownloadAPIURL, qobuzDownloadAPIURL,
qobuzZarzDownloadAPIURL,
qobuzDabMusicAPIURL, qobuzDabMusicAPIURL,
qobuzDeebAPIURL, qobuzDeebAPIURL,
qobuzAfkarAPIURL, qobuzAfkarAPIURL,
@@ -1147,6 +1162,7 @@ const (
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider { func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
return []qobuzAPIProvider{ return []qobuzAPIProvider{
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL}, {Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
{Name: "zarz", URL: qobuzZarzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard}, {Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
@@ -2793,10 +2809,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
TrackNumber: actualTrackNumber, TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, DiscNumber: req.DiscNumber,
TotalDiscs: req.TotalDiscs,
ISRC: track.ISRC, ISRC: track.ISRC,
Genre: req.Genre, Genre: req.Genre,
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
Composer: req.Composer,
} }
var coverData []byte var coverData []byte
+37 -2
View File
@@ -241,12 +241,13 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
func TestQobuzAvailableProviders(t *testing.T) { func TestQobuzAvailableProviders(t *testing.T) {
providers := NewQobuzDownloader().GetAvailableProviders() providers := NewQobuzDownloader().GetAvailableProviders()
if len(providers) != 5 { if len(providers) != 6 {
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers)) t.Fatalf("expected 6 Qobuz providers, got %d", len(providers))
} }
want := map[string]string{ want := map[string]string{
"musicdl": qobuzAPIKindMusicDL, "musicdl": qobuzAPIKindMusicDL,
"zarz": qobuzAPIKindMusicDL,
"dabmusic": qobuzAPIKindStandard, "dabmusic": qobuzAPIKindStandard,
"deeb": qobuzAPIKindStandard, "deeb": qobuzAPIKindStandard,
"qbz": qobuzAPIKindStandard, "qbz": qobuzAPIKindStandard,
@@ -518,3 +519,37 @@ func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification") 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)
}
}
+4 -3
View File
@@ -147,6 +147,7 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
return nil, fmt.Errorf("failed to create resolve request: %w", err) return nil, fmt.Errorf("failed to create resolve request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err != nil { if err != nil {
@@ -164,9 +165,9 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
} }
var resolveResp struct { var resolveResp struct {
Success bool `json:"success"` Success bool `json:"success"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
SongUrls map[string]json.RawMessage `json:"songUrls"` SongUrls map[string]json.RawMessage `json:"songUrls"`
} }
if err := json.Unmarshal(body, &resolveResp); err != nil { if err := json.Unmarshal(body, &resolveResp); err != nil {
return nil, fmt.Errorf("failed to decode resolve response: %w", err) return nil, fmt.Errorf("failed to decode resolve response: %w", err)
+9
View File
@@ -1012,6 +1012,7 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
} }
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items)) tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
totalDiscs := 0
for _, item := range itemsModule.PagedList.Items { for _, item := range itemsModule.PagedList.Items {
track := item.Item track := item.Item
track.Album.ID = headerModule.Album.ID track.Album.ID = headerModule.Album.ID
@@ -1019,8 +1020,14 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
track.Album.Cover = headerModule.Album.Cover track.Album.Cover = headerModule.Album.Cover
track.Album.ReleaseDate = headerModule.Album.ReleaseDate track.Album.ReleaseDate = headerModule.Album.ReleaseDate
track.Album.URL = headerModule.Album.URL track.Album.URL = headerModule.Album.URL
if track.VolumeNumber > totalDiscs {
totalDiscs = track.VolumeNumber
}
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track)) tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
} }
for i := range tracks {
tracks[i].TotalDiscs = totalDiscs
}
return &AlbumResponsePayload{ return &AlbumResponsePayload{
AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album), AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
@@ -2360,10 +2367,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TrackNumber: actualTrackNumber, TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNumber, DiscNumber: actualDiscNumber,
TotalDiscs: req.TotalDiscs,
ISRC: track.ISRC, ISRC: track.ISRC,
Genre: req.Genre, Genre: req.Genre,
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
Composer: req.Composer,
} }
var coverData []byte var coverData []byte
+60 -10
View File
@@ -22,6 +22,9 @@ import Gobackend // Import Go framework
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
GobackendSetAppVersion(version)
}
let controller = window?.rootViewController as! FlutterViewController let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel( let channel = FlutterMethodChannel(
@@ -66,9 +69,59 @@ import Gobackend // Import Go framework
) )
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
if let url = launchOptions?[.url] as? URL {
_ = handleExtensionOAuthRedirect(url: url)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions) 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 { deinit {
stopDownloadProgressStream() stopDownloadProgressStream()
stopLibraryScanProgressStream() stopLibraryScanProgressStream()
@@ -371,16 +424,6 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response 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": case "searchTidalAll":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let query = args["query"] as! String let query = args["query"] as! String
@@ -607,6 +650,13 @@ import Gobackend // Import Go framework
let response = GobackendGetProviderPriorityJSON(&error) let response = GobackendGetProviderPriorityJSON(&error)
if let error = error { throw error } if let error = error { throw error }
return response 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": case "setMetadataProviderPriority":
let args = call.arguments as! [String: Any] 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 /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '4.2.0'; static const String version = '4.3.0';
static const String buildNumber = '121'; static const String buildNumber = '125';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release. /// Shows "Internal" in debug builds, actual version in release.
+48
View File
@@ -352,6 +352,18 @@ abstract class AppLocalizations {
/// **'Using extension: {extensionName}'** /// **'Using extension: {extensionName}'**
String optionsUsingExtension(String 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 /// Hint to switch back to built-in providers
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -718,6 +730,12 @@ abstract class AppLocalizations {
/// **'PC source code'** /// **'PC source code'**
String get aboutPCSource; 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 /// Link to report bugs
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1738,6 +1756,24 @@ abstract class AppLocalizations {
/// **'If a track is not available on the first provider, the app will automatically try the next one.'** /// **'If a track is not available on the first provider, the app will automatically try the next one.'**
String get providerPriorityInfo; 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) /// Label for built-in providers (Tidal/Qobuz)
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2644,6 +2680,18 @@ abstract class AppLocalizations {
/// **'Set download service order'** /// **'Set download service order'**
String get extensionsDownloadPrioritySubtitle; 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 /// Empty state - no download providers
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+28
View File
@@ -129,6 +129,13 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Erweiterung verwenden: $extensionName'; 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 @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tippe auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln'; 'Tippe auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln';
@@ -341,6 +348,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC Quellcode'; String get aboutPCSource => 'PC Quellcode';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Problem melden'; String get aboutReportIssue => 'Problem melden';
@@ -940,6 +950,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.'; '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 @override
String get providerBuiltIn => 'Integriert'; String get providerBuiltIn => 'Integriert';
@@ -1438,6 +1459,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle => String get extensionsDownloadPrioritySubtitle =>
'Download-Service-Reihenfolge festlegen'; '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 @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'Keine Erweiterungen mit Download-Provider'; 'Keine Erweiterungen mit Download-Provider';
+28
View File
@@ -127,6 +127,13 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Using extension: $extensionName'; 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 @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
@@ -926,6 +936,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; '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 @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1436,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; 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 @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+31
View File
@@ -127,6 +127,13 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Using extension: $extensionName'; 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 @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
@@ -926,6 +936,17 @@ class AppLocalizationsEs extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; '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 @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1436,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; 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 @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
@@ -3689,6 +3717,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override @override
String get aboutPCSource => 'Código fuente de PC'; String get aboutPCSource => 'Código fuente de PC';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Reportar un problema'; String get aboutReportIssue => 'Reportar un problema';
+28
View File
@@ -128,6 +128,13 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Utilisation de l\'extension: $extensionName'; 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 @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension'; 'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
@@ -336,6 +343,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
@@ -928,6 +938,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; '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 @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1417,6 +1438,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; 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 @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+28
View File
@@ -127,6 +127,13 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Using extension: $extensionName'; 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 @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
@@ -926,6 +936,17 @@ class AppLocalizationsHi extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; '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 @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1436,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; 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 @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+28
View File
@@ -129,6 +129,13 @@ class AppLocalizationsId extends AppLocalizations {
return 'Menggunakan ekstensi: $extensionName'; 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 @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi'; 'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
@@ -337,6 +344,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get aboutPCSource => 'Kode sumber PC'; String get aboutPCSource => 'Kode sumber PC';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Laporkan masalah'; String get aboutReportIssue => 'Laporkan masalah';
@@ -930,6 +940,17 @@ class AppLocalizationsId extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.'; '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 @override
String get providerBuiltIn => 'Bawaan'; String get providerBuiltIn => 'Bawaan';
@@ -1423,6 +1444,13 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle => String get extensionsDownloadPrioritySubtitle =>
'Atur urutan layanan unduhan'; 'Atur urutan layanan unduhan';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'Tidak ada ekstensi dengan provider unduhan'; 'Tidak ada ekstensi dengan provider unduhan';
+28
View File
@@ -127,6 +127,13 @@ class AppLocalizationsJa extends AppLocalizations {
return '拡張の使用: $extensionName'; return '拡張の使用: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -330,6 +337,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC 版のソースコード'; String get aboutPCSource => 'PC 版のソースコード';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => '問題を報告する'; String get aboutReportIssue => '問題を報告する';
@@ -920,6 +930,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; '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 @override
String get providerBuiltIn => '内蔵'; String get providerBuiltIn => '内蔵';
@@ -1409,6 +1430,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定'; String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません'; String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません';
+28
View File
@@ -125,6 +125,13 @@ class AppLocalizationsKo extends AppLocalizations {
return '확장 기능을 사용: $extensionName'; return '확장 기능을 사용: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.'; String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
@@ -323,6 +330,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC 소스 코드'; String get aboutPCSource => 'PC 소스 코드';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => '문제 신고'; String get aboutReportIssue => '문제 신고';
@@ -908,6 +918,17 @@ class AppLocalizationsKo extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; '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 @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1395,6 +1416,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; 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 @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+28
View File
@@ -127,6 +127,13 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Using extension: $extensionName'; 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 @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
@@ -926,6 +936,17 @@ class AppLocalizationsNl extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; '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 @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1436,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; 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 @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+31
View File
@@ -127,6 +127,13 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Using extension: $extensionName'; 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 @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
@@ -926,6 +936,17 @@ class AppLocalizationsPt extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; '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 @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1436,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; 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 @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
@@ -3689,6 +3717,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override @override
String get aboutPCSource => 'Código-fonte do app desktop'; String get aboutPCSource => 'Código-fonte do app desktop';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Reportar um problema'; String get aboutReportIssue => 'Reportar um problema';
+28
View File
@@ -129,6 +129,13 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Используется расширение: $extensionName'; return 'Используется расширение: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Нажмите Deezer или Spotify для возврата с расширения'; 'Нажмите Deezer или Spotify для возврата с расширения';
@@ -340,6 +347,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get aboutPCSource => 'Исходный код ПК версии'; String get aboutPCSource => 'Исходный код ПК версии';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Сообщить о проблеме'; String get aboutReportIssue => 'Сообщить о проблеме';
@@ -940,6 +950,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get providerPriorityInfo => 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 @override
String get providerBuiltIn => 'Встроенные'; String get providerBuiltIn => 'Встроенные';
@@ -1439,6 +1460,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle => String get extensionsDownloadPrioritySubtitle =>
'Установка порядок сервисов скачивания'; 'Установка порядок сервисов скачивания';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'Нет расширений с провайдером загрузки'; 'Нет расширений с провайдером загрузки';
+28
View File
@@ -129,6 +129,13 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Kullanılan eklenti: $extensionName'; return 'Kullanılan eklenti: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Dahili kaynaklara dönmek için Deezer veya Spotify\'a tıkla'; 'Dahili kaynaklara dönmek için Deezer veya Spotify\'a tıkla';
@@ -337,6 +344,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC kaynak kodu'; String get aboutPCSource => 'PC kaynak kodu';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Sorun bildir'; String get aboutReportIssue => 'Sorun bildir';
@@ -931,6 +941,17 @@ class AppLocalizationsTr extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'Eğer bir şarkı ilk hizmette mevcut değilse uygulama otomatik olarak bir sonrakini deneyecektir.'; 'Eğer bir şarkı ilk hizmette mevcut değilse uygulama otomatik olarak bir sonrakini deneyecektir.';
@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 @override
String get providerBuiltIn => 'Dahili'; String get providerBuiltIn => 'Dahili';
@@ -1421,6 +1442,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; 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 @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+34
View File
@@ -127,6 +127,13 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Using extension: $extensionName'; 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 @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
@@ -926,6 +936,17 @@ class AppLocalizationsZh extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; '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 @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1436,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; 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 @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
@@ -3671,6 +3699,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override @override
String get aboutPCSource => '桌面版本源代码'; String get aboutPCSource => '桌面版本源代码';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => '报告一个问题'; String get aboutReportIssue => '报告一个问题';
@@ -6065,6 +6096,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Problem melden", "aboutReportIssue": "Problem melden",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+32
View File
@@ -158,6 +158,14 @@
} }
} }
}, },
"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": "Tap Deezer or Spotify to switch back from extension",
"@optionsSwitchBack": { "@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers" "description": "Hint to switch back to built-in providers"
@@ -422,6 +430,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
@@ -1203,6 +1215,18 @@
"@providerPriorityInfo": { "@providerPriorityInfo": {
"description": "Info tip about fallback behavior" "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": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)" "description": "Label for built-in providers (Tidal/Qobuz)"
@@ -1857,6 +1881,14 @@
"@extensionsDownloadPrioritySubtitle": { "@extensionsDownloadPrioritySubtitle": {
"description": "Subtitle for download priority" "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": "No extensions with download provider",
"@extensionsNoDownloadProvider": { "@extensionsNoDownloadProvider": {
"description": "Empty state - no download providers" "description": "Empty state - no download providers"
+4
View File
@@ -362,6 +362,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Reportar un problema", "aboutReportIssue": "Reportar un problema",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+32
View File
@@ -150,6 +150,14 @@
} }
} }
}, },
"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": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi",
"@optionsSwitchBack": { "@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers" "description": "Hint to switch back to built-in providers"
@@ -382,6 +390,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Laporkan masalah", "aboutReportIssue": "Laporkan masalah",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
@@ -1119,6 +1131,18 @@
"@providerPriorityInfo": { "@providerPriorityInfo": {
"description": "Info tip about fallback behavior" "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": "Bawaan",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)" "description": "Label for built-in providers (Tidal/Qobuz)"
@@ -1713,6 +1737,14 @@
"@extensionsDownloadPrioritySubtitle": { "@extensionsDownloadPrioritySubtitle": {
"description": "Subtitle for download priority" "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": "Tidak ada ekstensi dengan provider unduhan",
"@extensionsNoDownloadProvider": { "@extensionsNoDownloadProvider": {
"description": "Empty state - no download providers" "description": "Empty state - no download providers"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "問題を報告する", "aboutReportIssue": "問題を報告する",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "문제 신고", "aboutReportIssue": "문제 신고",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -362,6 +362,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Reportar um problema", "aboutReportIssue": "Reportar um problema",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Сообщить о проблеме", "aboutReportIssue": "Сообщить о проблеме",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Sorun bildir", "aboutReportIssue": "Sorun bildir",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -362,6 +362,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "报告一个问题", "aboutReportIssue": "报告一个问题",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+11
View File
@@ -33,7 +33,9 @@ class AppSettings {
final bool askQualityBeforeDownload; final bool askQualityBeforeDownload;
final bool enableLogging; final bool enableLogging;
final bool useExtensionProviders; final bool useExtensionProviders;
final List<String>? downloadFallbackExtensionIds;
final String? searchProvider; final String? searchProvider;
final String defaultSearchTab;
final String? homeFeedProvider; final String? homeFeedProvider;
final bool separateSingles; final bool separateSingles;
final String singleFilenameFormat; final String singleFilenameFormat;
@@ -108,7 +110,9 @@ class AppSettings {
this.askQualityBeforeDownload = true, this.askQualityBeforeDownload = true,
this.enableLogging = false, this.enableLogging = false,
this.useExtensionProviders = true, this.useExtensionProviders = true,
this.downloadFallbackExtensionIds,
this.searchProvider, this.searchProvider,
this.defaultSearchTab = 'all',
this.homeFeedProvider, this.homeFeedProvider,
this.separateSingles = false, this.separateSingles = false,
this.singleFilenameFormat = '{title} - {artist}', this.singleFilenameFormat = '{title} - {artist}',
@@ -170,8 +174,11 @@ class AppSettings {
bool? askQualityBeforeDownload, bool? askQualityBeforeDownload,
bool? enableLogging, bool? enableLogging,
bool? useExtensionProviders, bool? useExtensionProviders,
List<String>? downloadFallbackExtensionIds,
bool clearDownloadFallbackExtensionIds = false,
String? searchProvider, String? searchProvider,
bool clearSearchProvider = false, bool clearSearchProvider = false,
String? defaultSearchTab,
String? homeFeedProvider, String? homeFeedProvider,
bool clearHomeFeedProvider = false, bool clearHomeFeedProvider = false,
bool? separateSingles, bool? separateSingles,
@@ -232,9 +239,13 @@ class AppSettings {
enableLogging: enableLogging ?? this.enableLogging, enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders:
useExtensionProviders ?? this.useExtensionProviders, useExtensionProviders ?? this.useExtensionProviders,
downloadFallbackExtensionIds: clearDownloadFallbackExtensionIds
? null
: (downloadFallbackExtensionIds ?? this.downloadFallbackExtensionIds),
searchProvider: clearSearchProvider searchProvider: clearSearchProvider
? null ? null
: (searchProvider ?? this.searchProvider), : (searchProvider ?? this.searchProvider),
defaultSearchTab: defaultSearchTab ?? this.defaultSearchTab,
homeFeedProvider: clearHomeFeedProvider homeFeedProvider: clearHomeFeedProvider
? null ? null
: (homeFeedProvider ?? this.homeFeedProvider), : (homeFeedProvider ?? this.homeFeedProvider),
+7
View File
@@ -35,7 +35,12 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
enableLogging: json['enableLogging'] as bool? ?? false, enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
downloadFallbackExtensionIds:
(json['downloadFallbackExtensionIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
searchProvider: json['searchProvider'] as String?, searchProvider: json['searchProvider'] as String?,
defaultSearchTab: json['defaultSearchTab'] as String? ?? 'all',
homeFeedProvider: json['homeFeedProvider'] as String?, homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false, separateSingles: json['separateSingles'] as bool? ?? false,
singleFilenameFormat: singleFilenameFormat:
@@ -105,7 +110,9 @@ Map<String, dynamic> _$AppSettingsToJson(
'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'enableLogging': instance.enableLogging, 'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders, 'useExtensionProviders': instance.useExtensionProviders,
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
'searchProvider': instance.searchProvider, 'searchProvider': instance.searchProvider,
'defaultSearchTab': instance.defaultSearchTab,
'homeFeedProvider': instance.homeFeedProvider, 'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles, 'separateSingles': instance.separateSingles,
'singleFilenameFormat': instance.singleFilenameFormat, 'singleFilenameFormat': instance.singleFilenameFormat,
+4
View File
@@ -16,12 +16,14 @@ class Track {
final int duration; final int duration;
final int? trackNumber; final int? trackNumber;
final int? discNumber; final int? discNumber;
final int? totalDiscs;
final String? releaseDate; final String? releaseDate;
final String? deezerId; final String? deezerId;
final ServiceAvailability? availability; final ServiceAvailability? availability;
final String? source; final String? source;
final String? albumType; final String? albumType;
final int? totalTracks; final int? totalTracks;
final String? composer;
final String? itemType; final String? itemType;
const Track({ const Track({
@@ -37,12 +39,14 @@ class Track {
required this.duration, required this.duration,
this.trackNumber, this.trackNumber,
this.discNumber, this.discNumber,
this.totalDiscs,
this.releaseDate, this.releaseDate,
this.deezerId, this.deezerId,
this.availability, this.availability,
this.source, this.source,
this.albumType, this.albumType,
this.totalTracks, this.totalTracks,
this.composer,
this.itemType, this.itemType,
}); });
+4
View File
@@ -19,6 +19,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
duration: (json['duration'] as num).toInt(), duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(), trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(), discNumber: (json['discNumber'] as num?)?.toInt(),
totalDiscs: (json['totalDiscs'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?, releaseDate: json['releaseDate'] as String?,
deezerId: json['deezerId'] as String?, deezerId: json['deezerId'] as String?,
availability: json['availability'] == null availability: json['availability'] == null
@@ -29,6 +30,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
source: json['source'] as String?, source: json['source'] as String?,
albumType: json['albumType'] as String?, albumType: json['albumType'] as String?,
totalTracks: (json['totalTracks'] as num?)?.toInt(), totalTracks: (json['totalTracks'] as num?)?.toInt(),
composer: json['composer'] as String?,
itemType: json['itemType'] as String?, itemType: json['itemType'] as String?,
); );
@@ -45,12 +47,14 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'duration': instance.duration, 'duration': instance.duration,
'trackNumber': instance.trackNumber, 'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber, 'discNumber': instance.discNumber,
'totalDiscs': instance.totalDiscs,
'releaseDate': instance.releaseDate, 'releaseDate': instance.releaseDate,
'deezerId': instance.deezerId, 'deezerId': instance.deezerId,
'availability': instance.availability, 'availability': instance.availability,
'source': instance.source, 'source': instance.source,
'albumType': instance.albumType, 'albumType': instance.albumType,
'totalTracks': instance.totalTracks, 'totalTracks': instance.totalTracks,
'composer': instance.composer,
'itemType': instance.itemType, 'itemType': instance.itemType,
}; };
File diff suppressed because it is too large Load Diff
+101 -18
View File
@@ -20,7 +20,6 @@ class Extension {
final String name; final String name;
final String displayName; final String displayName;
final String version; final String version;
final String author;
final String description; final String description;
final bool enabled; final bool enabled;
final String status; final String status;
@@ -45,7 +44,6 @@ class Extension {
required this.name, required this.name,
required this.displayName, required this.displayName,
required this.version, required this.version,
required this.author,
required this.description, required this.description,
required this.enabled, required this.enabled,
required this.status, required this.status,
@@ -73,7 +71,6 @@ class Extension {
displayName: displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '', json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0', version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '', description: json['description'] as String? ?? '',
enabled: json['enabled'] as bool? ?? false, enabled: json['enabled'] as bool? ?? false,
status: json['status'] as String? ?? 'loaded', status: json['status'] as String? ?? 'loaded',
@@ -124,7 +121,6 @@ class Extension {
String? name, String? name,
String? displayName, String? displayName,
String? version, String? version,
String? author,
String? description, String? description,
bool? enabled, bool? enabled,
String? status, String? status,
@@ -149,7 +145,6 @@ class Extension {
name: name ?? this.name, name: name ?? this.name,
displayName: displayName ?? this.displayName, displayName: displayName ?? this.displayName,
version: version ?? this.version, version: version ?? this.version,
author: author ?? this.author,
description: description ?? this.description, description: description ?? this.description,
enabled: enabled ?? this.enabled, enabled: enabled ?? this.enabled,
status: status ?? this.status, status: status ?? this.status,
@@ -178,6 +173,12 @@ class Extension {
bool get hasPostProcessing => postProcessing?.enabled ?? false; bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasHomeFeed => capabilities['homeFeed'] == true; bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == 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;
}
} }
class SearchFilter { class SearchFilter {
@@ -481,8 +482,10 @@ class ExtensionState {
} }
class ExtensionNotifier extends Notifier<ExtensionState> { class ExtensionNotifier extends Notifier<ExtensionState> {
static const _builtInMetadataProviders = ['qobuz', 'tidal'];
AppLifecycleListener? _appLifecycleListener; AppLifecycleListener? _appLifecycleListener;
bool _cleanupInFlight = false; bool _cleanupInFlight = false;
Completer<void>? _initializationCompleter;
@override @override
ExtensionState build() { ExtensionState build() {
@@ -520,6 +523,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> initialize(String extensionsDir, String dataDir) async { Future<void> initialize(String extensionsDir, String dataDir) async {
if (state.isInitialized) return; if (state.isInitialized) return;
if (_initializationCompleter != null) {
await _initializationCompleter!.future;
return;
}
final completer = Completer<void>();
_initializationCompleter = completer;
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
@@ -531,6 +541,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
error: null, error: null,
); );
_log.i('Extension system disabled on this platform'); _log.i('Extension system disabled on this platform');
completer.complete();
_initializationCompleter = null;
return; return;
} }
@@ -544,6 +556,32 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} catch (e) { } catch (e) {
_log.e('Failed to initialize extension system: $e'); _log.e('Failed to initialize extension system: $e');
state = state.copyWith(isLoading: false, error: e.toString()); 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');
} }
} }
@@ -566,6 +604,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
final list = await PlatformBridge.getInstalledExtensions(); final list = await PlatformBridge.getInstalledExtensions();
final extensions = list.map((e) => Extension.fromJson(e)).toList(); final extensions = list.map((e) => Extension.fromJson(e)).toList();
state = state.copyWith(extensions: extensions); state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
_log.d('Loaded ${extensions.length} extensions'); _log.d('Loaded ${extensions.length} extensions');
for (final ext in extensions) { for (final ext in extensions) {
@@ -661,6 +700,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}).toList(); }).toList();
state = state.copyWith(extensions: extensions); state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
if (!enabled && ext != null) { if (!enabled && ext != null) {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
@@ -685,6 +725,23 @@ 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({ Future<bool> ensureSpotifyWebExtensionReady({
bool setAsSearchProvider = true, bool setAsSearchProvider = true,
}) async { }) async {
@@ -812,6 +869,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> _sanitizeDownloadProviderPriority(List<String> input) { List<String> _sanitizeDownloadProviderPriority(List<String> input) {
final allowed = getAllDownloadProviders().toSet(); final allowed = getAllDownloadProviders().toSet();
final preferredOrder = getAllDownloadProviders();
final result = <String>[]; final result = <String>[];
for (final provider in input) { for (final provider in input) {
@@ -820,7 +878,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
for (final provider in const ['tidal', 'qobuz', 'deezer']) { for (final provider in preferredOrder) {
if (!result.contains(provider)) { if (!result.contains(provider)) {
result.add(provider); result.add(provider);
} }
@@ -847,10 +905,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
); );
await PlatformBridge.setMetadataProviderPriority(priority); await PlatformBridge.setMetadataProviderPriority(priority);
} else { } else {
priority = _sanitizeMetadataProviderPriority( final backendPriority =
await PlatformBridge.getMetadataProviderPriority(), await PlatformBridge.getMetadataProviderPriority();
); priority = _sanitizeMetadataProviderPriority(backendPriority);
_log.d('Using default metadata provider priority: $priority'); _log.d('Using default metadata provider priority: $priority');
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(priority),
);
await PlatformBridge.setMetadataProviderPriority(priority);
} }
state = state.copyWith(metadataProviderPriority: priority); state = state.copyWith(metadataProviderPriority: priority);
@@ -896,7 +959,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
List<String> getAllDownloadProviders() { List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'deezer']; final providers = ['tidal', 'qobuz'];
for (final ext in state.extensions) { for (final ext in state.extensions) {
if (ext.enabled && ext.hasDownloadProvider) { if (ext.enabled && ext.hasDownloadProvider) {
providers.add(ext.id); providers.add(ext.id);
@@ -906,17 +969,26 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
List<String> getAllMetadataProviders() { List<String> getAllMetadataProviders() {
final providers = ['deezer', 'qobuz', 'tidal']; final metadataExtensions = state.extensions
for (final ext in state.extensions) { .where((ext) => ext.enabled && ext.hasMetadataProvider)
if (ext.enabled && ext.hasMetadataProvider) { .toList();
providers.add(ext.id); final primarySearchMetadataExtensions = metadataExtensions
} .where((ext) => ext.searchBehavior?.primary == true)
} .map((ext) => ext.id);
return providers; final otherMetadataExtensions = metadataExtensions
.where((ext) => ext.searchBehavior?.primary != true)
.map((ext) => ext.id);
return [
...primarySearchMetadataExtensions,
..._builtInMetadataProviders,
...otherMetadataExtensions,
];
} }
List<String> _sanitizeMetadataProviderPriority(List<String> input) { List<String> _sanitizeMetadataProviderPriority(List<String> input) {
final allowed = getAllMetadataProviders().toSet(); final allowed = getAllMetadataProviders().toSet();
final preferredOrder = getAllMetadataProviders();
final result = <String>[]; final result = <String>[];
for (final provider in input) { for (final provider in input) {
@@ -925,7 +997,18 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
for (final provider in const ['deezer', 'qobuz', 'tidal']) { 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) {
if (!result.contains(provider)) { if (!result.contains(provider)) {
result.add(provider); result.add(provider);
} }
+69 -4
View File
@@ -12,12 +12,13 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings'; const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version'; const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 9; const _currentMigrationVersion = 10;
const _spotifyClientSecretKey = 'spotify_client_secret'; const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider'); final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> { class SettingsNotifier extends Notifier<AppSettings> {
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); 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 Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -35,9 +36,23 @@ class SettingsNotifier extends Notifier<AppSettings> {
final prefs = await _prefs; final prefs = await _prefs;
final json = prefs.getString(_settingsKey); final json = prefs.getString(_settingsKey);
if (json != null) { if (json != null) {
state = AppSettings.fromJson( final loaded = AppSettings.fromJson(
Map<String, dynamic>.from(jsonDecode(json) as Map), 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 _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded(); await _normalizeIosDownloadDirectoryIfNeeded();
@@ -50,6 +65,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
_syncLyricsSettingsToBackend(); _syncLyricsSettingsToBackend();
_syncNetworkCompatibilitySettingsToBackend(); _syncNetworkCompatibilitySettingsToBackend();
_syncExtensionFallbackSettingsToBackend();
} }
void _syncLyricsSettingsToBackend() { void _syncLyricsSettingsToBackend() {
@@ -83,6 +99,16 @@ 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 { Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
@@ -111,8 +137,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
); );
} }
state = state.copyWith(lastSeenVersion: AppInfo.version); state = state.copyWith(lastSeenVersion: AppInfo.version);
// Migration 7: YouTube is no longer a built-in service reset to Tidal // Migration 7/10: retired built-in services reset back to Tidal
if (state.defaultService == 'youtube') { if (state.defaultService == 'youtube' ||
state.defaultService == 'deezer') {
state = state.copyWith(defaultService: 'tidal'); state = state.copyWith(defaultService: 'tidal');
} }
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
@@ -165,6 +192,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
return 'US'; return 'US';
} }
String _normalizeDefaultSearchTab(String value) {
final normalized = value.trim().toLowerCase();
if (_searchTabValues.contains(normalized)) return normalized;
return 'all';
}
Future<void> _normalizeSongLinkRegionIfNeeded() async { Future<void> _normalizeSongLinkRegionIfNeeded() async {
final normalized = _normalizeSongLinkRegion(state.songLinkRegion); final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
if (normalized == state.songLinkRegion) return; if (normalized == state.songLinkRegion) return;
@@ -172,6 +205,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings(); 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 { Future<void> _cleanupRetiredSpotifySettings() async {
final storedSecret = await _secureStorage.read( final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey, key: _spotifyClientSecretKey,
@@ -370,6 +419,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setDefaultSearchTab(String tab) {
state = state.copyWith(defaultSearchTab: _normalizeDefaultSearchTab(tab));
_saveSettings();
}
void setHomeFeedProvider(String? provider) { void setHomeFeedProvider(String? provider) {
if (provider == null || provider.isEmpty) { if (provider == null || provider.isEmpty) {
state = state.copyWith(clearHomeFeedProvider: true); state = state.copyWith(clearHomeFeedProvider: true);
@@ -390,6 +444,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _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) { void setSeparateSingles(bool enabled) {
state = state.copyWith(separateSingles: enabled); state = state.copyWith(separateSingles: enabled);
_saveSettings(); _saveSettings();
-4
View File
@@ -63,7 +63,6 @@ class StoreExtension {
final String name; final String name;
final String displayName; final String displayName;
final String version; final String version;
final String author;
final String description; final String description;
final String downloadUrl; final String downloadUrl;
final String? iconUrl; final String? iconUrl;
@@ -81,7 +80,6 @@ class StoreExtension {
required this.name, required this.name,
required this.displayName, required this.displayName,
required this.version, required this.version,
required this.author,
required this.description, required this.description,
required this.downloadUrl, required this.downloadUrl,
this.iconUrl, this.iconUrl,
@@ -102,7 +100,6 @@ class StoreExtension {
displayName: displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '', json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0', version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '', description: json['description'] as String? ?? '',
downloadUrl: json['download_url'] as String? ?? '', downloadUrl: json['download_url'] as String? ?? '',
iconUrl: json['icon_url'] as String?, iconUrl: json['icon_url'] as String?,
@@ -194,7 +191,6 @@ class StoreState {
e.name.toLowerCase().contains(query) || e.name.toLowerCase().contains(query) ||
e.displayName.toLowerCase().contains(query) || e.displayName.toLowerCase().contains(query) ||
e.description.toLowerCase().contains(query) || e.description.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query)), e.tags.any((t) => t.toLowerCase().contains(query)),
) )
.toList(); .toList();
+144 -38
View File
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('TrackProvider'); final _log = AppLogger('TrackProvider');
const _extensionInitRetryTimeout = Duration(seconds: 30);
class TrackState { class TrackState {
final List<Track> tracks; final List<Track> tracks;
@@ -203,13 +204,36 @@ class TrackNotifier extends Notifier<TrackState> {
bool _isRequestValid(int requestId) => requestId == _currentRequestId; 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 { Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try { try {
final extensionHandler = await PlatformBridge.findURLHandler(url); 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);
}
}
if (extensionHandler != null) { if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url'); _log.i('Found extension URL handler: $extensionHandler for URL: $url');
@@ -559,8 +583,98 @@ class TrackNotifier extends Notifier<TrackState> {
String? builtInSearchProvider, String? builtInSearchProvider,
}) async { }) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
final currentFilter = filterOverride ?? state.selectedSearchFilter; 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;
}
}
final isEnabledExtensionProvider =
resolvedProvider != null &&
resolvedProvider.isNotEmpty &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.id == resolvedProvider,
);
if (resolvedProvider != null &&
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;
}
if (resolvedProvider != null &&
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;
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( state = TrackState(
isLoading: true, isLoading: true,
@@ -570,42 +684,21 @@ class TrackNotifier extends Notifier<TrackState> {
); );
try { try {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final hasActiveMetadataExtensions = extensionState.extensions.any( final hasActiveMetadataExtensions = extensionState.extensions.any(
(e) => e.enabled && e.hasMetadataProvider, (e) => e.enabled && e.hasMetadataProvider,
); );
final includeExtensions = final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions; settings.useExtensionProviders && hasActiveMetadataExtensions;
final effectiveProvider = builtInSearchProvider ?? 'deezer'; final effectiveProvider = effectiveBuiltInProvider;
_log.i( _log.i(
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter', 'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter',
); );
Map<String, dynamic> results; Map<String, dynamic> results;
List<Map<String, dynamic>> metadataTrackResults = []; 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) { switch (effectiveProvider) {
case 'tidal': case 'tidal':
_log.d('Calling Tidal search API...'); _log.d('Calling Tidal search API...');
@@ -613,7 +706,7 @@ class TrackNotifier extends Notifier<TrackState> {
query, query,
trackLimit: 20, trackLimit: 20,
artistLimit: 2, artistLimit: 2,
filter: currentFilter, filter: requestFilter,
); );
break; break;
case 'qobuz': case 'qobuz':
@@ -622,17 +715,23 @@ class TrackNotifier extends Notifier<TrackState> {
query, query,
trackLimit: 20, trackLimit: 20,
artistLimit: 2, artistLimit: 2,
filter: currentFilter, filter: requestFilter,
); );
break; break;
default: default:
_log.d('Calling Deezer search API...'); _log.d('Calling metadata provider track search API...');
results = await PlatformBridge.searchDeezerAll( metadataTrackResults =
query, await PlatformBridge.searchTracksWithMetadataProviders(
trackLimit: 20, query,
artistLimit: 2, limit: 20,
filter: currentFilter, includeExtensions: includeExtensions,
); );
results = const <String, List<dynamic>>{
'tracks': <dynamic>[],
'artists': <dynamic>[],
'albums': <dynamic>[],
'playlists': <dynamic>[],
};
break; break;
} }
_log.i( _log.i(
@@ -741,14 +840,16 @@ class TrackNotifier extends Notifier<TrackState> {
String extensionId, String extensionId,
String query, { String query, {
Map<String, dynamic>? options, Map<String, dynamic>? options,
String? selectedFilter,
}) async { }) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
final currentFilter = selectedFilter ?? state.selectedSearchFilter;
state = TrackState( state = TrackState(
isLoading: true, isLoading: true,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: state.selectedSearchFilter, selectedSearchFilter: currentFilter,
); );
try { try {
@@ -788,7 +889,7 @@ class TrackNotifier extends Notifier<TrackState> {
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
searchExtensionId: extensionId, searchExtensionId: extensionId,
selectedSearchFilter: state.selectedSearchFilter, selectedSearchFilter: currentFilter,
); );
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
@@ -798,6 +899,7 @@ class TrackNotifier extends Notifier<TrackState> {
error: e.toString(), error: e.toString(),
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
); );
} }
} }
@@ -906,9 +1008,11 @@ class TrackNotifier extends Notifier<TrackState> {
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?, albumType: normalizeOptionalString(data['album_type']?.toString()),
totalTracks: data['total_tracks'] as int?, totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
); );
} }
@@ -939,10 +1043,12 @@ class TrackNotifier extends Notifier<TrackState> {
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?, totalTracks: data['total_tracks'] as int?,
source: effectiveSource, source: effectiveSource,
albumType: data['album_type']?.toString(), albumType: normalizeOptionalString(data['album_type']?.toString()),
composer: data['composer']?.toString(),
itemType: itemType, itemType: itemType,
); );
} }
+79 -20
View File
@@ -75,6 +75,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
String? _error; String? _error;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
String? _artistId; String? _artistId;
String? _albumType;
int? _albumTotalTracks;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@override @override
@@ -112,6 +114,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_tracks = _AlbumCache.get(widget.albumId); _tracks = _AlbumCache.get(widget.albumId);
} }
_artistId = widget.artistId; _artistId = widget.artistId;
_albumType = _tracks?.firstOrNull?.albumType;
_albumTotalTracks = _tracks?.firstOrNull?.totalTracks;
if (_tracks == null || _tracks!.isEmpty) { if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks(); _fetchTracks();
@@ -179,13 +183,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
deezerAlbumId, deezerAlbumId,
); );
final trackList = metadata['track_list'] as List<dynamic>; 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 albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.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); _AlbumCache.set(widget.albumId, tracks);
@@ -193,6 +206,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -204,13 +219,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
qobuzAlbumId, qobuzAlbumId,
); );
final trackList = metadata['track_list'] as List<dynamic>; 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 albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.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); _AlbumCache.set(widget.albumId, tracks);
@@ -218,6 +242,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -229,13 +255,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
tidalAlbumId, tidalAlbumId,
); );
final trackList = metadata['track_list'] as List<dynamic>; 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 albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.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); _AlbumCache.set(widget.albumId, tracks);
@@ -243,6 +278,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -255,13 +292,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
final trackList = result['tracks'] as List<dynamic>; 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 albumInfo = result['album'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.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); _AlbumCache.set(widget.albumId, tracks);
@@ -269,6 +315,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -284,7 +332,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
} }
Track _parseTrack(Map<String, dynamic> data) { Track _parseTrack(
Map<String, dynamic> data, {
String? albumTypeFallback,
int? totalTracksFallback,
}) {
return Track( return Track(
id: data['spotify_id'] as String? ?? '', id: data['spotify_id'] as String? ?? '',
name: data['name'] as String? ?? '', name: data['name'] as String? ?? '',
@@ -299,9 +351,17 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?, albumType:
totalTracks: data['total_tracks'] as int?, normalizeOptionalString(data['album_type']?.toString()) ??
albumTypeFallback ??
_albumType,
totalTracks:
data['total_tracks'] as int? ??
totalTracksFallback ??
_albumTotalTracks,
composer: data['composer']?.toString(),
); );
} }
@@ -311,7 +371,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
if (widget.albumId.startsWith('tidal:')) return 'tidal'; if (widget.albumId.startsWith('tidal:')) return 'tidal';
if (widget.albumId.startsWith('qobuz:')) return 'qobuz'; if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
if (widget.albumId.startsWith('deezer:')) return 'deezer';
return null; return null;
} }
+11 -3
View File
@@ -159,7 +159,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
} }
if (widget.artistId.startsWith('tidal:')) return 'tidal'; if (widget.artistId.startsWith('tidal:')) return 'tidal';
if (widget.artistId.startsWith('qobuz:')) return 'qobuz'; if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
if (widget.artistId.startsWith('deezer:')) return 'deezer';
return null; return null;
} }
@@ -410,9 +409,13 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
albumType: data['album_type']?.toString() ?? album?.albumType, albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
album?.albumType,
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks, totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
composer: data['composer']?.toString(),
source: data['provider_id']?.toString() ?? widget.extensionId, source: data['provider_id']?.toString() ?? widget.extensionId,
); );
} }
@@ -1055,9 +1058,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
); );
if (result != null && result['tracks'] != null) { if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>; final tracksList = result['tracks'] as List<dynamic>;
return tracksList final parsedTracks = tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album)) .map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList(); .toList();
return parsedTracks;
} }
} else if (album.id.startsWith('deezer:')) { } else if (album.id.startsWith('deezer:')) {
final deezerId = album.id.replaceFirst('deezer:', ''); final deezerId = album.id.replaceFirst('deezer:', '');
@@ -1129,9 +1133,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
trackNumber: trackNumber:
data['track_position'] as int? ?? data['track_number'] as int?, data['track_position'] as int? ?? data['track_number'] as int?,
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?, discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: album.releaseDate, releaseDate: album.releaseDate,
albumType: album.albumType, albumType: album.albumType,
totalTracks: album.totalTracks, totalTracks: album.totalTracks,
composer: data['composer']?.toString(),
); );
} }
@@ -1930,6 +1936,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
albumId: album.id, albumId: album.id,
albumName: album.name, albumName: album.name,
coverUrl: album.coverUrl, coverUrl: album.coverUrl,
initialAlbumType: album.albumType,
initialTotalTracks: album.totalTracks,
), ),
), ),
); );
+34 -5
View File
@@ -299,7 +299,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}); });
} }
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async { Future<void> _navigateToMetadataScreen(
DownloadHistoryItem item, {
required List<DownloadHistoryItem> navigationItems,
required int navigationIndex,
}) async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
final beforeModTime = final beforeModTime =
@@ -309,7 +313,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
); );
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath, item.filePath,
@@ -691,7 +701,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
key: ValueKey(track.id), key: ValueKey(track.id),
child: StaggeredListItem( child: StaggeredListItem(
index: index, index: index,
child: _buildTrackItem(context, colorScheme, track), child: _buildTrackItem(
context,
colorScheme,
track,
tracks,
index,
),
), ),
); );
}, childCount: tracks.length), }, childCount: tracks.length),
@@ -709,12 +725,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children.add(_buildDiscSeparator(context, colorScheme, discNumber)); children.add(_buildDiscSeparator(context, colorScheme, discNumber));
for (final track in discTracks) { for (final track in discTracks) {
final navigationIndex = tracks.indexOf(track);
children.add( children.add(
KeyedSubtree( KeyedSubtree(
key: ValueKey(track.id), key: ValueKey(track.id),
child: StaggeredListItem( child: StaggeredListItem(
index: revealIndex++, index: revealIndex++,
child: _buildTrackItem(context, colorScheme, track), child: _buildTrackItem(
context,
colorScheme,
track,
tracks,
navigationIndex,
),
), ),
), ),
); );
@@ -774,6 +797,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
BuildContext context, BuildContext context,
ColorScheme colorScheme, ColorScheme colorScheme,
DownloadHistoryItem track, DownloadHistoryItem track,
List<DownloadHistoryItem> navigationItems,
int navigationIndex,
) { ) {
final isSelected = _selectedIds.contains(track.id); final isSelected = _selectedIds.contains(track.id);
@@ -791,7 +816,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
), ),
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(track.id) ? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track), : () => _navigateToMetadataScreen(
track,
navigationItems: navigationItems,
navigationIndex: navigationIndex,
),
onLongPress: _isSelectionMode onLongPress: _isSelectionMode
? null ? null
: () => _enterSelectionMode(track.id), : () => _enterSelectionMode(track.id),
+353 -42
View File
@@ -426,14 +426,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
String? currentSearchProvider, String? currentSearchProvider,
List<Extension> extensions, List<Extension> extensions,
) { ) {
final resolvedSearchProvider = _resolveSearchProvider(
currentSearchProvider,
extensions,
);
final isUsingExtensionSearch = final isUsingExtensionSearch =
currentSearchProvider != null && resolvedSearchProvider != null &&
currentSearchProvider.isNotEmpty && resolvedSearchProvider.isNotEmpty &&
extensions.any((e) => e.id == currentSearchProvider && e.enabled); extensions.any((e) => e.id == resolvedSearchProvider && e.enabled);
if (isUsingExtensionSearch) { if (isUsingExtensionSearch) {
final currentSearchExtension = extensions final currentSearchExtension = extensions
.where((e) => e.id == currentSearchProvider && e.enabled) .where((e) => e.id == resolvedSearchProvider && e.enabled)
.firstOrNull; .firstOrNull;
final filters = currentSearchExtension?.searchBehavior?.filters; final filters = currentSearchExtension?.searchBehavior?.filters;
if (filters != null && filters.isNotEmpty) { if (filters != null && filters.isNotEmpty) {
@@ -449,6 +453,154 @@ 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;
}
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) { _SearchResultBuckets _getSearchResultBuckets(List<Track> tracks) {
final cached = _searchBucketsCache; final cached = _searchBucketsCache;
if (cached != null && identical(tracks, _searchBucketsSourceTracks)) { if (cached != null && identical(tracks, _searchBucketsSourceTracks)) {
@@ -530,7 +682,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
bool _isLiveSearchEnabled() { bool _isLiveSearchEnabled() {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider); final extState = ref.read(extensionProvider);
final searchProvider = settings.searchProvider; final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
);
if (searchProvider == null || searchProvider.isEmpty) return false; if (searchProvider == null || searchProvider.isEmpty) return false;
@@ -599,9 +754,32 @@ class _HomeTabState extends ConsumerState<HomeTab>
Future<void> _performSearch(String query, {String? filterOverride}) async { Future<void> _performSearch(String query, {String? filterOverride}) async {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider); final extState = ref.read(extensionProvider);
final searchProvider = settings.searchProvider; final searchProvider = _resolveSearchProvider(
final selectedFilter = settings.searchProvider,
filterOverride ?? ref.read(trackProvider).selectedSearchFilter; 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 searchKey = final searchKey =
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}'; '${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
@@ -627,7 +805,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
} }
await ref await ref
.read(trackProvider.notifier) .read(trackProvider.notifier)
.customSearch(searchProvider, query, options: options); .customSearch(
searchProvider,
query,
options: options,
selectedFilter: selectedFilter,
);
} else if (isBuiltInProvider) { } else if (isBuiltInProvider) {
await ref await ref
.read(trackProvider.notifier) .read(trackProvider.notifier)
@@ -1062,6 +1245,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
final hasSearchedBefore = ref.watch( final hasSearchedBefore = ref.watch(
settingsProvider.select((s) => s.hasSearchedBefore), settingsProvider.select((s) => s.hasSearchedBefore),
); );
final defaultSearchTab = ref.watch(
settingsProvider.select((s) => s.defaultSearchTab),
);
final hasExploreContent = ref.watch( final hasExploreContent = ref.watch(
exploreProvider.select((s) => s.sections.isNotEmpty), exploreProvider.select((s) => s.sections.isNotEmpty),
@@ -1103,6 +1289,29 @@ class _HomeTabState extends ConsumerState<HomeTab>
(hasHomeFeedExtension || hasExploreContent) && (hasHomeFeedExtension || hasExploreContent) &&
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 && if (hasActualResults &&
isShowingRecentAccess && isShowingRecentAccess &&
hasSearchInput && hasSearchInput &&
@@ -1246,7 +1455,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: _buildSearchFilterBar( child: _buildSearchFilterBar(
searchFilters, searchFilters,
selectedSearchFilter, _displaySearchFilterSelection(
selectedSearchFilter,
defaultSearchTab,
currentSearchProvider,
extensions,
),
colorScheme, colorScheme,
), ),
); );
@@ -1443,7 +1657,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
button: true, button: true,
label: 'Open track ${item.trackName} by ${item.artistName}', label: 'Open track ${item.trackName} by ${item.artistName}',
child: GestureDetector( child: GestureDetector(
onTap: () => _navigateToMetadataScreen(item), onTap: () => _navigateToMetadataScreen(
item,
navigationItems: items
.take(itemCount)
.toList(growable: false),
navigationIndex: index,
),
child: Container( child: Container(
width: coverSize, width: coverSize,
margin: const EdgeInsets.only(right: 12), margin: const EdgeInsets.only(right: 12),
@@ -1840,6 +2060,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
duration: item.durationMs ~/ 1000, duration: item.durationMs ~/ 1000,
trackNumber: null, trackNumber: null,
discNumber: null, discNumber: null,
totalDiscs: null,
isrc: null, isrc: null,
releaseDate: item.releaseDate, releaseDate: item.releaseDate,
coverUrl: item.coverUrl, coverUrl: item.coverUrl,
@@ -2085,17 +2306,25 @@ 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) { void _navigateToRecentItem(RecentAccessItem item) {
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
switch (item.type) { switch (item.type) {
case RecentAccessType.artist: case RecentAccessType.artist:
if (item.providerId != null && if (_isEnabledMetadataExtension(item.providerId)) {
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute<void>( MaterialPageRoute<void>(
@@ -2132,12 +2361,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
), ),
), ),
); );
} else if (item.providerId != null && } else if (_isEnabledMetadataExtension(item.providerId)) {
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute<void>( MaterialPageRoute<void>(
@@ -2182,12 +2406,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return; return;
} }
if (item.providerId != null && if (_isEnabledMetadataExtension(item.providerId)) {
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute<void>( MaterialPageRoute<void>(
@@ -2216,7 +2435,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
} }
} }
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async { Future<void> _navigateToMetadataScreen(
DownloadHistoryItem item, {
List<DownloadHistoryItem>? navigationItems,
int? navigationIndex,
}) async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
final beforeModTime = final beforeModTime =
@@ -2225,7 +2448,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
); );
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
); );
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath, item.filePath,
@@ -3014,6 +3243,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
albumId: albumItem.id, albumId: albumItem.id,
albumName: albumItem.name, albumName: albumItem.name,
coverUrl: albumItem.coverUrl, coverUrl: albumItem.coverUrl,
initialAlbumType: albumItem.albumType,
initialTotalTracks: albumItem.totalTracks,
), ),
), ),
); );
@@ -3092,8 +3323,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
String _getSearchHint() { String _getSearchHint() {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final searchProvider = settings.searchProvider;
final extState = ref.read(extensionProvider); final extState = ref.read(extensionProvider);
final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
);
if (!extState.isInitialized) { if (!extState.isInitialized) {
return 'Paste supported URL or search...'; return 'Paste supported URL or search...';
@@ -3135,10 +3369,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: FilterChip( child: FilterChip(
label: Text(context.l10n.historyFilterAll), label: Text(context.l10n.historyFilterAll),
selected: selectedFilter == null, selected: selectedFilter == 'all',
onSelected: (_) { onSelected: (_) {
ref.read(trackProvider.notifier).setSearchFilter(null); ref.read(trackProvider.notifier).setSearchFilter('all');
_triggerSearchWithFilter(null); _triggerSearchWithFilter('all');
}, },
showCheckmark: false, showCheckmark: false,
), ),
@@ -3294,9 +3528,23 @@ class _SearchProviderDropdown extends ConsumerWidget {
const _SearchProviderDropdown({this.onProviderChanged}); 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentProvider = ref.watch( final rawCurrentProvider = ref.watch(
settingsProvider.select((s) => s.searchProvider), settingsProvider.select((s) => s.searchProvider),
); );
final extensions = ref.watch(extensionProvider.select((s) => s.extensions)); final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
@@ -3305,6 +3553,17 @@ class _SearchProviderDropdown extends ConsumerWidget {
final searchProviders = extensions final searchProviders = extensions
.where((ext) => ext.enabled && ext.hasCustomSearch) .where((ext) => ext.enabled && ext.hasCustomSearch)
.toList(); .toList();
final primarySearchExtension = _defaultSearchExtension(searchProviders);
final defaultProviderLabel =
primarySearchExtension?.displayName ?? 'Deezer';
final defaultProviderIconPath = primarySearchExtension?.iconPath;
final currentProvider =
rawCurrentProvider != null &&
rawCurrentProvider.isNotEmpty &&
({'tidal', 'qobuz'}.contains(rawCurrentProvider) ||
searchProviders.any((e) => e.id == rawCurrentProvider))
? rawCurrentProvider
: null;
Extension? currentExt; Extension? currentExt;
if (currentProvider != null && currentProvider.isNotEmpty) { if (currentProvider != null && currentProvider.isNotEmpty) {
@@ -3324,6 +3583,19 @@ class _SearchProviderDropdown extends ConsumerWidget {
if (currentExt.searchBehavior?.icon != null) { if (currentExt.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); 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) { } else if (isBuiltInProvider) {
displayIcon = Icons.music_note; displayIcon = Icons.music_note;
} }
@@ -3378,7 +3650,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
'Deezer', defaultProviderLabel,
style: TextStyle( style: TextStyle(
fontWeight: fontWeight:
currentProvider == null || currentProvider.isEmpty currentProvider == null || currentProvider.isEmpty
@@ -4298,6 +4570,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
final String albumId; final String albumId;
final String albumName; final String albumName;
final String? coverUrl; final String? coverUrl;
final String? initialAlbumType;
final int? initialTotalTracks;
const ExtensionAlbumScreen({ const ExtensionAlbumScreen({
super.key, super.key,
@@ -4305,6 +4579,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
required this.albumId, required this.albumId,
required this.albumName, required this.albumName,
this.coverUrl, this.coverUrl,
this.initialAlbumType,
this.initialTotalTracks,
}); });
@override @override
@@ -4318,10 +4594,14 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
String? _error; String? _error;
String? _artistId; String? _artistId;
String? _artistName; String? _artistName;
String? _albumType;
int? _albumTotalTracks;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_albumType = normalizeOptionalString(widget.initialAlbumType);
_albumTotalTracks = widget.initialTotalTracks;
_fetchTracks(); _fetchTracks();
} }
@@ -4355,17 +4635,28 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
return; return;
} }
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final artistId = (result['artist_id'] ?? result['artistId'])?.toString(); final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
final artistName = result['artists'] as String?; 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(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_artistName = artistName; _artistName = artistName;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } catch (e) {
@@ -4377,7 +4668,11 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
} }
} }
Track _parseTrack(Map<String, dynamic> data) { Track _parseTrack(
Map<String, dynamic> data, {
String? albumTypeFallback,
int? totalTracksFallback,
}) {
int durationMs = 0; int durationMs = 0;
final durationValue = data['duration_ms']; final durationValue = data['duration_ms'];
if (durationValue is int) { if (durationValue is int) {
@@ -4403,7 +4698,17 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
albumTypeFallback ??
_albumType,
totalTracks:
data['total_tracks'] as int? ??
totalTracksFallback ??
_albumTotalTracks,
composer: data['composer']?.toString(),
source: widget.extensionId, source: widget.extensionId,
); );
} }
@@ -4562,7 +4867,10 @@ class _ExtensionPlaylistScreenState
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
source: widget.extensionId, source: widget.extensionId,
); );
} }
@@ -4739,7 +5047,10 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
source: (data['provider_id'] ?? widget.extensionId).toString(), source: (data['provider_id'] ?? widget.extensionId).toString(),
); );
} }
+12 -3
View File
@@ -1631,7 +1631,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
try { try {
await PlatformBridge.safDelete(item.filePath); await PlatformBridge.safDelete(item.filePath);
} catch (_) {} } catch (_) {}
await localDb.deleteByPath(item.filePath); await localDb.replaceWithConvertedItem(
item: item,
newFilePath: safUri,
targetFormat: targetFormat,
bitrate: bitrate,
);
} }
try { try {
@@ -1643,8 +1648,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} catch (_) {} } catch (_) {}
} }
} else { } else {
// Regular file: just remove old entry, rescan will find the new one await localDb.replaceWithConvertedItem(
await localDb.deleteByPath(item.filePath); item: item,
newFilePath: newPath,
targetFormat: targetFormat,
bitrate: bitrate,
);
} }
successCount++; successCount++;
-15
View File
@@ -7,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.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/settings_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart';
@@ -94,20 +93,6 @@ class _MainShellState extends ConsumerState<MainShell>
} }
Future<void> _handleSharedUrl(String url) async { 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; if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
+3 -2
View File
@@ -61,7 +61,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
if (playlistId != null) { if (playlistId != null) {
if (playlistId.startsWith('tidal:')) return 'tidal'; if (playlistId.startsWith('tidal:')) return 'tidal';
if (playlistId.startsWith('qobuz:')) return 'qobuz'; if (playlistId.startsWith('qobuz:')) return 'qobuz';
if (playlistId.startsWith('deezer:')) return 'deezer';
} }
final source = _tracks.firstOrNull?.source; final source = _tracks.firstOrNull?.source;
@@ -72,7 +71,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final trackId = _tracks.firstOrNull?.id ?? ''; final trackId = _tracks.firstOrNull?.id ?? '';
if (trackId.startsWith('tidal:')) return 'tidal'; if (trackId.startsWith('tidal:')) return 'tidal';
if (trackId.startsWith('qobuz:')) return 'qobuz'; if (trackId.startsWith('qobuz:')) return 'qobuz';
if (trackId.startsWith('deezer:')) return 'deezer';
return null; return null;
} }
@@ -164,7 +162,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
); );
} }
+145 -21
View File
@@ -2963,15 +2963,23 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
Future<void> _navigateToHistoryMetadataScreen( Future<void> _navigateToHistoryMetadataScreen(
DownloadHistoryItem item, DownloadHistoryItem item, {
) async { List<DownloadHistoryItem>? navigationItems,
int? navigationIndex,
}) async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
final beforeModTime = await _readFileModTimeMillis(item.filePath); final beforeModTime = await _readFileModTimeMillis(item.filePath);
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
); );
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
if (result == true) { if (result == true) {
@@ -2988,11 +2996,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
} }
void _navigateToLocalMetadataScreen(LocalLibraryItem item) { void _navigateToLocalMetadataScreen(
LocalLibraryItem item, {
List<LocalLibraryItem>? navigationItems,
int? navigationIndex,
}) {
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
Navigator.push( Navigator.push(
context, context,
slidePageRoute<void>(page: TrackMetadataScreen(localItem: item)), slidePageRoute<void>(
page: TrackMetadataScreen(
localItem: item,
localNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
).then((_) => _searchFocusNode.unfocus()); ).then((_) => _searchFocusNode.unfocus());
} }
@@ -4227,6 +4245,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final filteredUnifiedItems = filterData.filteredUnifiedItems; final filteredUnifiedItems = filterData.filteredUnifiedItems;
final totalTrackCount = filterData.totalTrackCount; final totalTrackCount = filterData.totalTrackCount;
final totalAlbumCount = filterData.totalAlbumCount; 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( return CustomScrollView(
slivers: [ slivers: [
@@ -4419,12 +4456,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
child: _buildUnifiedGridItem( child: _buildUnifiedGridItem(
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
); );
@@ -4472,12 +4523,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
child: _buildUnifiedLibraryItem( child: _buildUnifiedLibraryItem(
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
); );
@@ -4540,6 +4604,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
); );
}, childCount: filteredUnifiedItems.length), }, childCount: filteredUnifiedItems.length),
@@ -4554,6 +4624,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
); );
}, childCount: filteredUnifiedItems.length), }, childCount: filteredUnifiedItems.length),
@@ -5853,13 +5929,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final baseName = dotIdx > 0 final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx) ? oldFileName.substring(0, dotIdx)
: oldFileName; : oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus' String newExt;
? '.opus' String mimeType;
: '.mp3'; switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt'; final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath( final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri, treeUri: treeUri,
@@ -5884,7 +5974,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
try { try {
await PlatformBridge.safDelete(item.filePath); await PlatformBridge.safDelete(item.filePath);
} catch (_) {} } catch (_) {}
await LibraryDatabase.instance.deleteByPath(item.filePath); await LibraryDatabase.instance.replaceWithConvertedItem(
item: item.localItem!,
newFilePath: safUri,
targetFormat: targetFormat,
bitrate: bitrate,
);
} }
try { try {
@@ -5903,7 +5998,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
clearAudioSpecs: true, clearAudioSpecs: true,
); );
} else if (item.localItem != null) { } else if (item.localItem != null) {
await LibraryDatabase.instance.deleteByPath(item.filePath); await LibraryDatabase.instance.replaceWithConvertedItem(
item: item.localItem!,
newFilePath: newPath,
targetFormat: targetFormat,
bitrate: bitrate,
);
} }
successCount++; successCount++;
@@ -6585,8 +6685,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Widget _buildUnifiedLibraryItem( Widget _buildUnifiedLibraryItem(
BuildContext context, BuildContext context,
UnifiedLibraryItem item, UnifiedLibraryItem item,
ColorScheme colorScheme, ColorScheme colorScheme, {
) { required List<DownloadHistoryItem> downloadedNavigationItems,
required int? downloadedNavigationIndex,
required List<LocalLibraryItem> localNavigationItems,
required int? localNavigationIndex,
}) {
final fileExistsListenable = _fileExistsListenable(item.filePath); final fileExistsListenable = _fileExistsListenable(item.filePath);
final isSelected = _selectedIds.contains(item.id); final isSelected = _selectedIds.contains(item.id);
final date = item.addedAt; final date = item.addedAt;
@@ -6616,9 +6720,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(item.id) ? () => _toggleSelection(item.id)
: isDownloaded : isDownloaded
? () => _navigateToHistoryMetadataScreen(item.historyItem!) ? () => _navigateToHistoryMetadataScreen(
item.historyItem!,
navigationItems: downloadedNavigationItems,
navigationIndex: downloadedNavigationIndex,
)
: item.localItem != null : item.localItem != null
? () => _navigateToLocalMetadataScreen(item.localItem!) ? () => _navigateToLocalMetadataScreen(
item.localItem!,
navigationItems: localNavigationItems,
navigationIndex: localNavigationIndex,
)
: () => _openFile( : () => _openFile(
item.filePath, item.filePath,
title: item.trackName, title: item.trackName,
@@ -6792,8 +6904,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Widget _buildUnifiedGridItem( Widget _buildUnifiedGridItem(
BuildContext context, BuildContext context,
UnifiedLibraryItem item, UnifiedLibraryItem item,
ColorScheme colorScheme, ColorScheme colorScheme, {
) { required List<DownloadHistoryItem> downloadedNavigationItems,
required int? downloadedNavigationIndex,
required List<LocalLibraryItem> localNavigationItems,
required int? localNavigationIndex,
}) {
final fileExistsListenable = _fileExistsListenable(item.filePath); final fileExistsListenable = _fileExistsListenable(item.filePath);
final isSelected = _selectedIds.contains(item.id); final isSelected = _selectedIds.contains(item.id);
final isDownloaded = item.source == LibraryItemSource.downloaded; final isDownloaded = item.source == LibraryItemSource.downloaded;
@@ -6802,9 +6918,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(item.id) ? () => _toggleSelection(item.id)
: isDownloaded : isDownloaded
? () => _navigateToHistoryMetadataScreen(item.historyItem!) ? () => _navigateToHistoryMetadataScreen(
item.historyItem!,
navigationItems: downloadedNavigationItems,
navigationIndex: downloadedNavigationIndex,
)
: item.localItem != null : item.localItem != null
? () => _navigateToLocalMetadataScreen(item.localItem!) ? () => _navigateToLocalMetadataScreen(
item.localItem!,
navigationItems: localNavigationItems,
navigationIndex: localNavigationIndex,
)
: () => _openFile( : () => _openFile(
item.filePath, item.filePath,
title: item.trackName, title: item.trackName,
-7
View File
@@ -789,13 +789,6 @@ 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) ...[ if (extension.requiresNewerApp) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Container( Container(

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