Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1138ec7af | |||
| 0e00660e2e | |||
| aad72226c5 | |||
| 83d7106e35 | |||
| 30a7cba02a | |||
| 01a5b43613 | |||
| 149cdc782d | |||
| d24435dbc2 | |||
| bb06ab7e12 | |||
| 2143de3aa7 | |||
| b5973c45a2 | |||
| 9a78798854 | |||
| 101ab3f521 | |||
| cfc8e699f3 | |||
| 6b342aeac6 | |||
| b306056995 | |||
| 82e317c4a8 | |||
| a4dc776bfb | |||
| 5bdaa35ced | |||
| e187ac461d | |||
| 1b4a6cd042 | |||
| dcfb22c3f4 | |||
| 501158df03 | |||
| e17a4fad4e | |||
| 34894faabf | |||
| b329acd710 | |||
| 87dc8eb5ea | |||
| 397669965d | |||
| 60bd0e619e | |||
| 2c7621c1a5 | |||
| b55be00fab | |||
| f8b7812943 | |||
| 8f14ff169a | |||
| ca3abeb1cf | |||
| bb0cc23461 | |||
| 45fa33e1ec | |||
| 64dbf4441c | |||
| 148e5c1231 | |||
| 3a7419ec9f | |||
| 01c7c9cc3a | |||
| 3f56b88fa5 | |||
| bdd3f4aef5 | |||
| 611abdc6ae | |||
| 6e9fa45915 | |||
| 7dafbc1063 | |||
| ad8ac3bd2b | |||
| cd2c2a9854 | |||
| bb7c86c29e | |||
| df96cc4a1d | |||
| 6c3d92cee4 | |||
| 803cd2de96 | |||
| 8f2ca33e87 | |||
| d87e0d7e01 | |||
| 16ce6089fb | |||
| 6895e45f2c | |||
| e87f7a1177 | |||
| bcd8a05352 |
@@ -166,9 +166,8 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|
||||
|
||||
| | | | | |
|
||||
|---|---|---|---|---|
|
||||
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
|
||||
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
|
||||
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | [Monochrome](https://monochrome.tf) |
|
||||
| [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) |
|
||||
| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,11 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
always_declare_return_types: true
|
||||
avoid_dynamic_calls: true
|
||||
avoid_types_as_parameter_names: true
|
||||
strict_top_level_inference: true
|
||||
type_annotate_public_apis: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
|
||||
|
||||
@@ -123,5 +123,6 @@ dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:label="SpotiFLAC"
|
||||
android:label="SpotiFLAC Mobile"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="false"
|
||||
|
||||
@@ -43,6 +43,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"com.zarz.spotiflac/library_scan_progress_stream"
|
||||
private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L
|
||||
private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L
|
||||
private val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
|
||||
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||
private val safScanLock = Any()
|
||||
@@ -51,6 +54,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private var downloadProgressStreamJob: Job? = null
|
||||
private var downloadProgressEventSink: EventChannel.EventSink? = null
|
||||
private var lastDownloadProgressPayload: String? = null
|
||||
private var lastDownloadProgressSeq = 0L
|
||||
private var libraryScanProgressStreamJob: Job? = null
|
||||
private var libraryScanProgressEventSink: EventChannel.EventSink? = null
|
||||
private var lastLibraryScanProgressPayload: String? = null
|
||||
@@ -299,7 +303,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
private fun mimeTypeForExt(ext: String?): String {
|
||||
return when (normalizeExt(ext)) {
|
||||
".m4a" -> "audio/mp4"
|
||||
".m4a", ".mp4" -> "audio/mp4"
|
||||
".mp3" -> "audio/mpeg"
|
||||
".opus" -> "audio/ogg"
|
||||
".flac" -> "audio/flac"
|
||||
@@ -308,21 +312,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun forceFilenameExt(name: String, outputExt: String): String {
|
||||
val normalizedExt = normalizeExt(outputExt)
|
||||
if (normalizedExt.isBlank()) return sanitizeFilename(name)
|
||||
|
||||
val safeName = sanitizeFilename(name)
|
||||
val lower = safeName.lowercase(Locale.ROOT)
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp3", ".opus", ".lrc")
|
||||
for (knownExt in knownExts) {
|
||||
if (lower.endsWith(knownExt)) {
|
||||
return safeName.dropLast(knownExt.length) + normalizedExt
|
||||
}
|
||||
}
|
||||
return safeName + normalizedExt
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
@@ -341,9 +330,47 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
.replace(Regex("_+"), "_")
|
||||
.trim('_', ' ')
|
||||
|
||||
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
|
||||
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
|
||||
return if (sanitized.isBlank()) "Unknown" else sanitized
|
||||
}
|
||||
|
||||
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
|
||||
|
||||
val dotIndex = name.lastIndexOf('.')
|
||||
val ext = if (
|
||||
dotIndex > 0 &&
|
||||
dotIndex < name.length - 1 &&
|
||||
name.length - dotIndex <= 10
|
||||
) {
|
||||
name.substring(dotIndex)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
|
||||
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
|
||||
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
|
||||
}
|
||||
|
||||
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
|
||||
|
||||
val builder = StringBuilder()
|
||||
var usedBytes = 0
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
val codePoint = value.codePointAt(index)
|
||||
val char = String(Character.toChars(codePoint))
|
||||
val charBytes = char.toByteArray(Charsets.UTF_8).size
|
||||
if (usedBytes + charBytes > maxBytes) break
|
||||
builder.append(char)
|
||||
usedBytes += charBytes
|
||||
index += Character.charCount(codePoint)
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
if (relativeDir.isBlank()) return ""
|
||||
return relativeDir
|
||||
@@ -504,17 +531,46 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun bridgeJsonResult(payload: String): Any {
|
||||
if (payload.toByteArray(Charsets.UTF_8).size < LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES) {
|
||||
return payload
|
||||
}
|
||||
|
||||
return try {
|
||||
val file = File(cacheDir, "bridge_json_${System.nanoTime()}.json")
|
||||
file.writeText(payload, Charsets.UTF_8)
|
||||
mapOf(LARGE_JSON_RESULT_FILE_KEY to file.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Failed to spill large bridge JSON result to file: ${e.message}",
|
||||
)
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDownloadProgressSeq(payload: String) {
|
||||
try {
|
||||
val seq = JSONObject(payload).optLong("seq", lastDownloadProgressSeq)
|
||||
if (seq > lastDownloadProgressSeq) {
|
||||
lastDownloadProgressSeq = seq
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
|
||||
stopDownloadProgressStream()
|
||||
downloadProgressEventSink = sink
|
||||
lastDownloadProgressPayload = null
|
||||
lastDownloadProgressSeq = 0L
|
||||
downloadProgressStreamJob = scope.launch {
|
||||
while (isActive && downloadProgressEventSink === sink) {
|
||||
try {
|
||||
val payload = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAllDownloadProgress()
|
||||
Gobackend.getAllDownloadProgressDelta(lastDownloadProgressSeq)
|
||||
}
|
||||
if (payload != lastDownloadProgressPayload) {
|
||||
if (payload.isNotEmpty() && payload != lastDownloadProgressPayload) {
|
||||
updateDownloadProgressSeq(payload)
|
||||
lastDownloadProgressPayload = payload
|
||||
sink.success(parseJsonPayload(payload))
|
||||
}
|
||||
@@ -534,6 +590,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
downloadProgressStreamJob = null
|
||||
downloadProgressEventSink = null
|
||||
lastDownloadProgressPayload = null
|
||||
lastDownloadProgressSeq = 0L
|
||||
}
|
||||
|
||||
private fun startLibraryScanProgressStream(sink: EventChannel.EventSink) {
|
||||
@@ -580,17 +637,17 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
lastLibraryScanProgressPayload = null
|
||||
}
|
||||
|
||||
private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String {
|
||||
private fun loadExistingFilesFromSnapshot(snapshotPath: String): MutableMap<String, Long> {
|
||||
val result = mutableMapOf<String, Long>()
|
||||
if (snapshotPath.isBlank()) {
|
||||
return "{}"
|
||||
return result
|
||||
}
|
||||
|
||||
val snapshotFile = File(snapshotPath)
|
||||
if (!snapshotFile.exists()) {
|
||||
return "{}"
|
||||
return result
|
||||
}
|
||||
|
||||
val result = JSONObject()
|
||||
snapshotFile.forEachLine { line ->
|
||||
if (line.isBlank()) return@forEachLine
|
||||
val separatorIndex = line.indexOf('\t')
|
||||
@@ -600,10 +657,10 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L
|
||||
val filePath = line.substring(separatorIndex + 1)
|
||||
if (filePath.isNotEmpty()) {
|
||||
result.put(filePath, modTime)
|
||||
result[filePath] = modTime
|
||||
}
|
||||
}
|
||||
return result.toString()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
|
||||
@@ -667,16 +724,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||
val provided = req.optString("saf_file_name", "")
|
||||
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
|
||||
|
||||
val trackName = req.optString("track_name", "track")
|
||||
val artistName = req.optString("artist_name", "")
|
||||
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
|
||||
return forceFilenameExt(baseName, outputExt)
|
||||
}
|
||||
|
||||
private fun errorJson(message: String): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("success", false)
|
||||
@@ -724,6 +771,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private fun extFromFileName(name: String): String {
|
||||
return when {
|
||||
name.endsWith(".m4a") -> ".m4a"
|
||||
name.endsWith(".mp4") -> ".mp4"
|
||||
name.endsWith(".mp3") -> ".mp3"
|
||||
name.endsWith(".opus") -> ".opus"
|
||||
name.endsWith(".flac") -> ".flac"
|
||||
@@ -957,112 +1005,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleSafDownload(requestJson: String, downloader: (String) -> String): String {
|
||||
val req = JSONObject(requestJson)
|
||||
val storageMode = req.optString("storage_mode", "")
|
||||
val treeUriStr = req.optString("saf_tree_uri", "")
|
||||
if (storageMode != "saf" || treeUriStr.isBlank()) {
|
||||
return downloader(requestJson)
|
||||
}
|
||||
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
|
||||
val existingDir = findDocumentDir(treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
val obj = JSONObject()
|
||||
obj.put("success", true)
|
||||
obj.put("message", "File already exists")
|
||||
obj.put("file_path", existing.uri.toString())
|
||||
obj.put("file_name", existing.name ?: fileName)
|
||||
obj.put("already_exists", true)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
|
||||
val targetDir = ensureDocumentDir(treeUri, relativeDir)
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
var document = createOrReuseDocumentFile(targetDir, mimeType, fileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
?: return errorJson("Failed to open SAF file")
|
||||
|
||||
var detachedFd: Int? = null
|
||||
try {
|
||||
// Prefer handing off a detached FD directly to Go.
|
||||
// Some devices/providers reject re-opening /proc/self/fd/* with permission denied.
|
||||
detachedFd = pfd.detachFd()
|
||||
req.put("output_path", "")
|
||||
req.put("output_fd", detachedFd)
|
||||
req.put("output_ext", outputExt)
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
// Extension providers write to a local temp path instead of the SAF FD.
|
||||
val goFilePath = respObj.optString("file_path", "")
|
||||
if (goFilePath.isNotEmpty() &&
|
||||
!goFilePath.startsWith("content://") &&
|
||||
!goFilePath.startsWith("/proc/self/fd/")
|
||||
) {
|
||||
try {
|
||||
val srcFile = java.io.File(goFilePath)
|
||||
if (!srcFile.exists() || srcFile.length() <= 0) {
|
||||
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
||||
}
|
||||
val actualExt = normalizeExt(srcFile.extension)
|
||||
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||
val actualFileName = buildSafFileName(req, actualExt)
|
||||
val actualMimeType = mimeTypeForExt(actualExt)
|
||||
val replacement = createOrReuseDocumentFile(
|
||||
targetDir,
|
||||
actualMimeType,
|
||||
actualFileName,
|
||||
)
|
||||
?: throw IllegalStateException("failed to create SAF output with actual extension")
|
||||
if (replacement.uri != document.uri) {
|
||||
document.delete()
|
||||
document = replacement
|
||||
}
|
||||
}
|
||||
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
srcFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||
srcFile.delete()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}")
|
||||
return errorJson("Failed to copy extension output to SAF: ${e.message}")
|
||||
}
|
||||
}
|
||||
respObj.put("file_path", document.uri.toString())
|
||||
respObj.put("file_name", document.name ?: fileName)
|
||||
} else {
|
||||
document.delete()
|
||||
}
|
||||
return respObj.toString()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
return errorJson("SAF download failed: ${e.message}")
|
||||
} finally {
|
||||
// If detachFd() failed before handoff, close original ParcelFileDescriptor.
|
||||
// Otherwise Go owns the detached raw FD and is responsible for closing it.
|
||||
if (detachedFd == null) {
|
||||
try {
|
||||
pfd.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent DocumentFile directory for a SAF document URI.
|
||||
* The child URI must be a tree-based document URI (e.g. from SAF tree scan).
|
||||
@@ -1451,6 +1393,22 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
* @return JSON object with new/changed files and removed URIs
|
||||
*/
|
||||
private fun scanSafTreeIncremental(treeUriStr: String, existingFilesJson: String): String {
|
||||
val existingFiles = mutableMapOf<String, Long>()
|
||||
try {
|
||||
val obj = JSONObject(existingFilesJson)
|
||||
val keys = obj.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
existingFiles[key] = obj.optLong(key, 0)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
return scanSafTreeIncremental(treeUriStr, existingFiles)
|
||||
}
|
||||
|
||||
private fun scanSafTreeIncremental(
|
||||
treeUriStr: String,
|
||||
existingFiles: Map<String, Long>,
|
||||
): String {
|
||||
if (treeUriStr.isBlank()) {
|
||||
val result = JSONObject()
|
||||
result.put("files", JSONArray())
|
||||
@@ -1470,16 +1428,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
val existingFiles = mutableMapOf<String, Long>()
|
||||
try {
|
||||
val obj = JSONObject(existingFilesJson)
|
||||
val keys = obj.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
existingFiles[key] = obj.optLong(key, 0)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
resetSafScanProgress()
|
||||
safScanCancel = false
|
||||
safScanActive = true
|
||||
@@ -2155,7 +2103,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"downloadByStrategy" -> {
|
||||
val requestJson = call.arguments as String
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
handleSafDownload(requestJson) { json ->
|
||||
SafDownloadHandler.handle(this@MainActivity, requestJson) { json ->
|
||||
Gobackend.downloadByStrategy(json)
|
||||
}
|
||||
}
|
||||
@@ -2836,15 +2784,37 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"updateDownloadServiceProgress" -> {
|
||||
val trackName = call.argument<String>("track_name") ?: ""
|
||||
val artistName = call.argument<String>("artist_name") ?: ""
|
||||
val progress = call.argument<Long>("progress") ?: 0L
|
||||
val total = call.argument<Long>("total") ?: 0L
|
||||
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount)
|
||||
val progress = (call.argument<Number>("progress") ?: 0).toLong()
|
||||
val total = (call.argument<Number>("total") ?: 0).toLong()
|
||||
val queueCount = (call.argument<Number>("queue_count") ?: 0).toInt()
|
||||
val status = call.argument<String>("status") ?: "downloading"
|
||||
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount, status)
|
||||
result.success(null)
|
||||
}
|
||||
"isDownloadServiceRunning" -> {
|
||||
result.success(DownloadService.isServiceRunning())
|
||||
}
|
||||
"startNativeDownloadWorker" -> {
|
||||
val requestsJson = call.argument<String>("requests_json") ?: "[]"
|
||||
val settingsJson = call.argument<String>("settings_json") ?: "{}"
|
||||
DownloadService.startNativeQueue(this@MainActivity, requestsJson, settingsJson)
|
||||
result.success(null)
|
||||
}
|
||||
"pauseNativeDownloadWorker" -> {
|
||||
DownloadService.pauseNativeQueue(this@MainActivity)
|
||||
result.success(null)
|
||||
}
|
||||
"resumeNativeDownloadWorker" -> {
|
||||
DownloadService.resumeNativeQueue(this@MainActivity)
|
||||
result.success(null)
|
||||
}
|
||||
"cancelNativeDownloadWorker" -> {
|
||||
DownloadService.cancelNativeQueue(this@MainActivity)
|
||||
result.success(null)
|
||||
}
|
||||
"getNativeDownloadWorkerSnapshot" -> {
|
||||
result.success(parseJsonPayload(DownloadService.getNativeWorkerSnapshot(this@MainActivity)))
|
||||
}
|
||||
"preWarmTrackCache" -> {
|
||||
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -2864,26 +2834,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"searchTidalAll" -> {
|
||||
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.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchQobuzAll" -> {
|
||||
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.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerRelatedArtists" -> {
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 12
|
||||
@@ -2892,62 +2842,20 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerMetadata" -> {
|
||||
"getProviderMetadata" -> {
|
||||
val providerId = call.argument<String>("provider_id") ?: ""
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getDeezerMetadata(resourceType, resourceId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getQobuzMetadata" -> {
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getQobuzMetadata(resourceType, resourceId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getTidalMetadata" -> {
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getTidalMetadata(resourceType, resourceId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"parseDeezerUrl" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.parseDeezerURLExport(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"parseQobuzUrl" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.parseQobuzURLExport(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"parseTidalUrl" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.parseTidalURLExport(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"convertTidalToSpotifyDeezer" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.convertTidalToSpotifyDeezer(url)
|
||||
Gobackend.getProviderMetadataJSON(providerId, resourceType, resourceId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchDeezerByISRC" -> {
|
||||
val isrc = call.argument<String>("isrc") ?: ""
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchDeezerByISRC(isrc)
|
||||
Gobackend.searchDeezerByISRCForItemID(isrc, itemId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3132,6 +3040,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkExtensionHealth" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkExtensionHealthJSON(extensionId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"setExtensionSettings" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val settingsJson = call.argument<String>("settings") ?: "{}"
|
||||
@@ -3259,11 +3174,19 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val optionsJson = call.argument<String>("options") ?: ""
|
||||
val requestId = call.argument<String>("request_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.customSearchWithExtensionJSON(extensionId, query, optionsJson)
|
||||
Gobackend.customSearchWithExtensionJSONWithRequestID(extensionId, query, optionsJson, requestId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"cancelExtensionRequest" -> {
|
||||
val requestId = call.argument<String>("request_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.cancelExtensionRequestJSON(requestId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"getSearchProviders" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getSearchProvidersJSON()
|
||||
@@ -3290,30 +3213,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getAlbumWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val albumId = call.argument<String>("album_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAlbumWithExtensionJSON(extensionId, albumId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getPlaylistWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val playlistId = call.argument<String>("playlist_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getArtistWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getArtistWithExtensionJSON(extensionId, artistId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"runPostProcessing" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val metadataJson = call.argument<String>("metadata") ?: ""
|
||||
@@ -3420,8 +3319,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
"getExtensionHomeFeed" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val requestId = call.argument<String>("request_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getExtensionHomeFeedJSON(extensionId)
|
||||
Gobackend.getExtensionHomeFeedJSONWithRequestID(extensionId, requestId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3443,7 +3343,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val folderPath = call.argument<String>("folder_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
safScanActive = false
|
||||
Gobackend.scanLibraryFolderJSON(folderPath)
|
||||
bridgeJsonResult(Gobackend.scanLibraryFolderJSON(folderPath))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3452,7 +3352,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val existingFiles = call.argument<String>("existing_files") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
safScanActive = false
|
||||
Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles)
|
||||
bridgeJsonResult(
|
||||
Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles)
|
||||
)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3461,9 +3363,11 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
safScanActive = false
|
||||
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
|
||||
folderPath,
|
||||
snapshotPath,
|
||||
bridgeJsonResult(
|
||||
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
|
||||
folderPath,
|
||||
snapshotPath,
|
||||
)
|
||||
)
|
||||
}
|
||||
result.success(response)
|
||||
@@ -3471,7 +3375,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"scanSafTree" -> {
|
||||
val treeUri = call.argument<String>("tree_uri") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
scanSafTree(treeUri)
|
||||
bridgeJsonResult(scanSafTree(treeUri))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3479,7 +3383,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val treeUri = call.argument<String>("tree_uri") ?: ""
|
||||
val existingFiles = call.argument<String>("existing_files") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
scanSafTreeIncremental(treeUri, existingFiles)
|
||||
bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3487,9 +3391,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val treeUri = call.argument<String>("tree_uri") ?: ""
|
||||
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
val existingFilesJson =
|
||||
loadExistingFilesJsonFromSnapshot(snapshotPath)
|
||||
scanSafTreeIncremental(treeUri, existingFilesJson)
|
||||
val existingFiles =
|
||||
loadExistingFilesFromSnapshot(snapshotPath)
|
||||
bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Shared SAF download wrapper for foreground activity calls and service-owned
|
||||
* native workers.
|
||||
*/
|
||||
object SafDownloadHandler {
|
||||
private val safDirLock = Any()
|
||||
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
|
||||
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||
val req = JSONObject(requestJson)
|
||||
val storageMode = req.optString("storage_mode", "")
|
||||
val treeUriStr = req.optString("saf_tree_uri", "")
|
||||
if (storageMode != "saf" || treeUriStr.isBlank()) {
|
||||
return downloader(requestJson)
|
||||
}
|
||||
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
||||
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName, outputExt) else fileName
|
||||
val staleStagedFileName = buildStagedSafFileName(fileName, outputExt)
|
||||
|
||||
val existingDir = findDocumentDir(context, treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
if (useStagedOutput || deferSafPublish) {
|
||||
existingDir.findFile(staleStagedFileName)?.delete()
|
||||
}
|
||||
val obj = JSONObject()
|
||||
obj.put("success", true)
|
||||
obj.put("message", "File already exists")
|
||||
obj.put("file_path", existing.uri.toString())
|
||||
obj.put("file_name", existing.name ?: fileName)
|
||||
obj.put("already_exists", true)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir)
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
if (deferSafPublish) {
|
||||
targetDir.findFile(staleStagedFileName)?.delete()
|
||||
val workingExt = outputExt.ifBlank { ".tmp" }
|
||||
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
|
||||
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
|
||||
return try {
|
||||
req.put("output_path", workingFile.absolutePath)
|
||||
req.put("output_ext", outputExt)
|
||||
req.remove("output_fd")
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
val reportedPath = respObj.optString("file_path", "").trim()
|
||||
if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) {
|
||||
respObj.put("file_path", workingFile.absolutePath)
|
||||
} else if (reportedPath != workingFile.absolutePath) {
|
||||
workingFile.delete()
|
||||
}
|
||||
respObj.put("file_name", respObj.optString("file_name", "").ifBlank { fileName })
|
||||
respObj.put("saf_deferred_publish", true)
|
||||
respObj.put("saf_final_file_name", fileName)
|
||||
respObj.put("saf_relative_dir", relativeDir)
|
||||
respObj.put("saf_tree_uri", treeUriStr)
|
||||
respObj.put("saf_output_ext", outputExt)
|
||||
respObj.put("saf_final_mime_type", mimeType)
|
||||
} else {
|
||||
workingFile.delete()
|
||||
}
|
||||
respObj.toString()
|
||||
} catch (e: Exception) {
|
||||
workingFile.delete()
|
||||
errorJson("SAF deferred download failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
var document = createOrReuseDocumentFile(targetDir, mimeType, stagedFileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
?: return errorJson("Failed to open SAF file")
|
||||
|
||||
var detachedFd: Int? = null
|
||||
try {
|
||||
detachedFd = pfd.detachFd()
|
||||
req.put("output_path", "")
|
||||
req.put("output_fd", detachedFd)
|
||||
req.put("output_ext", outputExt)
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
val goFilePath = respObj.optString("file_path", "")
|
||||
if (goFilePath.isNotEmpty() &&
|
||||
!goFilePath.startsWith("content://") &&
|
||||
!goFilePath.startsWith("/proc/self/fd/")
|
||||
) {
|
||||
try {
|
||||
val srcFile = File(goFilePath)
|
||||
if (!srcFile.exists() || srcFile.length() <= 0) {
|
||||
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
||||
}
|
||||
val actualExt = normalizeExt(srcFile.extension)
|
||||
if (actualExt.isNotBlank()) {
|
||||
respObj.put("actual_extension", actualExt)
|
||||
}
|
||||
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||
val actualFileName = buildSafFileName(req, actualExt)
|
||||
val actualStagedFileName = if (useStagedOutput) {
|
||||
buildStagedSafFileName(actualFileName, actualExt)
|
||||
} else {
|
||||
actualFileName
|
||||
}
|
||||
val actualMimeType = mimeTypeForExt(actualExt)
|
||||
val replacement = createOrReuseDocumentFile(
|
||||
targetDir,
|
||||
actualMimeType,
|
||||
actualStagedFileName
|
||||
) ?: throw IllegalStateException(
|
||||
"failed to create SAF output with actual extension"
|
||||
)
|
||||
if (replacement.uri != document.uri) {
|
||||
document.delete()
|
||||
document = replacement
|
||||
}
|
||||
}
|
||||
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
srcFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||
srcFile.delete()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Failed to copy extension output to SAF: ${e.message}"
|
||||
)
|
||||
return errorJson("Failed to copy extension output to SAF: ${e.message}")
|
||||
}
|
||||
}
|
||||
respObj.put("file_path", document.uri.toString())
|
||||
respObj.put("file_name", document.name ?: fileName)
|
||||
if (useStagedOutput) {
|
||||
respObj.put("saf_staged_output", true)
|
||||
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
|
||||
}
|
||||
} else {
|
||||
document.delete()
|
||||
}
|
||||
return respObj.toString()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
return errorJson("SAF download failed: ${e.message}")
|
||||
} finally {
|
||||
if (detachedFd == null) {
|
||||
try {
|
||||
pfd.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
|
||||
return try {
|
||||
val uri = Uri.parse(uriStr)
|
||||
val extension = DocumentFile.fromSingleUri(context, uri)
|
||||
?.name
|
||||
?.substringAfterLast('.', "")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { ".$it" }
|
||||
?: ".tmp"
|
||||
val temp = File.createTempFile("native_saf_", extension, context.cacheDir)
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
temp.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return null
|
||||
temp.absolutePath
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Failed to copy SAF URI to temp: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSaf(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String
|
||||
): String? {
|
||||
var stagedDocument: DocumentFile? = null
|
||||
return try {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
|
||||
val stagedName = buildStagedSafFileName(finalName, ext)
|
||||
val document = createOrReuseDocumentFile(targetDir, mimeType, stagedName)
|
||||
?: return null
|
||||
stagedDocument = document
|
||||
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
|
||||
if (outputStream == null) {
|
||||
document.delete()
|
||||
stagedDocument = null
|
||||
return null
|
||||
}
|
||||
outputStream.use { output ->
|
||||
File(srcPath).inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
val existingFinal = targetDir.findFile(finalName)
|
||||
if (existingFinal != null && existingFinal.uri != document.uri) {
|
||||
existingFinal.delete()
|
||||
}
|
||||
if (!document.renameTo(finalName)) {
|
||||
document.delete()
|
||||
return null
|
||||
}
|
||||
stagedDocument = null
|
||||
targetDir.findFile(finalName)?.uri?.toString() ?: document.uri.toString()
|
||||
} catch (e: Exception) {
|
||||
stagedDocument?.delete()
|
||||
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContentUri(context: Context, uriStr: String): Boolean {
|
||||
return try {
|
||||
DocumentFile.fromSingleUri(context, Uri.parse(uriStr))?.delete() == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeExt(ext: String?): String {
|
||||
if (ext.isNullOrBlank()) return ""
|
||||
return if (ext.startsWith(".")) {
|
||||
ext.lowercase(Locale.ROOT)
|
||||
} else {
|
||||
".${ext.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun mimeTypeForExt(ext: String?): String {
|
||||
return when (normalizeExt(ext)) {
|
||||
".m4a", ".mp4" -> "audio/mp4"
|
||||
".mp3" -> "audio/mpeg"
|
||||
".opus" -> "audio/ogg"
|
||||
".flac" -> "audio/flac"
|
||||
".lrc" -> "application/octet-stream"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
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", ".mp4", ".mp3", ".opus", ".lrc")
|
||||
for (knownExt in knownExts) {
|
||||
if (lower.endsWith(knownExt)) {
|
||||
return safeName.dropLast(knownExt.length) + normalizedExt
|
||||
}
|
||||
}
|
||||
return safeName + normalizedExt
|
||||
}
|
||||
|
||||
private fun buildStagedSafFileName(fileName: String, outputExt: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(outputExt)
|
||||
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
|
||||
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
|
||||
}
|
||||
|
||||
val dot = safeName.lastIndexOf('.')
|
||||
if (dot > 0 && dot < safeName.lastIndex) {
|
||||
return safeName.substring(0, dot).trimEnd('.', ' ') +
|
||||
".partial" +
|
||||
safeName.substring(dot)
|
||||
}
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||
.filter { ch ->
|
||||
val code = ch.code
|
||||
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
|
||||
code == 0x7F ||
|
||||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
|
||||
}
|
||||
.trim()
|
||||
.trim('.', ' ')
|
||||
|
||||
sanitized = sanitized
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.replace(Regex("_+"), "_")
|
||||
.trim('_', ' ')
|
||||
|
||||
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
|
||||
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
|
||||
return if (sanitized.isBlank()) "Unknown" else sanitized
|
||||
}
|
||||
|
||||
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
|
||||
|
||||
val dotIndex = name.lastIndexOf('.')
|
||||
val ext = if (
|
||||
dotIndex > 0 &&
|
||||
dotIndex < name.length - 1 &&
|
||||
name.length - dotIndex <= 10
|
||||
) {
|
||||
name.substring(dotIndex)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
|
||||
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
|
||||
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
|
||||
}
|
||||
|
||||
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
|
||||
|
||||
val builder = StringBuilder()
|
||||
var usedBytes = 0
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
val codePoint = value.codePointAt(index)
|
||||
val char = String(Character.toChars(codePoint))
|
||||
val charBytes = char.toByteArray(Charsets.UTF_8).size
|
||||
if (usedBytes + charBytes > maxBytes) break
|
||||
builder.append(char)
|
||||
usedBytes += charBytes
|
||||
index += Character.charCount(codePoint)
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
if (relativeDir.isBlank()) return ""
|
||||
return relativeDir
|
||||
.split("/")
|
||||
.map { sanitizeFilename(it) }
|
||||
.filter { it.isNotBlank() && it != "." && it != ".." }
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun ensureDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
): DocumentFile? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) {
|
||||
return DocumentFile.fromTreeUri(context, treeUri)
|
||||
}
|
||||
|
||||
synchronized(safDirLock) {
|
||||
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
current = if (existing != null && existing.isDirectory) {
|
||||
existing
|
||||
} else {
|
||||
val created = current.createDirectory(part) ?: return null
|
||||
val createdName = created.name ?: part
|
||||
if (createdName != part) {
|
||||
created.delete()
|
||||
current.findFile(part) ?: return null
|
||||
} else {
|
||||
created
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
private fun findDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
): DocumentFile? {
|
||||
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) return current
|
||||
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
if (existing == null || !existing.isDirectory) return null
|
||||
current = existing
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||
val provided = req.optString("saf_file_name", "")
|
||||
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
|
||||
|
||||
val trackName = req.optString("track_name", "track")
|
||||
val artistName = req.optString("artist_name", "")
|
||||
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
|
||||
return forceFilenameExt(baseName, outputExt)
|
||||
}
|
||||
|
||||
private fun errorJson(message: String): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("success", false)
|
||||
obj.put("error", message)
|
||||
obj.put("message", message)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,12 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,12 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
@@ -6,4 +6,9 @@
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
<monochrome>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_monochrome"
|
||||
android:inset="16%" />
|
||||
</monochrome>
|
||||
</adaptive-icon>
|
||||
|
||||
|
Before Width: | Height: | Size: 954 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,17 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
|
||||
|
||||
@@ -20,7 +20,7 @@ pluginManagement {
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "SpotiFLAC Source",
|
||||
"name": "SpotiFLAC Mobile Source",
|
||||
"identifier": "com.zarzet.spotiflac.source",
|
||||
"subtitle": "FLAC Downloader for iOS",
|
||||
"apps": [
|
||||
{
|
||||
"name": "SpotiFLAC",
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.3.1",
|
||||
"versionDate": "2026-04-14",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.3.1/SpotiFLAC-v4.3.1-ios-unsigned.ipa",
|
||||
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 34773644
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ files:
|
||||
# Short codes for single-variant languages
|
||||
de: de
|
||||
es: es
|
||||
es-ES: es_ES
|
||||
fr: fr
|
||||
hi: hi
|
||||
id: id
|
||||
@@ -13,7 +14,11 @@ files:
|
||||
ko: ko
|
||||
nl: nl
|
||||
pt: pt
|
||||
pt-PT: pt_PT
|
||||
ru: ru
|
||||
tr: tr
|
||||
uk: uk
|
||||
zh: zh
|
||||
# Full codes for Chinese variants
|
||||
zh-CN: zh_CN
|
||||
zh-TW: zh_TW
|
||||
|
||||
@@ -56,7 +56,6 @@ func ReadAPETags(filePath string) (*APETag, error) {
|
||||
return nil, fmt.Errorf("file too small for APE tag")
|
||||
}
|
||||
|
||||
// Try to find APE tag footer at the end of file.
|
||||
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
||||
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
||||
if err == nil {
|
||||
@@ -255,7 +254,6 @@ func findExistingAPETagSize(filePath string) (int64, error) {
|
||||
|
||||
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
||||
|
||||
// Check if there's also a header (tagSize only covers items + footer)
|
||||
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
||||
totalSize := tagSize
|
||||
if hasHeader {
|
||||
@@ -511,7 +509,6 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||
// deletion: the caller sends an empty value which is not serialized into
|
||||
// newItems, but the old value must still be dropped.
|
||||
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
||||
// Build a set of keys being updated (upper-case for case-insensitive match)
|
||||
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
||||
for k := range overrideKeys {
|
||||
combined[strings.ToUpper(k)] = struct{}{}
|
||||
@@ -539,7 +536,6 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
||||
return nil, fmt.Errorf("file too small for APE tag")
|
||||
}
|
||||
|
||||
// Try footer at end of file
|
||||
footer := make([]byte, apeTagHeaderSize)
|
||||
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sample.ape")
|
||||
if err := os.WriteFile(path, []byte("audio-data"), 0600); err != nil {
|
||||
t.Fatalf("write sample: %v", err)
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{
|
||||
Title: "Song",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
AlbumArtist: "Album Artist",
|
||||
Genre: "Pop",
|
||||
Date: "2026",
|
||||
TrackNumber: 3,
|
||||
TotalTracks: 12,
|
||||
DiscNumber: 1,
|
||||
TotalDiscs: 2,
|
||||
ISRC: "USRC17607839",
|
||||
Lyrics: "lyrics",
|
||||
Label: "Label",
|
||||
Copyright: "Copyright",
|
||||
Composer: "Composer",
|
||||
Comment: "Comment",
|
||||
ReplayGainTrackGain: "-6.50 dB",
|
||||
ReplayGainTrackPeak: "0.98",
|
||||
ReplayGainAlbumGain: "-5.00 dB",
|
||||
ReplayGainAlbumPeak: "0.99",
|
||||
}
|
||||
items := AudioMetadataToAPEItems(metadata)
|
||||
if len(items) == 0 {
|
||||
t.Fatal("expected APE items")
|
||||
}
|
||||
|
||||
tag := &APETag{Items: append(items, APETagItem{Key: "Custom", Value: "Keep"})}
|
||||
if err := WriteAPETags(path, tag); err != nil {
|
||||
t.Fatalf("WriteAPETags: %v", err)
|
||||
}
|
||||
|
||||
readTag, err := ReadAPETags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAPETags: %v", err)
|
||||
}
|
||||
if readTag.Version != apeTagVersion2 {
|
||||
t.Fatalf("version = %d", readTag.Version)
|
||||
}
|
||||
readMetadata := APETagToAudioMetadata(readTag)
|
||||
if readMetadata.Title != "Song" || readMetadata.TrackNumber != 3 || readMetadata.TotalTracks != 12 {
|
||||
t.Fatalf("metadata = %#v", readMetadata)
|
||||
}
|
||||
|
||||
readerTag, err := ReadAPETagsFromReader(bytes.NewReader(mustReadFile(t, path)), int64(len(mustReadFile(t, path))))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAPETagsFromReader: %v", err)
|
||||
}
|
||||
if len(readerTag.Items) != len(readTag.Items) {
|
||||
t.Fatalf("reader items = %d, file items = %d", len(readerTag.Items), len(readTag.Items))
|
||||
}
|
||||
|
||||
override := apeKeysFromFields(map[string]string{"title": "", "lyrics": "", "disc_total": ""})
|
||||
merged := MergeAPEItems(readTag.Items, []APETagItem{{Key: "Title", Value: "New Song"}}, override)
|
||||
mergedMeta := APETagToAudioMetadata(&APETag{Items: merged})
|
||||
if mergedMeta.Title != "New Song" {
|
||||
t.Fatalf("merged title = %q", mergedMeta.Title)
|
||||
}
|
||||
if mergedMeta.Lyrics != "" {
|
||||
t.Fatalf("expected lyrics cleared, got %q", mergedMeta.Lyrics)
|
||||
}
|
||||
|
||||
if err := WriteAPETags(path, &APETag{Items: []APETagItem{{Key: "Title", Value: "Replacement"}}}); err != nil {
|
||||
t.Fatalf("replace APE tags: %v", err)
|
||||
}
|
||||
replaced, err := ReadAPETags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read replacement: %v", err)
|
||||
}
|
||||
if got := APETagToAudioMetadata(replaced).Title; got != "Replacement" {
|
||||
t.Fatalf("replacement title = %q", got)
|
||||
}
|
||||
|
||||
if _, err := marshalAPETag(nil); err == nil {
|
||||
t.Fatal("expected empty tag error")
|
||||
}
|
||||
if _, err := ReadAPETags(filepath.Join(dir, "missing.ape")); err == nil {
|
||||
t.Fatal("expected missing file error")
|
||||
}
|
||||
if _, err := ReadAPETagsFromReader(bytes.NewReader([]byte("short")), 5); err == nil {
|
||||
t.Fatal("expected small reader error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPETagInvalidFooterBranches(t *testing.T) {
|
||||
footer := buildAPEHeaderFooter(9999, apeTagHeaderSize, 1, 0)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected unsupported version")
|
||||
}
|
||||
|
||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize-1, 1, 0)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected small tag size")
|
||||
}
|
||||
|
||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1001, 0)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected too many items")
|
||||
}
|
||||
|
||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1, apeTagFlagHeader)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected header flag error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAudioMetadataID3ParsingBranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tagged.mp3")
|
||||
tag := buildID3v23Tag(
|
||||
id3TextFrame("TIT2", "Title"),
|
||||
id3TextFrame("TPE1", "Artist"),
|
||||
id3TextFrame("TPE2", "Album Artist"),
|
||||
id3TextFrame("TALB", "Album"),
|
||||
id3TextFrame("TDRC", "2026-05-04"),
|
||||
id3TextFrame("TCON", "(13)Pop"),
|
||||
id3TextFrame("TRCK", "4/12"),
|
||||
id3TextFrame("TPOS", "1/2"),
|
||||
id3TextFrame("TSRC", "USRC17607839"),
|
||||
id3TextFrame("TCOM", "Composer"),
|
||||
id3TextFrame("TPUB", "Label"),
|
||||
id3TextFrame("TCOP", "Copyright"),
|
||||
id3CommentFrame("COMM", "Comment"),
|
||||
id3CommentFrame("USLT", "Lyrics"),
|
||||
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.50 dB"),
|
||||
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_PEAK", "0.98"),
|
||||
)
|
||||
if err := os.WriteFile(path, append(tag, []byte("audio")...), 0600); err != nil {
|
||||
t.Fatalf("write ID3v2: %v", err)
|
||||
}
|
||||
|
||||
meta, err := ReadID3Tags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags: %v", err)
|
||||
}
|
||||
if meta.Title != "Title" || meta.TrackNumber != 4 || meta.TotalTracks != 12 || meta.Genre != "Pop" {
|
||||
t.Fatalf("metadata = %#v", meta)
|
||||
}
|
||||
if meta.Comment != "Comment" || meta.Lyrics != "Lyrics" || meta.ReplayGainTrackGain == "" {
|
||||
t.Fatalf("metadata comments/lyrics/replaygain = %#v", meta)
|
||||
}
|
||||
|
||||
id3v1Path := filepath.Join(dir, "id3v1.mp3")
|
||||
if err := os.WriteFile(id3v1Path, append([]byte("audio"), buildID3v1Tag("V1 Title", "V1 Artist", "V1 Album", "1999", 7, 13)...), 0600); err != nil {
|
||||
t.Fatalf("write ID3v1: %v", err)
|
||||
}
|
||||
v1, err := ReadID3Tags(id3v1Path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags v1: %v", err)
|
||||
}
|
||||
if v1.Title != "V1 Title" || v1.Artist != "V1 Artist" || v1.Genre == "" {
|
||||
t.Fatalf("v1 = %#v", v1)
|
||||
}
|
||||
|
||||
v22Path := filepath.Join(dir, "id3v22.mp3")
|
||||
v22 := buildID3v22Tag(
|
||||
id3v22TextFrame("TT2", "V22 Title"),
|
||||
id3v22TextFrame("TP1", "V22 Artist"),
|
||||
id3v22TextFrame("TRK", "2/5"),
|
||||
id3v22CommentFrame("ULT", "V22 Lyrics"),
|
||||
)
|
||||
if err := os.WriteFile(v22Path, append(v22, []byte("audio")...), 0600); err != nil {
|
||||
t.Fatalf("write ID3v2.2: %v", err)
|
||||
}
|
||||
v22Meta, err := ReadID3Tags(v22Path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags v2.2: %v", err)
|
||||
}
|
||||
if v22Meta.Title != "V22 Title" || v22Meta.Artist != "V22 Artist" || v22Meta.Lyrics != "V22 Lyrics" {
|
||||
t.Fatalf("v22 = %#v", v22Meta)
|
||||
}
|
||||
|
||||
if got := decodeUTF16([]byte{0xff, 0xfe, 'H', 0, 'i', 0}); got != "Hi" {
|
||||
t.Fatalf("decodeUTF16 = %q", got)
|
||||
}
|
||||
if got := decodeUTF16BE([]byte{0, 'O', 0, 'K'}); got != "OK" {
|
||||
t.Fatalf("decodeUTF16BE = %q", got)
|
||||
}
|
||||
if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 {
|
||||
t.Fatalf("parseIndexPair = %d/%d", n, total)
|
||||
}
|
||||
if got := parseTrackNumber("9/11"); got != 9 {
|
||||
t.Fatalf("parseTrackNumber = %d", got)
|
||||
}
|
||||
if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) {
|
||||
t.Fatalf("removeUnsync = %#v", got)
|
||||
}
|
||||
if got := extendedHeaderSize([]byte{0, 0, 0, 6, 0, 0, 0, 0, 0, 0}, 3); got != 10 {
|
||||
t.Fatalf("extendedHeaderSize = %d", got)
|
||||
}
|
||||
if got := syncsafeToInt([]byte{0, 0, 2, 0}); got != 256 {
|
||||
t.Fatalf("syncsafe = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
|
||||
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0}
|
||||
if detectCoverMIME("cover.jpg", png) != "image/png" || detectCoverMIME("cover.webp", []byte("RIFFxxxxWEBPdata")) != "image/webp" {
|
||||
t.Fatal("cover MIME detection mismatch")
|
||||
}
|
||||
if _, err := buildPictureBlock("", nil); err == nil {
|
||||
t.Fatal("expected empty picture block error")
|
||||
}
|
||||
|
||||
apic := append([]byte{3}, []byte("image/png\x00")...)
|
||||
apic = append(apic, 3, 0)
|
||||
apic = append(apic, png...)
|
||||
image, mime := parseAPICFrame(apic, 3)
|
||||
if mime != "image/png" || !bytes.Equal(image, png) {
|
||||
t.Fatalf("APIC = %s/%v", mime, image)
|
||||
}
|
||||
pic := append([]byte{0}, []byte("PNG")...)
|
||||
pic = append(pic, 3, 0)
|
||||
pic = append(pic, png...)
|
||||
image, mime = parseAPICFrame(pic, 2)
|
||||
if mime != "image/png" || !bytes.Equal(image, png) {
|
||||
t.Fatalf("PIC = %s/%v", mime, image)
|
||||
}
|
||||
|
||||
frame := make([]byte, 10)
|
||||
copy(frame[:4], "APIC")
|
||||
binary.BigEndian.PutUint32(frame[4:8], uint32(len(apic)))
|
||||
tag := append(frame, apic...)
|
||||
header := []byte{'I', 'D', '3', 3, 0, 0, byte(len(tag) >> 21), byte(len(tag) >> 14), byte(len(tag) >> 7), byte(len(tag))}
|
||||
mp3CoverPath := filepath.Join(t.TempDir(), "cover.mp3")
|
||||
if err := os.WriteFile(mp3CoverPath, append(append(header, tag...), []byte("audio")...), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extracted, extractedMIME, err := extractMP3CoverArt(mp3CoverPath)
|
||||
if err != nil || extractedMIME != "image/png" || !bytes.Equal(extracted, png) {
|
||||
t.Fatalf("extractMP3CoverArt = %s/%v/%v", extractedMIME, extracted, err)
|
||||
}
|
||||
|
||||
var picture bytes.Buffer
|
||||
binary.Write(&picture, binary.BigEndian, uint32(3))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len("image/png")))
|
||||
picture.WriteString("image/png")
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(32))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len(png)))
|
||||
picture.Write(png)
|
||||
flacImage, flacMIME := parseFLACPictureBlock(picture.Bytes())
|
||||
if flacMIME != "image/png" || !bytes.Equal(flacImage, png) {
|
||||
t.Fatalf("FLAC picture = %s/%v", flacMIME, flacImage)
|
||||
}
|
||||
|
||||
comment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture.Bytes())
|
||||
var vorbis bytes.Buffer
|
||||
binary.Write(&vorbis, binary.LittleEndian, uint32(6))
|
||||
vorbis.WriteString("vendor")
|
||||
binary.Write(&vorbis, binary.LittleEndian, uint32(1))
|
||||
binary.Write(&vorbis, binary.LittleEndian, uint32(len(comment)))
|
||||
vorbis.WriteString(comment)
|
||||
commentImage, commentMIME := extractPictureFromVorbisComments(vorbis.Bytes())
|
||||
if commentMIME != "image/png" || !bytes.Equal(commentImage, png) {
|
||||
t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage)
|
||||
}
|
||||
decoded := make([]byte, base64StdDecodeLen(len("SGV sbG8="))+4)
|
||||
n, err := base64StdDecode(decoded, []byte("SGV sbG8="))
|
||||
if err != nil || strings.TrimRight(string(decoded[:n]), "\x00") != "Hello" {
|
||||
t.Fatalf("base64 decode = %q/%v", decoded[:n], err)
|
||||
}
|
||||
|
||||
if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus {
|
||||
t.Fatal("expected opus stream")
|
||||
}
|
||||
if detectOggStreamType([][]byte{append([]byte{1}, []byte("vorbisxxxx")...)}) != oggStreamVorbis {
|
||||
t.Fatal("expected vorbis stream")
|
||||
}
|
||||
|
||||
mp3Path := filepath.Join(t.TempDir(), "quality.mp3")
|
||||
audio := append([]byte{0xFF, 0xFB, 0x90, 0x64}, bytes.Repeat([]byte{0}, 2000)...)
|
||||
if err := os.WriteFile(mp3Path, audio, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quality, err := GetMP3Quality(mp3Path)
|
||||
if err != nil || quality.SampleRate != 44100 || quality.Bitrate != 128000 {
|
||||
t.Fatalf("MP3 quality = %#v/%v", quality, err)
|
||||
}
|
||||
if _, _, err := extractMP3CoverArt(filepath.Join(t.TempDir(), "missing.mp3")); err == nil {
|
||||
t.Fatal("expected missing MP3 cover error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tagged.m4a")
|
||||
cover := []byte{0xFF, 0xD8, 0xFF, 0x00}
|
||||
ilstPayload := []byte{}
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9nam", "M4A Title")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9ART", "M4A Artist")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9alb", "M4A Album")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("aART", "Album Artist")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9day", "2026")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9gen", "Pop")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9wrt", "Composer")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9cmt", "[ti:Comment Lyrics]")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("cprt", "Copyright")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9lyr", "[00:00.00]M4A Lyrics")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AIndexTag("trkn", 3, 12)...)
|
||||
ilstPayload = append(ilstPayload, buildM4AIndexTag("disk", 1, 2)...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("ISRC", "USRC17607839")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("LABEL", "Label")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("REPLAYGAIN_TRACK_GAIN", "-6.50 dB")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AAtom("covr", buildM4AAtom("data", append([]byte{0, 0, 0, 13, 0, 0, 0, 0}, cover...)))...)
|
||||
fileData := buildM4AFileWithIlst(ilstPayload, true)
|
||||
if err := os.WriteFile(path, fileData, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, err := ReadM4ATags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadM4ATags: %v", err)
|
||||
}
|
||||
if meta.Title != "M4A Title" || meta.Artist != "M4A Artist" || meta.TrackNumber != 3 || meta.TotalTracks != 12 || meta.ISRC != "USRC17607839" {
|
||||
t.Fatalf("M4A metadata = %#v", meta)
|
||||
}
|
||||
if lyrics, err := extractLyricsFromM4A(path); err != nil || !strings.Contains(lyrics, "M4A Lyrics") {
|
||||
t.Fatalf("extractLyricsFromM4A = %q/%v", lyrics, err)
|
||||
}
|
||||
if image, err := extractCoverFromM4A(path); err != nil || !bytes.Equal(image, cover) {
|
||||
t.Fatalf("extractCoverFromM4A = %#v/%v", image, err)
|
||||
}
|
||||
if pathInfo, err := func() (m4aMetadataPath, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return m4aMetadataPath{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
info, _ := f.Stat()
|
||||
return findM4AMetadataPath(f, info.Size())
|
||||
}(); err != nil || pathInfo.udta == nil {
|
||||
t.Fatalf("findM4AMetadataPath = %#v/%v", pathInfo, err)
|
||||
}
|
||||
if err := EditM4AReplayGain(path, map[string]string{"replaygain_track_gain": "-5.00 dB", "replaygain_track_peak": "0.98"}); err != nil {
|
||||
t.Fatalf("EditM4AReplayGain: %v", err)
|
||||
}
|
||||
edited, err := ReadM4ATags(path)
|
||||
if err != nil || edited.ReplayGainTrackGain != "-5.00 dB" || edited.ReplayGainTrackPeak != "0.98" {
|
||||
t.Fatalf("edited M4A = %#v/%v", edited, err)
|
||||
}
|
||||
|
||||
noUdtaPath := filepath.Join(dir, "noudta.m4a")
|
||||
if err := os.WriteFile(noUdtaPath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "No Udta"), false), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta, err := ReadM4ATags(noUdtaPath); err != nil || meta.Title != "No Udta" {
|
||||
t.Fatalf("ReadM4ATags no udta = %#v/%v", meta, err)
|
||||
}
|
||||
if _, err := ReadM4ATags(filepath.Join(dir, "missing.m4a")); err == nil {
|
||||
t.Fatal("expected missing M4A error")
|
||||
}
|
||||
emptyM4A := filepath.Join(dir, "empty.m4a")
|
||||
if err := os.WriteFile(emptyM4A, buildM4AFileWithIlst(nil, true), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadM4ATags(emptyM4A); err == nil {
|
||||
t.Fatal("expected empty M4A tags error")
|
||||
}
|
||||
if _, err := extractCoverFromM4A(emptyM4A); err == nil {
|
||||
t.Fatal("expected missing M4A cover error")
|
||||
}
|
||||
if _, err := extractLyricsFromM4A(emptyM4A); err == nil {
|
||||
t.Fatal("expected missing M4A lyrics error")
|
||||
}
|
||||
|
||||
sidecarAudio := filepath.Join(dir, "sidecar.mp3")
|
||||
if err := os.WriteFile(sidecarAudio, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte(" [00:00.00]Sidecar "), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lyrics, err := extractLyricsFromSidecarLRC(sidecarAudio); err != nil || !strings.Contains(lyrics, "Sidecar") {
|
||||
t.Fatalf("sidecar lyrics = %q/%v", lyrics, err)
|
||||
}
|
||||
if !looksLikeEmbeddedLyrics("[ti:Song]") || !looksLikeEmbeddedLyrics("[00:00.00]Line\n[00:01.00]Next") || looksLikeEmbeddedLyrics("plain") {
|
||||
t.Fatal("embedded lyric heuristic mismatch")
|
||||
}
|
||||
if formatIndexValue(3, 12) != "3/12" || formatIndexValue(3, 0) != "3" || formatIndexValue(0, 12) != "" {
|
||||
t.Fatal("formatIndexValue mismatch")
|
||||
}
|
||||
if parsePositiveInt(" 42 ") != 42 || parsePositiveInt("bad") != 0 {
|
||||
t.Fatal("parsePositiveInt mismatch")
|
||||
}
|
||||
if !hasMapKey(map[string]string{"x": "y"}, "x") {
|
||||
t.Fatal("expected map key")
|
||||
}
|
||||
if _, ok := parseReplayGainDb("-6.50 dB"); !ok {
|
||||
t.Fatal("expected ReplayGain dB parse")
|
||||
}
|
||||
if _, ok := parseReplayGainPeak("0.98"); !ok {
|
||||
t.Fatal("expected ReplayGain peak parse")
|
||||
}
|
||||
if norm := buildITunNORMTag("-6.50 dB", "0.98"); norm == "" {
|
||||
t.Fatal("expected iTunNORM")
|
||||
}
|
||||
if fields := collectM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-6 dB", "replaygain_track_peak": "0.9"}); fields["iTunNORM"] == "" {
|
||||
t.Fatalf("ReplayGain fields = %#v", fields)
|
||||
}
|
||||
|
||||
qualityPath := filepath.Join(dir, "quality.m4a")
|
||||
mvhd := make([]byte, 20)
|
||||
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
|
||||
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
|
||||
sampleEntry := make([]byte, 32)
|
||||
copy(sampleEntry[0:4], "mp4a")
|
||||
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
|
||||
sampleEntry[28] = 0xAC
|
||||
sampleEntry[29] = 0x44
|
||||
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
||||
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(qualityPath); err != nil || quality.BitDepth != 24 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||
t.Fatalf("GetM4AQuality = %#v/%v", quality, err)
|
||||
}
|
||||
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
|
||||
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
|
||||
}
|
||||
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
|
||||
t.Fatal("short ALAC config should not parse")
|
||||
}
|
||||
alac := make([]byte, 24)
|
||||
alac[5] = 16
|
||||
binary.BigEndian.PutUint32(alac[20:24], 48000)
|
||||
if depth, rate, ok := parseALACSpecificConfig(alac); !ok || depth != 16 || rate != 48000 {
|
||||
t.Fatalf("ALAC config = %d/%d/%v", depth, rate, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opusHead := make([]byte, 19)
|
||||
copy(opusHead[0:8], "OpusHead")
|
||||
binary.LittleEndian.PutUint16(opusHead[10:12], 312)
|
||||
binary.LittleEndian.PutUint32(opusHead[12:16], 48000)
|
||||
|
||||
var comments bytes.Buffer
|
||||
binary.Write(&comments, binary.LittleEndian, uint32(6))
|
||||
comments.WriteString("vendor")
|
||||
entries := []string{
|
||||
"TITLE=Ogg Title",
|
||||
"ARTIST=Artist",
|
||||
"ALBUMARTIST=Album Artist",
|
||||
"TRACKNUMBER=2/9",
|
||||
"DISCNUMBER=1/2",
|
||||
"LYRICS=[00:00.00]Ogg Lyrics",
|
||||
}
|
||||
binary.Write(&comments, binary.LittleEndian, uint32(len(entries)))
|
||||
for _, entry := range entries {
|
||||
binary.Write(&comments, binary.LittleEndian, uint32(len(entry)))
|
||||
comments.WriteString(entry)
|
||||
}
|
||||
opusTags := append([]byte("OpusTags"), comments.Bytes()...)
|
||||
oggPath := filepath.Join(dir, "tagged.opus")
|
||||
oggData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, opusTags)...)
|
||||
if err := os.WriteFile(oggPath, oggData, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quality, err := GetOggQuality(oggPath)
|
||||
if err != nil || quality.SampleRate != 48000 || quality.Duration != 1 {
|
||||
t.Fatalf("GetOggQuality = %#v/%v", quality, err)
|
||||
}
|
||||
meta, err := ReadOggVorbisComments(oggPath)
|
||||
if err != nil || meta.Title != "Ogg Title" || meta.TrackNumber != 2 || meta.TotalTracks != 9 {
|
||||
t.Fatalf("ReadOggVorbisComments = %#v/%v", meta, err)
|
||||
}
|
||||
|
||||
picture := buildTestFLACPictureBlock([]byte{0x89, 0x50, 0x4E, 0x47}, "image/png")
|
||||
pictureComment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture)
|
||||
var coverComments bytes.Buffer
|
||||
binary.Write(&coverComments, binary.LittleEndian, uint32(6))
|
||||
coverComments.WriteString("vendor")
|
||||
binary.Write(&coverComments, binary.LittleEndian, uint32(1))
|
||||
binary.Write(&coverComments, binary.LittleEndian, uint32(len(pictureComment)))
|
||||
coverComments.WriteString(pictureComment)
|
||||
coverPath := filepath.Join(dir, "cover.opus")
|
||||
coverData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, append([]byte("OpusTags"), coverComments.Bytes()...))...)
|
||||
if err := os.WriteFile(coverPath, coverData, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if image, mime, err := extractOggCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractOggCoverArt = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
if image, mime, err := extractAnyCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractAnyCoverArt = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
extractedCoverPath := filepath.Join(dir, "extracted.png")
|
||||
if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil {
|
||||
t.Fatalf("ExtractCoverToFile = %v", err)
|
||||
}
|
||||
if data := mustReadFile(t, extractedCoverPath); len(data) == 0 {
|
||||
t.Fatal("expected extracted cover data")
|
||||
}
|
||||
cachePath, err := SaveCoverToCacheWithHintAndKey(coverPath, "cover.opus", dir, "key")
|
||||
if err != nil || cachePath == "" {
|
||||
t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err)
|
||||
}
|
||||
cacheDir := filepath.Join(dir, "cache")
|
||||
if path, err := SaveCoverToCache(coverPath, cacheDir); err != nil || !strings.HasSuffix(path, ".png") {
|
||||
t.Fatalf("SaveCoverToCache = %q/%v", path, err)
|
||||
}
|
||||
if path, err := SaveCoverToCacheWithHint(coverPath, "cover.opus", cacheDir); err != nil || path == "" {
|
||||
t.Fatalf("SaveCoverToCacheWithHint = %q/%v", path, err)
|
||||
}
|
||||
hitPath, err := SaveCoverToCache(coverPath, cacheDir)
|
||||
if err != nil || hitPath == "" {
|
||||
t.Fatalf("SaveCoverToCache cache hit = %q/%v", hitPath, err)
|
||||
}
|
||||
if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil {
|
||||
t.Fatal("expected missing cover cache error")
|
||||
}
|
||||
|
||||
badPath := filepath.Join(dir, "bad.ogg")
|
||||
if err := os.WriteFile(badPath, []byte("bad"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := GetOggQuality(badPath); err == nil {
|
||||
t.Fatal("expected invalid Ogg quality error")
|
||||
}
|
||||
}
|
||||
|
||||
func buildM4ADataPayload(payload []byte) []byte {
|
||||
return append([]byte{0, 0, 0, 1, 0, 0, 0, 0}, payload...)
|
||||
}
|
||||
|
||||
func buildM4ATextTag(atomType, value string) []byte {
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload([]byte(value))))
|
||||
}
|
||||
|
||||
func buildM4AIndexTag(atomType string, number, total int) []byte {
|
||||
payload := []byte{0, 0, 0, byte(number), 0, byte(total), 0, 0}
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload(payload)))
|
||||
}
|
||||
|
||||
func buildM4AFileWithIlst(ilstPayload []byte, withUdta bool) []byte {
|
||||
ilst := buildM4AAtom("ilst", ilstPayload)
|
||||
meta := buildM4AAtom("meta", append([]byte{0, 0, 0, 0}, ilst...))
|
||||
moovPayload := meta
|
||||
if withUdta {
|
||||
moovPayload = buildM4AAtom("udta", meta)
|
||||
}
|
||||
return append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", moovPayload)...)
|
||||
}
|
||||
|
||||
func buildOggPage(headerType byte, granule uint64, packet []byte) []byte {
|
||||
header := make([]byte, 27)
|
||||
copy(header[0:4], "OggS")
|
||||
header[4] = 0
|
||||
header[5] = headerType
|
||||
binary.LittleEndian.PutUint64(header[6:14], granule)
|
||||
header[26] = 1
|
||||
return append(append(header, byte(len(packet))), packet...)
|
||||
}
|
||||
|
||||
func buildTestFLACPictureBlock(image []byte, mime string) []byte {
|
||||
var picture bytes.Buffer
|
||||
binary.Write(&picture, binary.BigEndian, uint32(3))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len(mime)))
|
||||
picture.WriteString(mime)
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(32))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len(image)))
|
||||
picture.Write(image)
|
||||
return picture.Bytes()
|
||||
}
|
||||
@@ -9,15 +9,23 @@ import (
|
||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||
|
||||
// ErrExtensionRequestCancelled is returned when a UI-driven extension request
|
||||
// is superseded by a newer home/search request.
|
||||
var ErrExtensionRequestCancelled = errors.New("extension request cancelled")
|
||||
|
||||
type cancelEntry struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
canceled bool
|
||||
refs int
|
||||
}
|
||||
|
||||
var (
|
||||
cancelMu sync.Mutex
|
||||
cancelMap = make(map[string]*cancelEntry)
|
||||
|
||||
extensionRequestCancelMu sync.Mutex
|
||||
extensionRequestCancelMap = make(map[string]*cancelEntry)
|
||||
)
|
||||
|
||||
func initDownloadCancel(itemID string) context.Context {
|
||||
@@ -37,6 +45,7 @@ func initDownloadCancel(itemID string) context.Context {
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
entry.refs++
|
||||
return entry.ctx
|
||||
}
|
||||
|
||||
@@ -45,6 +54,7 @@ func initDownloadCancel(itemID string) context.Context {
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
refs: 1,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -87,6 +97,86 @@ func clearDownloadCancel(itemID string) {
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
delete(cancelMap, itemID)
|
||||
if entry, ok := cancelMap[itemID]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(cancelMap, itemID)
|
||||
}
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
}
|
||||
|
||||
func initExtensionRequestCancel(requestID string) context.Context {
|
||||
if requestID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
defer extensionRequestCancelMu.Unlock()
|
||||
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; 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()
|
||||
}
|
||||
}
|
||||
entry.refs++
|
||||
return entry.ctx
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
extensionRequestCancelMap[requestID] = &cancelEntry{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
refs: 1,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func cancelExtensionRequest(requestID string) {
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true}
|
||||
}
|
||||
extensionRequestCancelMu.Unlock()
|
||||
}
|
||||
|
||||
func isExtensionRequestCancelled(requestID string) bool {
|
||||
if requestID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
entry, ok := extensionRequestCancelMap[requestID]
|
||||
canceled := ok && entry.canceled
|
||||
extensionRequestCancelMu.Unlock()
|
||||
return canceled
|
||||
}
|
||||
|
||||
func clearExtensionRequestCancel(requestID string) {
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(extensionRequestCancelMap, requestID)
|
||||
}
|
||||
}
|
||||
extensionRequestCancelMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||
|
||||
var qobuzSizeRegex = regexp.MustCompile(`_\d+\.jpg$`)
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
@@ -135,7 +137,7 @@ func upgradeQobuzCover(coverURL string) string {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||
upgraded := qobuzSizeRegex.ReplaceAllString(coverURL, "_max.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestLoadedExtension(t *testing.T, types ...ExtensionType) *loadedExtension {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.js"), []byte(testExtensionJS), 0600); err != nil {
|
||||
t.Fatalf("write index.js: %v", err)
|
||||
}
|
||||
return &loadedExtension{
|
||||
ID: "coverage-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "coverage-ext",
|
||||
Description: "Coverage extension",
|
||||
Version: "1.0.0",
|
||||
Types: types,
|
||||
Permissions: ExtensionPermissions{File: true, Network: []string{"example.test"}},
|
||||
SearchBehavior: &SearchBehaviorConfig{
|
||||
Enabled: true,
|
||||
Placeholder: "Search coverage",
|
||||
Primary: true,
|
||||
Icon: "search",
|
||||
},
|
||||
URLHandler: &URLHandlerConfig{Enabled: true, Patterns: []string{"https://example.test/"}},
|
||||
TrackMatching: &TrackMatchingConfig{CustomMatching: true},
|
||||
PostProcessing: &PostProcessingConfig{
|
||||
Enabled: true,
|
||||
Hooks: []PostProcessingHook{{ID: "hook", Name: "Hook", DefaultEnabled: true, SupportedFormats: []string{"flac"}}},
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
SourceDir: dir,
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
}
|
||||
|
||||
const testExtensionJS = `
|
||||
function track(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Track " + id,
|
||||
artists: "Artist",
|
||||
albumName: "Album",
|
||||
albumArtist: "Album Artist",
|
||||
durationMs: 180000,
|
||||
coverUrl: "https://example.test/cover.jpg",
|
||||
releaseDate: "2026-05-04",
|
||||
trackNumber: 1,
|
||||
totalTracks: 10,
|
||||
discNumber: 1,
|
||||
totalDiscs: 1,
|
||||
isrc: "USRC17607839",
|
||||
itemType: "track",
|
||||
albumType: "album",
|
||||
tidalId: "tidal-1",
|
||||
qobuzId: "qobuz-1",
|
||||
deezerId: "deezer-1",
|
||||
spotifyId: "spotify:track:1",
|
||||
externalLinks: { tidal: "https://tidal.example/1" },
|
||||
label: "Label",
|
||||
copyright: "Copyright",
|
||||
genre: "Pop",
|
||||
composer: "Composer",
|
||||
audioQuality: "FLAC 24-bit",
|
||||
audioModes: "DOLBY_ATMOS"
|
||||
};
|
||||
}
|
||||
|
||||
registerExtension({
|
||||
searchTracks: function(query, limit) {
|
||||
return { tracks: [track("search-1")], total: 1 };
|
||||
},
|
||||
customSearch: function(query, options) {
|
||||
var t = track("custom-1");
|
||||
t.name = "Custom " + query;
|
||||
return [t];
|
||||
},
|
||||
getHomeFeed: function() {
|
||||
return [{ id: "home-1", title: "Home", tracks: [track("home-track")] }];
|
||||
},
|
||||
getBrowseCategories: function() {
|
||||
return [{ id: "cat-1", title: "Category" }];
|
||||
},
|
||||
getTrack: function(id) {
|
||||
return track(id);
|
||||
},
|
||||
getAlbum: function(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Album " + id,
|
||||
artists: "Artist",
|
||||
artistId: "artist-1",
|
||||
coverUrl: "https://example.test/album.jpg",
|
||||
releaseDate: "2026-05-04",
|
||||
totalTracks: 1,
|
||||
albumType: "album",
|
||||
tracks: [track("album-track")]
|
||||
};
|
||||
},
|
||||
getPlaylist: function(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Playlist " + id,
|
||||
artists: "Owner",
|
||||
coverUrl: "https://example.test/playlist.jpg",
|
||||
totalTracks: 1,
|
||||
tracks: [track("playlist-track")]
|
||||
};
|
||||
},
|
||||
getArtist: function(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Artist",
|
||||
imageUrl: "https://example.test/artist.jpg",
|
||||
headerImage: "https://example.test/header.jpg",
|
||||
listeners: 123,
|
||||
albums: [{ id: "album-1", name: "Album", artists: "Artist", totalTracks: 1 }],
|
||||
releases: [{ id: "release-1", name: "Release", artists: "Artist", totalTracks: 1, tracks: [track("release-track")] }],
|
||||
topTracks: [track("top-track")]
|
||||
};
|
||||
},
|
||||
enrichTrack: function(input) {
|
||||
var t = track(input.id || "enriched");
|
||||
t.name = "Enriched";
|
||||
return t;
|
||||
},
|
||||
checkAvailability: function(isrc, name, artist, ids) {
|
||||
return { available: true, reason: "ok", trackId: "download-track", skipFallback: true };
|
||||
},
|
||||
getDownloadUrl: function(id, quality) {
|
||||
return { url: "https://example.test/audio.flac", format: "flac", bitDepth: 24, sampleRate: 96000 };
|
||||
},
|
||||
download: function(id, quality, outputPath, onProgress) {
|
||||
if (onProgress) onProgress(100);
|
||||
return {
|
||||
success: true,
|
||||
filePath: "EXISTS:" + outputPath,
|
||||
alreadyExists: false,
|
||||
bitDepth: 24,
|
||||
sampleRate: 96000,
|
||||
title: "Downloaded",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
albumArtist: "Album Artist",
|
||||
trackNumber: 1,
|
||||
totalTracks: 10,
|
||||
discNumber: 1,
|
||||
totalDiscs: 1,
|
||||
releaseDate: "2026-05-04",
|
||||
coverUrl: "https://example.test/cover.jpg",
|
||||
isrc: "USRC17607839",
|
||||
genre: "Pop",
|
||||
label: "Label",
|
||||
copyright: "Copyright",
|
||||
composer: "Composer",
|
||||
lyricsLrc: "[00:00.00]Hello",
|
||||
decryptionKey: "001122",
|
||||
decryption: { strategy: "mp4_decryption_key", options: { kid: "1" } }
|
||||
};
|
||||
},
|
||||
fetchLyrics: function(name, artist, album, duration) {
|
||||
return { syncType: "LINE_SYNCED", provider: "coverage-ext", lines: [{ startTimeMs: 0, endTimeMs: 1000, words: "Hello" }] };
|
||||
},
|
||||
handleUrl: function(url) {
|
||||
return { type: "track", name: "Handled", coverUrl: "https://example.test/cover.jpg", track: track("url-track"), tracks: [track("url-track")], album: this.getAlbum("url-album"), artist: this.getArtist("url-artist") };
|
||||
},
|
||||
matchTrack: function(req) {
|
||||
return { matched: true, trackId: "download-track", confidence: 0.95, reason: "exact" };
|
||||
},
|
||||
postProcess: function(path, req) {
|
||||
return { success: true, newFilePath: path, bitDepth: 24, sampleRate: 96000 };
|
||||
},
|
||||
postProcessV2: function(input, metadata, hookId) {
|
||||
return { success: true, newFilePath: input.path || input.uri, newFileUri: input.uri || "", bitDepth: 24, sampleRate: 96000 };
|
||||
}
|
||||
});
|
||||
`
|
||||
|
||||
func mustReadFile(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read file: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func buildID3v23Tag(frames ...[]byte) []byte {
|
||||
body := bytes.Join(frames, nil)
|
||||
header := []byte{'I', 'D', '3', 3, 0, 0, 0, 0, 0, 0}
|
||||
copy(header[6:10], syncsafeBytes(len(body)))
|
||||
return append(header, body...)
|
||||
}
|
||||
|
||||
func id3TextFrame(id, value string) []byte {
|
||||
return id3v23Frame(id, append([]byte{3}, []byte(value)...))
|
||||
}
|
||||
|
||||
func id3CommentFrame(id, value string) []byte {
|
||||
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
|
||||
return id3v23Frame(id, payload)
|
||||
}
|
||||
|
||||
func id3UserTextFrame(id, desc, value string) []byte {
|
||||
payload := append([]byte{3}, []byte(desc)...)
|
||||
payload = append(payload, 0)
|
||||
payload = append(payload, []byte(value)...)
|
||||
return id3v23Frame(id, payload)
|
||||
}
|
||||
|
||||
func id3v23Frame(id string, payload []byte) []byte {
|
||||
frame := make([]byte, 10+len(payload))
|
||||
copy(frame[0:4], id)
|
||||
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
|
||||
copy(frame[10:], payload)
|
||||
return frame
|
||||
}
|
||||
|
||||
func buildID3v22Tag(frames ...[]byte) []byte {
|
||||
body := bytes.Join(frames, nil)
|
||||
header := []byte{'I', 'D', '3', 2, 0, 0, 0, 0, 0, 0}
|
||||
copy(header[6:10], syncsafeBytes(len(body)))
|
||||
return append(header, body...)
|
||||
}
|
||||
|
||||
func id3v22TextFrame(id, value string) []byte {
|
||||
return id3v22Frame(id, append([]byte{3}, []byte(value)...))
|
||||
}
|
||||
|
||||
func id3v22CommentFrame(id, value string) []byte {
|
||||
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
|
||||
return id3v22Frame(id, payload)
|
||||
}
|
||||
|
||||
func id3v22Frame(id string, payload []byte) []byte {
|
||||
frame := make([]byte, 6+len(payload))
|
||||
copy(frame[0:3], id)
|
||||
size := len(payload)
|
||||
frame[3] = byte(size >> 16)
|
||||
frame[4] = byte(size >> 8)
|
||||
frame[5] = byte(size)
|
||||
copy(frame[6:], payload)
|
||||
return frame
|
||||
}
|
||||
|
||||
func syncsafeBytes(size int) []byte {
|
||||
return []byte{
|
||||
byte((size >> 21) & 0x7f),
|
||||
byte((size >> 14) & 0x7f),
|
||||
byte((size >> 7) & 0x7f),
|
||||
byte(size & 0x7f),
|
||||
}
|
||||
}
|
||||
|
||||
func buildID3v1Tag(title, artist, album, year string, track, genre byte) []byte {
|
||||
tag := make([]byte, 128)
|
||||
copy(tag[0:3], "TAG")
|
||||
copyPadded(tag[3:33], title)
|
||||
copyPadded(tag[33:63], artist)
|
||||
copyPadded(tag[63:93], album)
|
||||
copyPadded(tag[93:97], year)
|
||||
tag[125] = 0
|
||||
tag[126] = track
|
||||
tag[127] = genre
|
||||
return tag
|
||||
}
|
||||
|
||||
func copyPadded(dst []byte, value string) {
|
||||
for i := range dst {
|
||||
dst[i] = ' '
|
||||
}
|
||||
copy(dst, value)
|
||||
}
|
||||
|
||||
func writeExportCueFixture(t *testing.T, dir string) (string, string) {
|
||||
t.Helper()
|
||||
audioPath := filepath.Join(dir, "exports.wav")
|
||||
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatalf("write export audio: %v", err)
|
||||
}
|
||||
cuePath := filepath.Join(dir, "exports.cue")
|
||||
cue := "PERFORMER \"Artist\"\nTITLE \"Album\"\nFILE \"exports.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"Song\"\n INDEX 01 00:00:00\n"
|
||||
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
|
||||
t.Fatalf("write export cue: %v", err)
|
||||
}
|
||||
return cuePath, audioPath
|
||||
}
|
||||
|
||||
func escapeJSONPath(path string) string {
|
||||
data, _ := json.Marshal(path)
|
||||
return strings.Trim(string(data), `"`)
|
||||
}
|
||||
|
||||
func fakeDeezerResponse(path, rawQuery string) string {
|
||||
switch {
|
||||
case path == "/2.0/search/track":
|
||||
if strings.Contains(rawQuery, "MISSING") {
|
||||
return `{"data":[]}`
|
||||
}
|
||||
return `{"data":[` + fakeDeezerTrackJSON(101, true) + `]}`
|
||||
case path == "/2.0/search/artist":
|
||||
return `{"data":[{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123}]}`
|
||||
case path == "/2.0/search/album":
|
||||
return `{"data":[{"id":201,"title":"Album","cover_xl":"album-xl","nb_tracks":2,"release_date":"2026-05-04","record_type":"compile","artist":{"id":301,"name":"Artist"}}]}`
|
||||
case path == "/2.0/search/playlist":
|
||||
return `{"data":[{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"user":{"name":"Owner"}}]}`
|
||||
case path == "/2.0/track/101", path == "/2.0/track/isrc:USRC17607839":
|
||||
return fakeDeezerTrackJSON(101, true)
|
||||
case path == "/2.0/track/102":
|
||||
return fakeDeezerTrackJSON(102, true)
|
||||
case path == "/2.0/track/isrc:MISSING":
|
||||
return `{"id":0}`
|
||||
case path == "/2.0/album/201":
|
||||
return `{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","nb_tracks":2,"record_type":"compile","label":"Label","copyright":"Copyright","genres":{"data":[{"name":"Pop"},{"name":"Dance"}]},"artist":{"id":301,"name":"Album Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
|
||||
case path == "/2.0/artist/301":
|
||||
return `{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123,"nb_album":1}`
|
||||
case path == "/2.0/artist/301/albums":
|
||||
return `{"data":[{"id":201,"title":"Album","release_date":"2026-05-04","nb_tracks":0,"cover_xl":"album-xl","record_type":"compile"}]}`
|
||||
case path == "/2.0/artist/301/related":
|
||||
return `{"data":[{"id":302,"name":"Related","picture_xl":"related-xl","nb_fan":10}]}`
|
||||
case path == "/2.0/playlist/401":
|
||||
return `{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"creator":{"name":"Owner"},"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func fakeDeezerTrackJSON(id int, withISRC bool) string {
|
||||
isrc := ""
|
||||
if withISRC {
|
||||
isrc = `,"isrc":"USRC17607839"`
|
||||
if id == 102 {
|
||||
isrc = `,"isrc":"USRC17607840"`
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(`{"id":%d,"title":"Track %d","duration":180,"track_position":%d,"disk_number":1%s,"link":"https://deezer.test/track/%d","release_date":"2026-05-04","artist":{"id":301,"name":"Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"album":{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","record_type":"album"}}`, id, id, id-100, isrc, id)
|
||||
}
|
||||
|
||||
func createTestExtensionPackage(t *testing.T, path, name, version, js string, extraFiles map[string]string) {
|
||||
t.Helper()
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create extension package: %v", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
zw := zip.NewWriter(out)
|
||||
defer zw.Close()
|
||||
|
||||
manifest := fmt.Sprintf(`{
|
||||
"name": %q,
|
||||
"displayName": %q,
|
||||
"version": %q,
|
||||
"description": "Packaged test extension",
|
||||
"type": ["metadata_provider", "download_provider", "lyrics_provider"],
|
||||
"permissions": {"network": ["example.test"], "storage": true, "file": true},
|
||||
"icon": "icon.png",
|
||||
"settings": [{"key":"quality","type":"string","label":"Quality"}],
|
||||
"qualityOptions": [{"id":"lossless","label":"Lossless","description":"Lossless"}],
|
||||
"searchBehavior": {"enabled": true, "placeholder": "Search", "primary": true},
|
||||
"urlHandler": {"enabled": true, "patterns": ["https://example.test/"]},
|
||||
"trackMatching": {"customMatching": true},
|
||||
"postProcessing": {"enabled": true, "hooks": [{"id":"hook","name":"Hook"}]},
|
||||
"serviceHealth": [{"id":"main","url":"https://example.test/health"}],
|
||||
"capabilities": {"homeFeed": true}
|
||||
}`, name, name, version)
|
||||
|
||||
for fileName, content := range map[string]string{
|
||||
"manifest.json": manifest,
|
||||
"index.js": js,
|
||||
"icon.png": "png",
|
||||
} {
|
||||
writer, err := zw.Create(fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", fileName, err)
|
||||
}
|
||||
if _, err := writer.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("zip write %s: %v", fileName, err)
|
||||
}
|
||||
}
|
||||
for fileName, content := range extraFiles {
|
||||
writer, err := zw.Create(fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create extra %s: %v", fileName, err)
|
||||
}
|
||||
if _, err := writer.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("zip write extra %s: %v", fileName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCueParserEndToEnd(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
audioPath := filepath.Join(dir, "album.wav")
|
||||
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatalf("write audio: %v", err)
|
||||
}
|
||||
cuePath := filepath.Join(dir, "album.cue")
|
||||
cue := "\ufeffREM GENRE \"Pop\"\n" +
|
||||
"REM DATE 2026\n" +
|
||||
"REM COMMENT \"comment\"\n" +
|
||||
"REM COMPOSER \"Album Composer\"\n" +
|
||||
"PERFORMER \"Album Artist\"\n" +
|
||||
"TITLE \"Album Title\"\n" +
|
||||
"FILE \"album.wav\" WAVE\n" +
|
||||
" TRACK 01 AUDIO\n" +
|
||||
" TITLE \"First\"\n" +
|
||||
" PERFORMER \"Track Artist\"\n" +
|
||||
" ISRC USRC17607839\n" +
|
||||
" INDEX 01 00:00:00\n" +
|
||||
" TRACK 02 AUDIO\n" +
|
||||
" TITLE \"Second\"\n" +
|
||||
" SONGWRITER \"Track Composer\"\n" +
|
||||
" INDEX 00 03:00:00\n" +
|
||||
" INDEX 01 03:05:00\n"
|
||||
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
|
||||
t.Fatalf("write cue: %v", err)
|
||||
}
|
||||
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCueFile: %v", err)
|
||||
}
|
||||
if sheet.Performer != "Album Artist" || sheet.Title != "Album Title" || len(sheet.Tracks) != 2 {
|
||||
t.Fatalf("sheet = %#v", sheet)
|
||||
}
|
||||
if got := parseCueTimestamp("01:02:37"); got <= 62 || got >= 63 {
|
||||
t.Fatalf("timestamp = %f", got)
|
||||
}
|
||||
if got := formatCueTimestamp(3723.5); got != "01:02:03.500" {
|
||||
t.Fatalf("format timestamp = %q", got)
|
||||
}
|
||||
if got := unquoteCue(" \"quoted\" "); got != "quoted" {
|
||||
t.Fatalf("unquote = %q", got)
|
||||
}
|
||||
fileName, fileType := parseCueFileLine("unquoted album.flac FLAC")
|
||||
if fileName != "unquoted album.flac" || fileType != "FLAC" {
|
||||
t.Fatalf("file line = %q/%q", fileName, fileType)
|
||||
}
|
||||
|
||||
if resolved := ResolveCueAudioPath(cuePath, "album.flac"); resolved != audioPath {
|
||||
t.Fatalf("resolved = %q want %q", resolved, audioPath)
|
||||
}
|
||||
info, err := BuildCueSplitInfo(cuePath, sheet, "")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCueSplitInfo: %v", err)
|
||||
}
|
||||
if info.Tracks[0].EndSec != 180 || info.Tracks[1].Composer != "Track Composer" {
|
||||
t.Fatalf("split info = %#v", info.Tracks)
|
||||
}
|
||||
|
||||
jsonText, err := ParseCueFileJSON(cuePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCueFileJSON: %v", err)
|
||||
}
|
||||
var decoded CueSplitInfo
|
||||
if err := json.Unmarshal([]byte(jsonText), &decoded); err != nil {
|
||||
t.Fatalf("decode cue json: %v", err)
|
||||
}
|
||||
if decoded.AudioPath != audioPath {
|
||||
t.Fatalf("decoded audio path = %q", decoded.AudioPath)
|
||||
}
|
||||
|
||||
results, err := ScanCueFileForLibraryExt(cuePath, "", "virtual/album.cue", 1234, "scan-time")
|
||||
if err != nil {
|
||||
t.Fatalf("ScanCueFileForLibraryExt: %v", err)
|
||||
}
|
||||
if len(results) != 2 || results[0].TrackName != "First" || results[0].Duration != 180 {
|
||||
t.Fatalf("scan results = %#v", results)
|
||||
}
|
||||
if results[0].FilePath != "virtual/album.cue#track01" || results[0].Format != "cue+wav" {
|
||||
t.Fatalf("scan path/format = %q/%q", results[0].FilePath, results[0].Format)
|
||||
}
|
||||
|
||||
if _, err := ParseCueFile(filepath.Join(dir, "missing.cue")); err == nil {
|
||||
t.Fatal("expected missing cue error")
|
||||
}
|
||||
emptyCue := filepath.Join(dir, "empty.cue")
|
||||
if err := os.WriteFile(emptyCue, []byte("TITLE \"No tracks\""), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseCueFile(emptyCue); err == nil {
|
||||
t.Fatal("expected no tracks error")
|
||||
}
|
||||
missingDir := t.TempDir()
|
||||
missingCuePath := filepath.Join(missingDir, "missing.cue")
|
||||
if err := os.WriteFile(missingCuePath, []byte(cue), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := BuildCueSplitInfo(missingCuePath, &CueSheet{FileName: "missing.wav"}, ""); err == nil {
|
||||
t.Fatal("expected missing audio error")
|
||||
}
|
||||
if _, err := resolveCueAudioPathForLibrary(cuePath, nil, ""); err == nil {
|
||||
t.Fatal("expected nil sheet error")
|
||||
}
|
||||
if _, err := scanCueSheetForLibrary(cuePath, nil, audioPath, "", 0, "", ""); err == nil {
|
||||
t.Fatal("expected nil scan sheet error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "song.flac")
|
||||
if err := os.WriteFile(filePath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir, buildTime: time.Now()}
|
||||
idx.Add("usrc17607839", filePath)
|
||||
if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath {
|
||||
t.Fatalf("lookup = %q/%v", got, ok)
|
||||
}
|
||||
if got, err := idx.Lookup("usrc17607839"); err != nil || got != filePath {
|
||||
t.Fatalf("Lookup = %q/%v", got, err)
|
||||
}
|
||||
idx.remove("usrc17607839")
|
||||
if _, ok := idx.lookup("usrc17607839"); ok {
|
||||
t.Fatal("expected removed ISRC")
|
||||
}
|
||||
|
||||
isrcIndexCacheMu.Lock()
|
||||
isrcIndexCache[dir] = idx
|
||||
isrcIndexCacheMu.Unlock()
|
||||
defer InvalidateISRCCache(dir)
|
||||
|
||||
AddToISRCIndex(dir, "USRC17607839", filePath)
|
||||
if found, err := CheckISRCExists(dir, "USRC17607839"); err != nil || found != filePath {
|
||||
t.Fatalf("CheckISRCExists = %q/%v", found, err)
|
||||
}
|
||||
if !CheckFileExists(filePath) || CheckFileExists(dir) || CheckFileExists(filepath.Join(dir, "missing.flac")) {
|
||||
t.Fatal("unexpected file existence result")
|
||||
}
|
||||
|
||||
tracksJSON := `[{"isrc":"USRC17607839","track_name":"Song","artist_name":"Artist"},{"isrc":"MISSING","track_name":"Other","artist_name":"Artist"}]`
|
||||
resultJSON, err := CheckFilesExistParallel(dir, tracksJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFilesExistParallel: %v", err)
|
||||
}
|
||||
var results []FileExistenceResult
|
||||
if err := json.Unmarshal([]byte(resultJSON), &results); err != nil {
|
||||
t.Fatalf("decode results: %v", err)
|
||||
}
|
||||
if !results[0].Exists || results[0].FilePath != filePath || results[1].Exists {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if _, err := CheckFilesExistParallel(dir, `not-json`); err == nil {
|
||||
t.Fatal("expected invalid json error")
|
||||
}
|
||||
if err := PreBuildISRCIndex(""); err == nil {
|
||||
t.Fatal("expected empty dir error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDeezerClientWithFakeHTTP(t *testing.T) {
|
||||
client := &DeezerClient{
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||
status := http.StatusOK
|
||||
if body == "" {
|
||||
status = http.StatusNotFound
|
||||
body = `{"error":"missing"}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})},
|
||||
searchCache: map[string]*cacheEntry{},
|
||||
albumCache: map[string]*cacheEntry{},
|
||||
artistCache: map[string]*cacheEntry{},
|
||||
isrcCache: map[string]string{},
|
||||
cacheCleanupInterval: time.Millisecond,
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
search, err := client.SearchAll(ctx, "artist song", 2, 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchAll: %v", err)
|
||||
}
|
||||
if len(search.Tracks) != 1 || len(search.Artists) != 1 || len(search.Albums) != 1 || len(search.Playlists) != 1 {
|
||||
t.Fatalf("search = %#v", search)
|
||||
}
|
||||
cached, err := client.SearchAll(ctx, "artist song", 2, 2, "")
|
||||
if err != nil || cached != search {
|
||||
t.Fatalf("cached SearchAll = %#v/%v", cached, err)
|
||||
}
|
||||
if filtered, err := client.SearchAll(ctx, "artist song", 1, 1, "track"); err != nil || len(filtered.Tracks) != 1 || len(filtered.Artists) != 0 {
|
||||
t.Fatalf("filtered search = %#v/%v", filtered, err)
|
||||
}
|
||||
|
||||
track, err := client.GetTrack(ctx, "101")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrack: %v", err)
|
||||
}
|
||||
if track.Track.SpotifyID != "deezer:101" || track.Track.Artists != "Contributor A, Contributor B" {
|
||||
t.Fatalf("track = %#v", track)
|
||||
}
|
||||
|
||||
album, err := client.GetAlbum(ctx, "201")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbum: %v", err)
|
||||
}
|
||||
if album.AlbumInfo.Name != "Album" || len(album.TrackList) != 2 || album.TrackList[1].ISRC == "" {
|
||||
t.Fatalf("album = %#v", album)
|
||||
}
|
||||
if cachedAlbum, err := client.GetAlbum(ctx, "201"); err != nil || cachedAlbum != album {
|
||||
t.Fatalf("cached album = %#v/%v", cachedAlbum, err)
|
||||
}
|
||||
|
||||
artist, err := client.GetArtist(ctx, "301")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtist: %v", err)
|
||||
}
|
||||
if artist.ArtistInfo.Name != "Artist" || len(artist.Albums) != 1 || artist.Albums[0].TotalTracks == 0 {
|
||||
t.Fatalf("artist = %#v", artist)
|
||||
}
|
||||
if cachedArtist, err := client.GetArtist(ctx, "301"); err != nil || cachedArtist != artist {
|
||||
t.Fatalf("cached artist = %#v/%v", cachedArtist, err)
|
||||
}
|
||||
|
||||
related, err := client.GetRelatedArtists(ctx, "deezer:301", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRelatedArtists: %v", err)
|
||||
}
|
||||
if len(related) != 1 || related[0].ID != "deezer:302" {
|
||||
t.Fatalf("related = %#v", related)
|
||||
}
|
||||
if _, err := client.GetRelatedArtists(ctx, "", 0); err == nil {
|
||||
t.Fatal("expected invalid related artist ID")
|
||||
}
|
||||
|
||||
playlist, err := client.GetPlaylist(ctx, "401")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlaylist: %v", err)
|
||||
}
|
||||
if playlist.PlaylistInfo.Tracks.Total != 2 || len(playlist.TrackList) != 2 {
|
||||
t.Fatalf("playlist = %#v", playlist)
|
||||
}
|
||||
|
||||
byISRC, err := client.SearchByISRC(ctx, "USRC17607839")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchByISRC: %v", err)
|
||||
}
|
||||
if byISRC.SpotifyID != "deezer:101" {
|
||||
t.Fatalf("by ISRC = %#v", byISRC)
|
||||
}
|
||||
if _, err := client.SearchByISRC(ctx, "MISSING"); err == nil {
|
||||
t.Fatal("expected missing ISRC error")
|
||||
}
|
||||
|
||||
isrc, err := client.GetTrackISRC(ctx, "102")
|
||||
if err != nil || isrc != "USRC17607840" {
|
||||
t.Fatalf("GetTrackISRC = %q/%v", isrc, err)
|
||||
}
|
||||
albumID, err := client.GetTrackAlbumID(ctx, "101")
|
||||
if err != nil || albumID != "201" {
|
||||
t.Fatalf("GetTrackAlbumID = %q/%v", albumID, err)
|
||||
}
|
||||
extended, err := client.GetAlbumExtendedMetadata(ctx, "201")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbumExtendedMetadata: %v", err)
|
||||
}
|
||||
if extended.Genre != "Pop, Dance" || extended.Label != "Label" {
|
||||
t.Fatalf("extended = %#v", extended)
|
||||
}
|
||||
if byTrack, err := client.GetExtendedMetadataByTrackID(ctx, "101"); err != nil || byTrack.Label != "Label" {
|
||||
t.Fatalf("metadata by track = %#v/%v", byTrack, err)
|
||||
}
|
||||
if byISRCMeta, err := client.GetExtendedMetadataByISRC(ctx, "USRC17607839"); err != nil || byISRCMeta.Label != "Label" {
|
||||
t.Fatalf("metadata by isrc = %#v/%v", byISRCMeta, err)
|
||||
}
|
||||
if _, err := client.GetExtendedMetadataByISRC(ctx, ""); err == nil {
|
||||
t.Fatal("expected empty ISRC metadata error")
|
||||
}
|
||||
|
||||
if typ, id, err := parseDeezerURL("https://www.deezer.com/us/track/101"); err != nil || typ != "track" || id != "101" {
|
||||
t.Fatalf("parseDeezerURL = %q/%q/%v", typ, id, err)
|
||||
}
|
||||
if _, _, err := parseDeezerURL("https://example.com/track/101"); err == nil {
|
||||
t.Fatal("expected non-Deezer URL error")
|
||||
}
|
||||
|
||||
client.cacheMu.Lock()
|
||||
client.searchCache["expired"] = &cacheEntry{expiresAt: time.Now().Add(-time.Hour)}
|
||||
client.searchCache["keep1"] = &cacheEntry{expiresAt: time.Now().Add(time.Hour)}
|
||||
client.searchCache["keep2"] = &cacheEntry{expiresAt: time.Now().Add(2 * time.Hour)}
|
||||
client.pruneExpiredCacheEntriesLocked(client.searchCache, time.Now())
|
||||
client.trimCacheEntriesLocked(client.searchCache, 1)
|
||||
client.isrcCache["1"] = "A"
|
||||
client.isrcCache["2"] = "B"
|
||||
client.trimStringCacheEntriesLocked(client.isrcCache, 1)
|
||||
client.cacheMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtensionPackageExportWrappers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
extensionsDir := filepath.Join(dir, "extensions")
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
if err := InitExtensionSystem(extensionsDir, dataDir); err != nil {
|
||||
t.Fatalf("InitExtensionSystem: %v", err)
|
||||
}
|
||||
CleanupExtensions()
|
||||
defer CleanupExtensions()
|
||||
|
||||
js := `
|
||||
registerExtension({
|
||||
initialize: function(settings) { this.settings = settings || {}; },
|
||||
cleanup: function() {},
|
||||
doAction: function() { return { message: "wrapped", setting_updates: { quality: "lossless" } }; },
|
||||
searchTracks: function() { return { tracks: [], total: 0 }; },
|
||||
fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; },
|
||||
getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; }
|
||||
});
|
||||
`
|
||||
pkgV1 := filepath.Join(dir, "wrapper-ext-v1.spotiflac-ext")
|
||||
pkgV2 := filepath.Join(dir, "wrapper-ext-v2.spotiflac-ext")
|
||||
createTestExtensionPackage(t, pkgV1, "wrapper-ext", "1.0.0", js, nil)
|
||||
createTestExtensionPackage(t, pkgV2, "wrapper-ext", "1.1.0", js, nil)
|
||||
|
||||
loadedJSON, err := LoadExtensionFromPath(pkgV1)
|
||||
if err != nil || !strings.Contains(loadedJSON, "wrapper-ext") {
|
||||
t.Fatalf("LoadExtensionFromPath = %q/%v", loadedJSON, err)
|
||||
}
|
||||
if installedJSON, err := GetInstalledExtensions(); err != nil || !strings.Contains(installedJSON, "wrapper-ext") {
|
||||
t.Fatalf("GetInstalledExtensions = %q/%v", installedJSON, err)
|
||||
}
|
||||
if err := SetExtensionEnabledByID("wrapper-ext", true); err != nil {
|
||||
t.Fatalf("SetExtensionEnabledByID true: %v", err)
|
||||
}
|
||||
if actionJSON, err := InvokeExtensionActionJSON("wrapper-ext", "doAction"); err != nil || !strings.Contains(actionJSON, "wrapped") {
|
||||
t.Fatalf("InvokeExtensionActionJSON = %q/%v", actionJSON, err)
|
||||
}
|
||||
if upgradeJSON, err := CheckExtensionUpgradeFromPath(pkgV2); err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) {
|
||||
t.Fatalf("CheckExtensionUpgradeFromPath = %q/%v", upgradeJSON, err)
|
||||
}
|
||||
if upgradedJSON, err := UpgradeExtensionFromPath(pkgV2); err != nil || !strings.Contains(upgradedJSON, "1.1.0") {
|
||||
t.Fatalf("UpgradeExtensionFromPath = %q/%v", upgradedJSON, err)
|
||||
}
|
||||
if err := SetExtensionEnabledByID("wrapper-ext", false); err != nil {
|
||||
t.Fatalf("SetExtensionEnabledByID false: %v", err)
|
||||
}
|
||||
if err := UnloadExtensionByID("wrapper-ext"); err != nil {
|
||||
t.Fatalf("UnloadExtensionByID: %v", err)
|
||||
}
|
||||
|
||||
dirExt := filepath.Join(extensionsDir, "wrapper-dir-ext")
|
||||
if err := createDirectoryExtension(dirExt, "wrapper-dir-ext", "1.0.0"); err != nil {
|
||||
t.Fatalf("create directory extension: %v", err)
|
||||
}
|
||||
if loadedDirJSON, err := LoadExtensionsFromDir(extensionsDir); err != nil || !strings.Contains(loadedDirJSON, "wrapper-dir-ext") {
|
||||
t.Fatalf("LoadExtensionsFromDir = %q/%v", loadedDirJSON, err)
|
||||
}
|
||||
if err := RemoveExtensionByID("wrapper-dir-ext"); err != nil {
|
||||
t.Fatalf("RemoveExtensionByID: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createDirectoryExtension(dir, name, version string) error {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
manifest := fmt.Sprintf(`{"name":%q,"displayName":%q,"version":%q,"description":"Directory wrapper extension","type":["metadata_provider"],"permissions":{}}`, name, name, version)
|
||||
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
audioPath := filepath.Join(dir, "sidecar.mp3")
|
||||
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte("[00:00.00]Sidecar lyric"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if jsonText, err := FetchLyrics("spotify-1", "Song Instrumental", "Artist", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
|
||||
t.Fatalf("FetchLyrics instrumental = %q/%v", jsonText, err)
|
||||
}
|
||||
if lrc, err := GetLyricsLRC("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || lrc != "[instrumental:true]" {
|
||||
t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err)
|
||||
}
|
||||
if jsonText, err := GetLyricsLRCWithSource("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
|
||||
t.Fatalf("GetLyricsLRCWithSource instrumental = %q/%v", jsonText, err)
|
||||
}
|
||||
if lrc, err := GetLyricsLRC("", "", "", audioPath, 0); err != nil || !strings.Contains(lrc, "Sidecar lyric") {
|
||||
t.Fatalf("GetLyricsLRC sidecar = %q/%v", lrc, err)
|
||||
}
|
||||
if jsonText, err := GetLyricsLRCWithSource("", "", "", audioPath, 0); err != nil || !strings.Contains(jsonText, "Sidecar lyric") {
|
||||
t.Fatalf("GetLyricsLRCWithSource sidecar = %q/%v", jsonText, err)
|
||||
}
|
||||
|
||||
outPath := filepath.Join(dir, "lyrics.lrc")
|
||||
if err := FetchAndSaveLyrics("Song", "Artist", "", 0, outPath, audioPath); err != nil {
|
||||
t.Fatalf("FetchAndSaveLyrics sidecar: %v", err)
|
||||
}
|
||||
if data := string(mustReadFile(t, outPath)); !strings.Contains(data, "Sidecar lyric") {
|
||||
t.Fatalf("saved lyrics = %q", data)
|
||||
}
|
||||
if response, err := EmbedLyricsToFile(filepath.Join(dir, "not-flac.mp3"), "lyrics"); err != nil || !strings.Contains(response, `"success":false`) {
|
||||
t.Fatalf("EmbedLyricsToFile error = %q/%v", response, err)
|
||||
}
|
||||
if response, err := RewriteSplitArtistTagsExport(filepath.Join(dir, "not-flac.mp3"), "A;B", "A"); err != nil || !strings.Contains(response, `"success":false`) {
|
||||
t.Fatalf("RewriteSplitArtistTagsExport error = %q/%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
||||
origClient := globalSongLinkClient
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
origSearchByISRC := songLinkSearchByISRC
|
||||
origCheckFromDeezer := songLinkCheckAvailabilityFromDeezer
|
||||
defer func() {
|
||||
globalSongLinkClient = origClient
|
||||
songLinkRetryConfig = origRetryConfig
|
||||
songLinkSearchByISRC = origSearchByISRC
|
||||
songLinkCheckAvailabilityFromDeezer = origCheckFromDeezer
|
||||
SetSongLinkNetworkOptions(false, false)
|
||||
}()
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
||||
}
|
||||
globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
var body string
|
||||
if req.URL.Host == "api.zarz.moe" {
|
||||
body = `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/spotify-1","Deezer":"https://www.deezer.com/track/101","Tidal":"https://listen.tidal.com/track/202","YouTube":"https://youtu.be/yt1","AmazonMusic":"https://music.amazon.com/tracks/amz1","Qobuz":"https://open.qobuz.com/track/303"}}`
|
||||
} else if req.URL.Host == "api.song.link" {
|
||||
body = `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}`
|
||||
} else {
|
||||
t.Fatalf("unexpected SongLink request: %s", req.URL.String())
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||
})}}
|
||||
songLinkClientOnce.Do(func() {})
|
||||
|
||||
SetSongLinkNetworkOptions(true, true)
|
||||
if availabilityJSON, err := CheckAvailability("spotify-1", ""); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
|
||||
t.Fatalf("CheckAvailability = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailabilityFromDeezerID("101"); err != nil || !strings.Contains(availabilityJSON, `"spotify_id":"spotify-1"`) {
|
||||
t.Fatalf("CheckAvailabilityFromDeezerID = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailabilityByPlatformID("deezer", "song", "101"); err != nil || !strings.Contains(availabilityJSON, `"tidal_url"`) {
|
||||
t.Fatalf("CheckAvailabilityByPlatformID = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if spotifyID, err := GetSpotifyIDFromDeezerTrack("101"); err != nil || spotifyID != "spotify-1" {
|
||||
t.Fatalf("GetSpotifyIDFromDeezerTrack = %q/%v", spotifyID, err)
|
||||
}
|
||||
if tidalURL, err := GetTidalURLFromDeezerTrack("101"); err != nil || !strings.Contains(tidalURL, "tidal") {
|
||||
t.Fatalf("GetTidalURLFromDeezerTrack = %q/%v", tidalURL, err)
|
||||
}
|
||||
if urls, err := NewSongLinkClient().GetStreamingURLs("spotify-1"); err != nil || urls["tidal"] == "" || urls["amazon"] == "" {
|
||||
t.Fatalf("GetStreamingURLs = %#v/%v", urls, err)
|
||||
}
|
||||
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromSpotify("spotify-1"); err != nil || !strings.Contains(youtubeURL, "youtu") {
|
||||
t.Fatalf("GetYouTubeURLFromSpotify = %q/%v", youtubeURL, err)
|
||||
}
|
||||
if amazonURL, err := NewSongLinkClient().GetAmazonURLFromDeezer("101"); err != nil || !strings.Contains(amazonURL, "amazon") {
|
||||
t.Fatalf("GetAmazonURLFromDeezer = %q/%v", amazonURL, err)
|
||||
}
|
||||
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromDeezer("101"); err != nil || !strings.Contains(youtubeURL, "youtube") {
|
||||
t.Fatalf("GetYouTubeURLFromDeezer = %q/%v", youtubeURL, err)
|
||||
}
|
||||
if deezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify("spotify-1"); err != nil || deezerID != "101" {
|
||||
t.Fatalf("GetDeezerIDFromSpotify = %q/%v", deezerID, err)
|
||||
}
|
||||
if album, err := NewSongLinkClient().CheckAlbumAvailability("album-1"); err != nil || !album.Deezer || album.DeezerID == "" {
|
||||
t.Fatalf("CheckAlbumAvailability = %#v/%v", album, err)
|
||||
}
|
||||
if albumID, err := NewSongLinkClient().GetDeezerAlbumIDFromSpotify("album-1"); err != nil || albumID == "" {
|
||||
t.Fatalf("GetDeezerAlbumIDFromSpotify = %q/%v", albumID, err)
|
||||
}
|
||||
if availability, err := NewSongLinkClient().CheckAvailabilityFromURL("https://www.deezer.com/track/101"); err != nil || !availability.Deezer {
|
||||
t.Fatalf("CheckAvailabilityFromURL = %#v/%v", availability, err)
|
||||
}
|
||||
|
||||
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||
return &TrackMetadata{SpotifyID: "deezer:101", ExternalURL: "https://www.deezer.com/track/101"}, nil
|
||||
}
|
||||
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||
return &TrackAvailability{SpotifyID: "spotify-1", Deezer: true, DeezerID: deezerTrackID}, nil
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailability("", "USRC17607839"); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
|
||||
t.Fatalf("CheckAvailability by ISRC = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if songLinkExtractDeezerTrackID(nil) != "" || songLinkExtractDeezerTrackID(&TrackMetadata{ExternalURL: "https://www.deezer.com/track/202"}) != "202" {
|
||||
t.Fatal("songLinkExtractDeezerTrackID mismatch")
|
||||
}
|
||||
|
||||
deezerClient = &DeezerClient{
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||
if body == "" {
|
||||
body = `{"error":"missing"}`
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||
})},
|
||||
searchCache: map[string]*cacheEntry{},
|
||||
albumCache: map[string]*cacheEntry{},
|
||||
artistCache: map[string]*cacheEntry{},
|
||||
isrcCache: map[string]string{},
|
||||
cacheCleanupInterval: time.Hour,
|
||||
}
|
||||
deezerClientOnce.Do(func() {})
|
||||
if jsonText, err := ConvertSpotifyToDeezer("track", "spotify-1"); err != nil || !strings.Contains(jsonText, `"spotify_id":"deezer:101"`) {
|
||||
t.Fatalf("ConvertSpotifyToDeezer track = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := ConvertSpotifyToDeezer("album", "album-1"); err != nil || jsonText == "" {
|
||||
t.Fatalf("ConvertSpotifyToDeezer album = %q/%v", jsonText, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
extensionsDir := filepath.Join(dir, "extensions")
|
||||
if err := InitExtensionSystem(extensionsDir, dataDir); err != nil {
|
||||
t.Fatalf("InitExtensionSystem: %v", err)
|
||||
}
|
||||
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
if manager.extensions == nil {
|
||||
manager.extensions = map[string]*loadedExtension{}
|
||||
}
|
||||
manager.extensions[ext.ID] = ext
|
||||
manager.mu.Unlock()
|
||||
defer func() {
|
||||
manager.mu.Lock()
|
||||
delete(manager.extensions, ext.ID)
|
||||
manager.mu.Unlock()
|
||||
}()
|
||||
|
||||
if response, err := DownloadTrack(`{}`); err != nil || !strings.Contains(response, "retired") {
|
||||
t.Fatalf("DownloadTrack = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadByStrategy(`not-json`); err != nil || !strings.Contains(response, "Invalid request") {
|
||||
t.Fatalf("DownloadByStrategy invalid = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadByStrategy(`{"use_extensions":false}`); err != nil || !strings.Contains(response, "disabled") {
|
||||
t.Fatalf("DownloadByStrategy disabled = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadWithFallback(`{}`); err != nil || !strings.Contains(response, "retired") {
|
||||
t.Fatalf("DownloadWithFallback = %q/%v", response, err)
|
||||
}
|
||||
|
||||
InitItemProgress("item-1")
|
||||
FinishItemProgress("item-1")
|
||||
ClearItemProgress("item-1")
|
||||
CancelDownload("item-1")
|
||||
if GetDownloadProgress() == "" || GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
|
||||
t.Fatal("expected progress JSON")
|
||||
}
|
||||
CleanupConnections()
|
||||
|
||||
cuePath, audioPath := writeExportCueFixture(t, dir)
|
||||
if jsonText, err := ParseCueSheet(cuePath, ""); err != nil {
|
||||
t.Fatalf("ParseCueSheet = %q/%v", jsonText, err)
|
||||
} else {
|
||||
var parsed CueSplitInfo
|
||||
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||
t.Fatalf("decode ParseCueSheet: %v", err)
|
||||
}
|
||||
if parsed.AudioPath != audioPath {
|
||||
t.Fatalf("ParseCueSheet audio path = %q want %q", parsed.AudioPath, audioPath)
|
||||
}
|
||||
}
|
||||
if jsonText, err := ScanCueSheetForLibrary(cuePath, "", "virtual.cue", 111); err != nil || !strings.Contains(jsonText, "cue+wav") {
|
||||
t.Fatalf("ScanCueSheetForLibrary = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := ScanCueSheetForLibraryWithCoverCacheKey(cuePath, "", "virtual.cue", 111, "cover-key"); err != nil || !strings.Contains(jsonText, "cue+wav") {
|
||||
t.Fatalf("ScanCueSheetForLibraryWithCoverCacheKey = %q/%v", jsonText, err)
|
||||
}
|
||||
|
||||
apePath := filepath.Join(dir, "edit.ape")
|
||||
if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
editJSON := `{"title":"Edited","artist":"Artist","track_number":"1","track_total":"2","disc_number":"1","disc_total":"1"}`
|
||||
if response, err := EditFileMetadata(apePath, editJSON); err != nil || !strings.Contains(response, "native_ape") {
|
||||
t.Fatalf("EditFileMetadata ape = %q/%v", response, err)
|
||||
}
|
||||
if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") {
|
||||
t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err)
|
||||
}
|
||||
if _, err := EditFileMetadata(apePath, `not-json`); err == nil {
|
||||
t.Fatal("expected invalid metadata JSON")
|
||||
}
|
||||
if !hasOnlyM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-1 dB"}) {
|
||||
t.Fatal("expected replaygain-only fields")
|
||||
}
|
||||
if hasOnlyM4AReplayGainFields(map[string]string{"title": "Song"}) {
|
||||
t.Fatal("expected non-replaygain field rejection")
|
||||
}
|
||||
|
||||
AllowDownloadDir(dir)
|
||||
if err := SetDownloadDirectory(dir); err != nil {
|
||||
t.Fatalf("SetDownloadDirectory: %v", err)
|
||||
}
|
||||
if duplicateJSON, err := CheckDuplicate(dir, ""); err != nil || !strings.Contains(duplicateJSON, "exists") {
|
||||
t.Fatalf("CheckDuplicate = %q/%v", duplicateJSON, err)
|
||||
}
|
||||
if batchJSON, err := CheckDuplicatesBatch(dir, `[{"isrc":"","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(batchJSON, "Song") {
|
||||
t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err)
|
||||
}
|
||||
_ = PreBuildDuplicateIndex(dir)
|
||||
InvalidateDuplicateIndex(dir)
|
||||
if filename, err := BuildFilename("{artist} - {title}", `{"artist":"A/B","title":"Song?"}`); err != nil || filename == "" {
|
||||
t.Fatalf("BuildFilename = %q/%v", filename, err)
|
||||
}
|
||||
if _, err := BuildFilename("{title}", `not-json`); err == nil {
|
||||
t.Fatal("expected BuildFilename JSON error")
|
||||
}
|
||||
if got := SanitizeFilename(`A/B:C*D?`); strings.ContainsAny(got, `/:*?`) {
|
||||
t.Fatalf("SanitizeFilename = %q", got)
|
||||
}
|
||||
|
||||
if response, err := PreWarmTrackCacheJSON(`not-json`); err != nil || !strings.Contains(response, "Invalid JSON") {
|
||||
t.Fatalf("PreWarmTrackCacheJSON invalid = %q/%v", response, err)
|
||||
}
|
||||
if response, err := PreWarmTrackCacheJSON(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(response, "success") {
|
||||
t.Fatalf("PreWarmTrackCacheJSON = %q/%v", response, err)
|
||||
}
|
||||
if GetTrackCacheSize() != 0 {
|
||||
t.Fatal("expected empty track cache")
|
||||
}
|
||||
ClearTrackIDCache()
|
||||
|
||||
if err := SetLyricsProvidersJSON(`["lrclib","apple_music"]`); err != nil {
|
||||
t.Fatalf("SetLyricsProvidersJSON: %v", err)
|
||||
}
|
||||
if providers, err := GetLyricsProvidersJSON(); err != nil || !strings.Contains(providers, "lrclib") {
|
||||
t.Fatalf("GetLyricsProvidersJSON = %q/%v", providers, err)
|
||||
}
|
||||
if available, err := GetAvailableLyricsProvidersJSON(); err != nil || available == "" {
|
||||
t.Fatalf("GetAvailableLyricsProvidersJSON = %q/%v", available, err)
|
||||
}
|
||||
if err := SetLyricsFetchOptionsJSON(`{"include_translation_netease":true}`); err != nil {
|
||||
t.Fatalf("SetLyricsFetchOptionsJSON: %v", err)
|
||||
}
|
||||
if opts, err := GetLyricsFetchOptionsJSON(); err != nil || opts == "" {
|
||||
t.Fatalf("GetLyricsFetchOptionsJSON = %q/%v", opts, err)
|
||||
}
|
||||
|
||||
if err := SetProviderPriorityJSON(`["coverage-ext"]`); err != nil {
|
||||
t.Fatalf("SetProviderPriorityJSON: %v", err)
|
||||
}
|
||||
if jsonText, err := GetProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||
t.Fatalf("GetProviderPriorityJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if err := SetExtensionFallbackProviderIDsJSON(`["coverage-ext"]`); err != nil {
|
||||
t.Fatalf("SetExtensionFallbackProviderIDsJSON: %v", err)
|
||||
}
|
||||
if jsonText, err := GetExtensionFallbackProviderIDsJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||
t.Fatalf("GetExtensionFallbackProviderIDsJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||
t.Fatalf("reset extension fallback IDs: %v", err)
|
||||
}
|
||||
if err := SetMetadataProviderPriorityJSON(`["coverage-ext"]`); err != nil {
|
||||
t.Fatalf("SetMetadataProviderPriorityJSON: %v", err)
|
||||
}
|
||||
if jsonText, err := GetMetadataProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||
t.Fatalf("GetMetadataProviderPriorityJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
|
||||
if err := SetExtensionSettingsJSON(ext.ID, `{"quality":"lossless","_secret":"hidden"}`); err != nil {
|
||||
t.Fatalf("SetExtensionSettingsJSON: %v", err)
|
||||
}
|
||||
if settingsJSON, err := GetExtensionSettingsJSON(ext.ID); err != nil || !strings.Contains(settingsJSON, "quality") {
|
||||
t.Fatalf("GetExtensionSettingsJSON = %q/%v", settingsJSON, err)
|
||||
}
|
||||
if err := SetExtensionSettingsJSON(ext.ID, `not-json`); err == nil {
|
||||
t.Fatal("expected settings JSON error")
|
||||
}
|
||||
|
||||
if jsonText, err := SearchTracksWithExtensionsJSON("song", 5); err != nil || !strings.Contains(jsonText, "search-1") {
|
||||
t.Fatalf("SearchTracksWithExtensionsJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := SearchTracksWithMetadataProvidersJSON("song", 5, true); err != nil || !strings.Contains(jsonText, "search-1") {
|
||||
t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := GetProviderMetadataJSON(ext.ID, "track", "track-1"); err != nil || !strings.Contains(jsonText, "Track track-1") {
|
||||
t.Fatalf("GetProviderMetadataJSON track = %q/%v", jsonText, err)
|
||||
}
|
||||
for _, resourceType := range []string{"album", "playlist", "artist"} {
|
||||
if jsonText, err := GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1"); err != nil || jsonText == "" {
|
||||
t.Fatalf("GetProviderMetadataJSON %s = %q/%v", resourceType, jsonText, err)
|
||||
}
|
||||
}
|
||||
if _, err := GetProviderMetadataJSON("", "track", "id"); err == nil {
|
||||
t.Fatal("expected empty provider ID error")
|
||||
}
|
||||
if _, err := GetProviderMetadataJSON(ext.ID, "unsupported", "id"); err == nil {
|
||||
t.Fatal("expected unsupported provider type")
|
||||
}
|
||||
if firstNonEmptyTrimmed(" ", " value ") != "value" {
|
||||
t.Fatal("expected first trimmed value")
|
||||
}
|
||||
requestJSON := `{"use_extensions":true,"use_fallback":false,"service":"coverage-ext","source":"coverage-ext","track_name":"Song","artist_name":"Artist","album_name":"Album","output_dir":"` + escapeJSONPath(dir) + `","output_ext":".flac","quality":"LOSSLESS"}`
|
||||
if jsonText, err := DownloadWithExtensionsJSON(requestJSON); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||
t.Fatalf("DownloadWithExtensionsJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if _, err := DownloadWithExtensionsJSON(`not-json`); err == nil {
|
||||
t.Fatal("expected DownloadWithExtensionsJSON JSON error")
|
||||
}
|
||||
|
||||
SetExtensionAuthCodeByID(ext.ID, "code")
|
||||
SetExtensionTokensByID(ext.ID, "access", "refresh", 60)
|
||||
if !IsExtensionAuthenticatedByID(ext.ID) {
|
||||
t.Fatal("expected authenticated extension")
|
||||
}
|
||||
if pending, err := GetExtensionPendingAuthJSON(ext.ID); err != nil || pending != "" {
|
||||
t.Fatalf("GetExtensionPendingAuthJSON = %q/%v", pending, err)
|
||||
}
|
||||
ClearExtensionPendingAuthByID(ext.ID)
|
||||
if all, err := GetAllPendingAuthRequestsJSON(); err != nil || all == "" {
|
||||
t.Fatalf("GetAllPendingAuthRequestsJSON = %q/%v", all, err)
|
||||
}
|
||||
|
||||
ffmpegCommandsMu.Lock()
|
||||
ffmpegCommands["cmd-1"] = &FFmpegCommand{ExtensionID: ext.ID, Command: "ffmpeg -version", InputPath: "in", OutputPath: "out"}
|
||||
ffmpegCommandsMu.Unlock()
|
||||
if cmdJSON, err := GetPendingFFmpegCommandJSON("cmd-1"); err != nil || !strings.Contains(cmdJSON, "cmd-1") {
|
||||
t.Fatalf("GetPendingFFmpegCommandJSON = %q/%v", cmdJSON, err)
|
||||
}
|
||||
if all, err := GetAllPendingFFmpegCommandsJSON(); err != nil || !strings.Contains(all, "cmd-1") {
|
||||
t.Fatalf("GetAllPendingFFmpegCommandsJSON = %q/%v", all, err)
|
||||
}
|
||||
SetFFmpegCommandResultByID("cmd-1", true, "ok", "")
|
||||
ClearFFmpegCommand("cmd-1")
|
||||
if empty, err := GetPendingFFmpegCommandJSON("missing"); err != nil || empty != "" {
|
||||
t.Fatalf("missing ffmpeg = %q/%v", empty, err)
|
||||
}
|
||||
|
||||
enrichedJSON, err := EnrichTrackWithExtensionJSON(ext.ID, `{"id":"track-1","name":"Old","artists":"Artist"}`)
|
||||
if err != nil || !strings.Contains(enrichedJSON, "Enriched") {
|
||||
t.Fatalf("EnrichTrackWithExtensionJSON = %q/%v", enrichedJSON, err)
|
||||
}
|
||||
if sameJSON, err := EnrichTrackWithExtensionJSON("missing", `{"name":"Old"}`); err != nil || !strings.Contains(sameJSON, "Old") {
|
||||
t.Fatalf("missing EnrichTrackWithExtensionJSON = %q/%v", sameJSON, err)
|
||||
}
|
||||
|
||||
deezerClient = &DeezerClient{
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||
status := http.StatusOK
|
||||
if body == "" {
|
||||
status = http.StatusNotFound
|
||||
body = `{"error":"missing"}`
|
||||
}
|
||||
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||
})},
|
||||
searchCache: map[string]*cacheEntry{},
|
||||
albumCache: map[string]*cacheEntry{},
|
||||
artistCache: map[string]*cacheEntry{},
|
||||
isrcCache: map[string]string{},
|
||||
cacheCleanupInterval: time.Hour,
|
||||
}
|
||||
deezerClientOnce.Do(func() {})
|
||||
for _, item := range []struct {
|
||||
typ string
|
||||
id string
|
||||
}{
|
||||
{"track", "101"},
|
||||
{"album", "201"},
|
||||
{"artist", "301"},
|
||||
{"playlist", "401"},
|
||||
} {
|
||||
if jsonText, err := GetDeezerMetadata(item.typ, item.id); err != nil || jsonText == "" {
|
||||
t.Fatalf("GetDeezerMetadata %s = %q/%v", item.typ, jsonText, err)
|
||||
}
|
||||
}
|
||||
if _, err := GetDeezerMetadata("bad", "1"); err == nil {
|
||||
t.Fatal("expected unsupported Deezer metadata type")
|
||||
}
|
||||
if jsonText, err := GetDeezerRelatedArtists("301", 2); err != nil || !strings.Contains(jsonText, "Related") {
|
||||
t.Fatalf("GetDeezerRelatedArtists = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") {
|
||||
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
|
||||
}
|
||||
if _, err := GetDeezerExtendedMetadata(""); err == nil {
|
||||
t.Fatal("expected empty Deezer metadata ID error")
|
||||
}
|
||||
if jsonText, err := SearchDeezerByISRC("USRC17607839"); err != nil || !strings.Contains(jsonText, "deezer:101") {
|
||||
t.Fatalf("SearchDeezerByISRC = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := SearchDeezerByISRCForItemID("USRC17607839", "item-isrc"); err != nil || !strings.Contains(jsonText, "deezer:101") {
|
||||
t.Fatalf("SearchDeezerByISRCForItemID = %q/%v", jsonText, err)
|
||||
}
|
||||
|
||||
customJSON, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`)
|
||||
if err != nil || !strings.Contains(customJSON, "Custom needle") {
|
||||
t.Fatalf("CustomSearchWithExtensionJSON = %q/%v", customJSON, err)
|
||||
}
|
||||
if customJSON, err := CustomSearchWithExtensionJSONWithRequestID(ext.ID, "needle", `not-json`, "req-custom"); err != nil || !strings.Contains(customJSON, "custom-1") {
|
||||
t.Fatalf("CustomSearchWithExtensionJSONWithRequestID = %q/%v", customJSON, err)
|
||||
}
|
||||
if providersJSON, err := GetSearchProvidersJSON(); err != nil || !strings.Contains(providersJSON, "coverage-ext") {
|
||||
t.Fatalf("GetSearchProvidersJSON = %q/%v", providersJSON, err)
|
||||
}
|
||||
if found := FindURLHandlerJSON("https://example.test/track/1"); found != ext.ID {
|
||||
t.Fatalf("FindURLHandlerJSON = %q", found)
|
||||
}
|
||||
if handlersJSON, err := GetURLHandlersJSON(); err != nil || !strings.Contains(handlersJSON, "coverage-ext") {
|
||||
t.Fatalf("GetURLHandlersJSON = %q/%v", handlersJSON, err)
|
||||
}
|
||||
if handledJSON, err := HandleURLWithExtensionJSON("https://example.test/track/1"); err != nil || !strings.Contains(handledJSON, "url-track") {
|
||||
t.Fatalf("HandleURLWithExtensionJSON = %q/%v", handledJSON, err)
|
||||
}
|
||||
if postJSON, err := RunPostProcessingJSON(filepath.Join(dir, "song.flac"), `{"title":"Song"}`); err != nil || !strings.Contains(postJSON, "success") {
|
||||
t.Fatalf("RunPostProcessingJSON = %q/%v", postJSON, err)
|
||||
}
|
||||
v2Input := `{"path":"` + escapeJSONPath(filepath.Join(dir, "song.flac")) + `","uri":"content://song","name":"song.flac","mime_type":"audio/flac","size":10}`
|
||||
if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") {
|
||||
t.Fatalf("RunPostProcessingV2JSON = %q/%v", postJSON, err)
|
||||
}
|
||||
if postProviders, err := GetPostProcessingProvidersJSON(); err != nil || !strings.Contains(postProviders, "hook") {
|
||||
t.Fatalf("GetPostProcessingProvidersJSON = %q/%v", postProviders, err)
|
||||
}
|
||||
if feedJSON, err := GetExtensionHomeFeedJSON(ext.ID); err != nil || !strings.Contains(feedJSON, "home-1") {
|
||||
t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err)
|
||||
}
|
||||
if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") {
|
||||
t.Fatalf("GetExtensionHomeFeedJSONWithRequestID = %q/%v", feedJSON, err)
|
||||
}
|
||||
if categoriesJSON, err := GetExtensionBrowseCategoriesJSON(ext.ID); err != nil || !strings.Contains(categoriesJSON, "cat-1") {
|
||||
t.Fatalf("GetExtensionBrowseCategoriesJSON = %q/%v", categoriesJSON, err)
|
||||
}
|
||||
CancelExtensionRequestJSON("req-home")
|
||||
|
||||
storeDir := filepath.Join(dir, "store")
|
||||
if err := InitExtensionStoreJSON(storeDir); err != nil {
|
||||
t.Fatalf("InitExtensionStoreJSON: %v", err)
|
||||
}
|
||||
if err := SetStoreRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
|
||||
t.Fatalf("SetStoreRegistryURLJSON: %v", err)
|
||||
}
|
||||
store := getExtensionStore()
|
||||
store.cache = &storeRegistry{Extensions: []storeExtension{{
|
||||
ID: "coverage-ext",
|
||||
Name: "coverage-ext",
|
||||
Version: "1.0.0",
|
||||
Description: "Coverage",
|
||||
Category: CategoryMetadata,
|
||||
Tags: []string{"metadata"},
|
||||
DownloadURL: "https://registry.example.com/coverage.spotiflac-ext",
|
||||
}}}
|
||||
store.cacheTime = time.Now()
|
||||
if registryURL, err := GetStoreRegistryURLJSON(); err != nil || registryURL == "" {
|
||||
t.Fatalf("GetStoreRegistryURLJSON = %q/%v", registryURL, err)
|
||||
}
|
||||
if storeJSON, err := GetStoreExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("GetStoreExtensionsJSON = %q/%v", storeJSON, err)
|
||||
}
|
||||
if storeJSON, err := SearchStoreExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("SearchStoreExtensionsJSON = %q/%v", storeJSON, err)
|
||||
}
|
||||
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
||||
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
|
||||
}
|
||||
if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
||||
}
|
||||
if _, err := buildStoreExtensionDestPath(dir, " "); err == nil {
|
||||
t.Fatal("expected invalid extension id")
|
||||
}
|
||||
if err := ClearStoreCacheJSON(); err != nil {
|
||||
t.Fatalf("ClearStoreCacheJSON: %v", err)
|
||||
}
|
||||
if err := ClearStoreRegistryURLJSON(); err != nil {
|
||||
t.Fatalf("ClearStoreRegistryURLJSON: %v", err)
|
||||
}
|
||||
|
||||
SetLibraryCoverCacheDirJSON(filepath.Join(dir, "covers"))
|
||||
libraryDir := filepath.Join(dir, "library")
|
||||
if err := os.MkdirAll(libraryDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(libraryDir, "Artist - Song.mp3"), []byte("not mp3"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scanJSON, err := ScanLibraryFolderJSON(libraryDir); err != nil || !strings.Contains(scanJSON, "Song") {
|
||||
t.Fatalf("ScanLibraryFolderJSON = %q/%v", scanJSON, err)
|
||||
}
|
||||
if scanJSON, err := ScanLibraryFolderIncrementalJSON(libraryDir, `[]`); err != nil || !strings.Contains(scanJSON, "Song") {
|
||||
t.Fatalf("ScanLibraryFolderIncrementalJSON = %q/%v", scanJSON, err)
|
||||
}
|
||||
snapshotPath := filepath.Join(dir, "snapshot.json")
|
||||
if err := os.WriteFile(snapshotPath, []byte(`[]`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scanJSON, err := ScanLibraryFolderIncrementalFromSnapshotJSON(libraryDir, snapshotPath); err != nil || !strings.Contains(scanJSON, "Song") {
|
||||
t.Fatalf("ScanLibraryFolderIncrementalFromSnapshotJSON = %q/%v", scanJSON, err)
|
||||
}
|
||||
if GetLibraryScanProgressJSON() == "" {
|
||||
t.Fatal("expected scan progress JSON")
|
||||
}
|
||||
CancelLibraryScanJSON()
|
||||
if metadataJSON, err := ReadAudioMetadataJSON(filepath.Join(libraryDir, "missing.mp3")); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
if metadataJSON, err := ReadAudioMetadataWithHintJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing"); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataWithHintJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
if metadataJSON, err := ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing", "key"); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataWithHintAndCoverCacheKeyJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -176,6 +177,98 @@ func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMusicBrainzAlbumArtistPrefersMatchingRelease(t *testing.T) {
|
||||
releases := []musicBrainzRelease{
|
||||
{
|
||||
Title: "Other Album",
|
||||
ArtistCredit: []musicBrainzArtistCredit{
|
||||
{Name: "Wrong Artist"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Target Album",
|
||||
ArtistCredit: []musicBrainzArtistCredit{
|
||||
{Name: "Artist A", JoinPhrase: " & "},
|
||||
{Name: "Artist B"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := selectMusicBrainzAlbumArtist(releases, "Target Album")
|
||||
if got != "Artist A & Artist B" {
|
||||
t.Fatalf("album artist = %q, want matching release artist credit", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichRequestExtendedMetadataUsesMusicBrainzAlbumArtist(t *testing.T) {
|
||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||
origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC
|
||||
origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC
|
||||
defer func() {
|
||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||
fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher
|
||||
fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher
|
||||
}()
|
||||
|
||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return &AlbumExtendedMetadata{}, nil
|
||||
}
|
||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||
return "", fmt.Errorf("no genre")
|
||||
}
|
||||
fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) {
|
||||
if isrc != "TESTISRC" || albumName != "Target Album" {
|
||||
t.Fatalf("unexpected MusicBrainz args: %q / %q", isrc, albumName)
|
||||
}
|
||||
return "MusicBrainz Album Artist", nil
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
ISRC: "TESTISRC",
|
||||
ArtistName: "Track Artist",
|
||||
AlbumName: "Target Album",
|
||||
}
|
||||
|
||||
enrichRequestExtendedMetadata(&req)
|
||||
|
||||
if req.AlbumArtist != "MusicBrainz Album Artist" {
|
||||
t.Fatalf("album artist = %q, want MusicBrainz value", req.AlbumArtist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichRequestExtendedMetadataDoesNotFallbackAlbumArtistToTrackArtist(t *testing.T) {
|
||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||
origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC
|
||||
origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC
|
||||
defer func() {
|
||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||
fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher
|
||||
fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher
|
||||
}()
|
||||
|
||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return &AlbumExtendedMetadata{}, nil
|
||||
}
|
||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||
return "", fmt.Errorf("no genre")
|
||||
}
|
||||
fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) {
|
||||
return "", fmt.Errorf("no album artist")
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
ISRC: "TESTISRC",
|
||||
ArtistName: "Track Artist",
|
||||
AlbumName: "Target Album",
|
||||
}
|
||||
|
||||
enrichRequestExtendedMetadata(&req)
|
||||
|
||||
if req.AlbumArtist != "" {
|
||||
t.Fatalf("album artist = %q, want empty when MusicBrainz has no value", req.AlbumArtist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichExtraMetadataByISRCFallsBackToMusicBrainzGenre(t *testing.T) {
|
||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
extensionHealthDefaultTimeout = 4 * time.Second
|
||||
extensionHealthMaxBodyBytes = 64 * 1024
|
||||
)
|
||||
|
||||
type ExtensionHealthResult struct {
|
||||
ExtensionID string `json:"extension_id"`
|
||||
Status string `json:"status"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Checks []ExtensionHealthCheckResult `json:"checks"`
|
||||
}
|
||||
|
||||
type ExtensionHealthCheckResult struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
ServiceKey string `json:"service_key,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Status string `json:"status"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
}
|
||||
|
||||
func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := CheckExtensionHealth(ext)
|
||||
bytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
result := ExtensionHealthResult{
|
||||
ExtensionID: "",
|
||||
Status: "unsupported",
|
||||
CheckedAt: now,
|
||||
Checks: []ExtensionHealthCheckResult{},
|
||||
}
|
||||
if ext == nil || ext.Manifest == nil {
|
||||
result.Status = "offline"
|
||||
return result
|
||||
}
|
||||
|
||||
result.ExtensionID = ext.ID
|
||||
checks := ext.Manifest.ServiceHealth
|
||||
if len(checks) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
result.Status = "online"
|
||||
for _, check := range checks {
|
||||
checkResult := runExtensionHealthCheck(ext.Manifest, check)
|
||||
result.Checks = append(result.Checks, checkResult)
|
||||
|
||||
switch checkResult.Status {
|
||||
case "offline":
|
||||
if check.Required {
|
||||
result.Status = "offline"
|
||||
} else if result.Status == "online" {
|
||||
result.Status = "degraded"
|
||||
}
|
||||
case "degraded":
|
||||
if result.Status == "online" {
|
||||
result.Status = "degraded"
|
||||
}
|
||||
case "unknown":
|
||||
if result.Status == "online" {
|
||||
result.Status = "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
|
||||
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
result := ExtensionHealthCheckResult{
|
||||
ID: check.ID,
|
||||
Label: check.Label,
|
||||
URL: check.URL,
|
||||
Method: method,
|
||||
ServiceKey: strings.TrimSpace(check.ServiceKey),
|
||||
Required: check.Required,
|
||||
Status: "unknown",
|
||||
CheckedAt: now,
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(check.URL)
|
||||
if err != nil {
|
||||
result.Status = "offline"
|
||||
result.Error = fmt.Sprintf("invalid health URL: %v", err)
|
||||
return result
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
result.Status = "offline"
|
||||
result.Error = "health check must use https"
|
||||
return result
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
result.Status = "offline"
|
||||
result.Error = "health check URL hostname is required"
|
||||
return result
|
||||
}
|
||||
if isPrivateIP(host) {
|
||||
result.Status = "offline"
|
||||
result.Error = "private/local health check host is not allowed"
|
||||
return result
|
||||
}
|
||||
if manifest == nil || !manifest.IsDomainAllowed(host) {
|
||||
result.Status = "offline"
|
||||
result.Error = fmt.Sprintf("health check host '%s' is not in extension network permissions", host)
|
||||
return result
|
||||
}
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
result.Status = "offline"
|
||||
result.Error = "health check method must be GET or HEAD"
|
||||
return result
|
||||
}
|
||||
|
||||
timeout := extensionHealthDefaultTimeout
|
||||
if check.TimeoutMs > 0 {
|
||||
timeout = time.Duration(check.TimeoutMs) * time.Millisecond
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, check.URL, nil)
|
||||
if err != nil {
|
||||
result.Status = "offline"
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", userAgentForURL(parsed))
|
||||
|
||||
start := time.Now()
|
||||
resp, err := NewMetadataHTTPClient(timeout).Do(req)
|
||||
result.LatencyMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
result.Status = "offline"
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
result.HTTPStatus = resp.StatusCode
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
result.Status = "offline"
|
||||
result.Message = resp.Status
|
||||
return result
|
||||
}
|
||||
|
||||
if method == http.MethodHead {
|
||||
result.Status = "online"
|
||||
result.Message = resp.Status
|
||||
return result
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, extensionHealthMaxBodyBytes))
|
||||
if err != nil {
|
||||
result.Status = "degraded"
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
status, message := classifyExtensionHealthBody(body, check.ServiceKey)
|
||||
result.Status = status
|
||||
if message == "" {
|
||||
result.Message = resp.Status
|
||||
} else {
|
||||
result.Message = message
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
|
||||
if len(strings.TrimSpace(string(body))) == 0 {
|
||||
return "online", ""
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "online", ""
|
||||
}
|
||||
|
||||
serviceKey = strings.TrimSpace(serviceKey)
|
||||
if serviceKey != "" {
|
||||
if status, message, ok := classifyExtensionHealthService(payload, serviceKey); ok {
|
||||
return status, message
|
||||
}
|
||||
}
|
||||
|
||||
rawStatus, _ := payload["status"].(string)
|
||||
normalized := strings.ToLower(strings.TrimSpace(rawStatus))
|
||||
switch normalized {
|
||||
case "", "ok", "up", "online", "healthy", "operational", "pass", "passing":
|
||||
return "online", rawStatus
|
||||
case "degraded", "partial", "warning", "warn":
|
||||
return "degraded", rawStatus
|
||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||
return "offline", rawStatus
|
||||
default:
|
||||
return "online", rawStatus
|
||||
}
|
||||
}
|
||||
|
||||
func classifyExtensionHealthService(payload map[string]interface{}, serviceKey string) (string, string, bool) {
|
||||
rawServices, ok := payload["services"]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
services, ok := rawServices.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
rawService, ok := services[serviceKey]
|
||||
if !ok {
|
||||
return "unknown", fmt.Sprintf("service '%s' not found", serviceKey), true
|
||||
}
|
||||
service, ok := rawService.(map[string]interface{})
|
||||
if !ok {
|
||||
return "unknown", fmt.Sprintf("service '%s' has invalid health payload", serviceKey), true
|
||||
}
|
||||
|
||||
label, _ := service["label"].(string)
|
||||
detail, _ := service["detail"].(string)
|
||||
errText, _ := service["error"].(string)
|
||||
messageParts := []string{}
|
||||
if strings.TrimSpace(label) != "" {
|
||||
messageParts = append(messageParts, strings.TrimSpace(label))
|
||||
}
|
||||
if strings.TrimSpace(detail) != "" {
|
||||
messageParts = append(messageParts, strings.TrimSpace(detail))
|
||||
}
|
||||
if strings.TrimSpace(errText) != "" {
|
||||
messageParts = append(messageParts, strings.TrimSpace(errText))
|
||||
}
|
||||
|
||||
rawStatus, hasStatus := service["status"]
|
||||
okValue, hasOK := service["ok"].(bool)
|
||||
if statusCode, ok := healthNumber(rawStatus); ok {
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if statusCode == http.StatusInternalServerError && hasOK && okValue {
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
return "offline", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
|
||||
if isExtensionHealthAuthRequired(detail) {
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if hasOK {
|
||||
if okValue {
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
return "offline", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if !hasStatus {
|
||||
return "unknown", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
|
||||
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
||||
switch statusString {
|
||||
case "ok", "up", "online", "healthy", "operational":
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
case "degraded", "partial", "warning", "warn":
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||
return "offline", strings.Join(messageParts, ": "), true
|
||||
default:
|
||||
return "unknown", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
}
|
||||
|
||||
func isExtensionHealthAuthRequired(detail string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(detail)) {
|
||||
case "auth_required", "authorization_required", "login_required", "unauthorized":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func healthNumber(value interface{}) (int, bool) {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
case json.Number:
|
||||
n, err := v.Int64()
|
||||
return int(n), err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtensionHealthClassificationAndValidation(t *testing.T) {
|
||||
if status, msg := classifyExtensionHealthBody([]byte(`{"status":"degraded"}`), ""); status != "degraded" || msg != "degraded" {
|
||||
t.Fatalf("status/message = %q/%q", status, msg)
|
||||
}
|
||||
if status, _ := classifyExtensionHealthBody([]byte(`not-json`), ""); status != "online" {
|
||||
t.Fatalf("invalid JSON status = %q", status)
|
||||
}
|
||||
if status, msg := classifyExtensionHealthBody([]byte(`{"services":{"tidal":{"status":401,"label":"Tidal","detail":"auth_required"}}}`), "tidal"); status != "degraded" || !strings.Contains(msg, "Tidal") {
|
||||
t.Fatalf("service status/message = %q/%q", status, msg)
|
||||
}
|
||||
if status, msg, ok := classifyExtensionHealthService(map[string]interface{}{"services": map[string]interface{}{}}, "missing"); !ok || status != "unknown" || !strings.Contains(msg, "missing") {
|
||||
t.Fatalf("missing service = %q/%q/%v", status, msg, ok)
|
||||
}
|
||||
if n, ok := healthNumber(json.Number("503")); !ok || n != 503 {
|
||||
t.Fatalf("health number = %d/%v", n, ok)
|
||||
}
|
||||
if !isExtensionHealthAuthRequired(" unauthorized ") {
|
||||
t.Fatal("expected auth required")
|
||||
}
|
||||
|
||||
if result := CheckExtensionHealth(nil); result.Status != "offline" {
|
||||
t.Fatalf("nil health = %#v", result)
|
||||
}
|
||||
manifest := &ExtensionManifest{Permissions: ExtensionPermissions{Network: []string{"status.example.com"}}}
|
||||
invalidURL := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "bad", URL: "://bad"})
|
||||
if invalidURL.Status != "offline" {
|
||||
t.Fatalf("invalid URL = %#v", invalidURL)
|
||||
}
|
||||
insecure := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "http", URL: "http://status.example.com"})
|
||||
if insecure.Status != "offline" || !strings.Contains(insecure.Error, "https") {
|
||||
t.Fatalf("insecure = %#v", insecure)
|
||||
}
|
||||
disallowedHost := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "host", URL: "https://other.example.com"})
|
||||
if disallowedHost.Status != "offline" || !strings.Contains(disallowedHost.Error, "permissions") {
|
||||
t.Fatalf("host = %#v", disallowedHost)
|
||||
}
|
||||
badMethod := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "method", URL: "https://status.example.com", Method: "POST"})
|
||||
if badMethod.Status != "offline" || !strings.Contains(badMethod.Error, "method") {
|
||||
t.Fatalf("method = %#v", badMethod)
|
||||
}
|
||||
|
||||
ext := &loadedExtension{
|
||||
ID: "health-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
ServiceHealth: []ExtensionHealthCheck{
|
||||
{ID: "required", URL: "http://status.example.com", Required: true},
|
||||
{ID: "optional", URL: "http://status.example.com", Required: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
if result := CheckExtensionHealth(ext); result.Status != "offline" || len(result.Checks) != 2 {
|
||||
t.Fatalf("extension health = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverRomajiParallelAndIDHSHelpers(t *testing.T) {
|
||||
spotify := "https://i.scdn.co/image/ab67616d00001e02abcdef"
|
||||
if got := GetCoverFromSpotify(spotify, true); !strings.Contains(got, spotifySizeMax) {
|
||||
t.Fatalf("spotify cover = %q", got)
|
||||
}
|
||||
if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1800x1800") {
|
||||
t.Fatalf("deezer cover = %q", got)
|
||||
}
|
||||
if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") {
|
||||
t.Fatalf("tidal cover = %q", got)
|
||||
}
|
||||
if got := upgradeToMaxQuality("https://static.qobuz.com/images/covers/ab/cd/foo_600.jpg"); !strings.Contains(got, "_max.jpg") {
|
||||
t.Fatalf("qobuz cover = %q", got)
|
||||
}
|
||||
if data, err := downloadCoverToMemory("", false); err == nil || data != nil {
|
||||
t.Fatalf("expected empty cover error")
|
||||
}
|
||||
|
||||
if !ContainsJapanese("カタカナ") || ContainsJapanese("abc") {
|
||||
t.Fatal("unexpected Japanese detection")
|
||||
}
|
||||
if got := JapaneseToRomaji("きゃット"); got != "kyatto" {
|
||||
t.Fatalf("romaji = %q", got)
|
||||
}
|
||||
if got := BuildSearchQuery("きゃ! song", "アーティスト"); got != "atisuto kya song" {
|
||||
t.Fatalf("query = %q", got)
|
||||
}
|
||||
if got := CleanToASCII("A, B. C!"); got != "A B C" {
|
||||
t.Fatalf("ascii = %q", got)
|
||||
}
|
||||
|
||||
if err := PreWarmCache(`not-json`); err == nil {
|
||||
t.Fatal("expected prewarm JSON error")
|
||||
}
|
||||
if err := PreWarmCache(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist","spotify_id":"sp","service":"tidal"}]`); err != nil {
|
||||
t.Fatalf("PreWarmCache: %v", err)
|
||||
}
|
||||
if result := FetchCoverAndLyricsParallel("", false, "", "", "", false, 0); result == nil || result.CoverErr != nil || result.LyricsErr != nil {
|
||||
t.Fatalf("parallel result = %#v", result)
|
||||
}
|
||||
if ClearTrackCache(); GetCacheSize() != 0 {
|
||||
t.Fatal("expected empty cache size")
|
||||
}
|
||||
|
||||
client := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
body := `{"id":"1","type":"song","title":"Song","links":[{"type":"tidal","url":"https://tidal.com/browse/track/7"},{"type":"deezer","url":"https://www.deezer.com/track/9"},{"type":"spotify","url":"https://open.spotify.com/track/abc"}]}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}}
|
||||
availability, err := client.GetAvailabilityFromSpotify("spotify-track")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAvailabilityFromSpotify: %v", err)
|
||||
}
|
||||
if !availability.Tidal || !availability.Deezer || availability.DeezerID != "9" {
|
||||
t.Fatalf("spotify availability = %#v", availability)
|
||||
}
|
||||
deezerAvailability, err := client.GetAvailabilityFromDeezer("9")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAvailabilityFromDeezer: %v", err)
|
||||
}
|
||||
if deezerAvailability.SpotifyID != "abc" || !deezerAvailability.Tidal {
|
||||
t.Fatalf("deezer availability = %#v", deezerAvailability)
|
||||
}
|
||||
|
||||
errorClient := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
|
||||
})}}
|
||||
if _, err := errorClient.Search("bad", nil); err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
@@ -156,12 +157,12 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
|
||||
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||
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")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||
return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package")
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
@@ -186,16 +187,16 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
}
|
||||
|
||||
if manifestData == nil {
|
||||
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
|
||||
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
|
||||
}
|
||||
|
||||
if !hasIndexJS {
|
||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||
return nil, fmt.Errorf("invalid extension package: index.js not found")
|
||||
}
|
||||
|
||||
manifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
@@ -213,9 +214,9 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
if versionCompare > 0 {
|
||||
return m.UpgradeExtension(filePath)
|
||||
} else if versionCompare == 0 {
|
||||
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||
} else {
|
||||
return nil, fmt.Errorf("Cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
|
||||
return nil, fmt.Errorf("cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +224,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, exists := m.extensions[manifest.Name]; exists {
|
||||
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
||||
return nil, fmt.Errorf("extension '%s' was installed by another process", manifest.DisplayName)
|
||||
}
|
||||
|
||||
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
||||
@@ -342,23 +343,90 @@ func initializeVMLocked(ext *loadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
||||
vm := goja.New()
|
||||
|
||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||
jsCode, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
cookieJar: nil,
|
||||
dataDir: ext.DataDir,
|
||||
vm: vm,
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
if ext.runtime != nil && ext.runtime.cookieJar != nil {
|
||||
runtime.cookieJar = ext.runtime.cookieJar
|
||||
} else {
|
||||
jar, _ := newSimpleCookieJar()
|
||||
runtime.cookieJar = jar
|
||||
}
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second))
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout)
|
||||
runtime.RegisterAPIs(vm)
|
||||
runtime.RegisterGoBackendAPIs(vm)
|
||||
|
||||
console := vm.NewObject()
|
||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||
args := make([]interface{}, len(call.Arguments))
|
||||
for i, arg := range call.Arguments {
|
||||
args[i] = arg.Export()
|
||||
}
|
||||
GoLog("[Extension:%s] %v\n", ext.ID, args)
|
||||
return goja.Undefined()
|
||||
})
|
||||
vm.Set("console", console)
|
||||
|
||||
var registeredExtension goja.Value
|
||||
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
registeredExtension = call.Arguments[0]
|
||||
vm.Set("extension", call.Arguments[0])
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
if _, err := vm.RunString(string(jsCode)); err != nil {
|
||||
runtime.closeStorageFlusher()
|
||||
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
|
||||
}
|
||||
|
||||
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||
runtime.closeStorageFlusher()
|
||||
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
|
||||
}
|
||||
|
||||
settings := getExtensionInitSettings(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
|
||||
runtime.closeStorageFlusher()
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return vm, runtime, nil
|
||||
}
|
||||
|
||||
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
return initializeVMLocked(ext)
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *loadedExtension,
|
||||
func initializeExtensionRuntimeWithSettings(
|
||||
vm *goja.Runtime,
|
||||
extensionID string,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
return fmt.Errorf("failed to save settings")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -376,11 +444,9 @@ func initializeExtensionWithSettingsLocked(
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
result, err := vm.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -392,14 +458,29 @@ func initializeExtensionWithSettingsLocked(
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *loadedExtension,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("extension failed to load: please reinstall the extension")
|
||||
}
|
||||
|
||||
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
return err
|
||||
}
|
||||
|
||||
ext.initialized = true
|
||||
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||
return nil
|
||||
@@ -407,45 +488,56 @@ func initializeExtensionWithSettingsLocked(
|
||||
|
||||
func runCleanupLocked(ext *loadedExtension) error {
|
||||
if ext.VM != nil {
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
if err := runCleanupOnVM(ext.VM); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||
if ext.VM.Get("extension") != nil {
|
||||
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCleanupOnVM(vm *goja.Runtime) error {
|
||||
if vm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := vm.RunString(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func teardownVMLocked(ext *loadedExtension) {
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||
@@ -478,7 +570,7 @@ func (m *extensionManager) UnloadExtension(extensionID string) error {
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
return fmt.Errorf("extension not found")
|
||||
}
|
||||
|
||||
ext.VMMu.Lock()
|
||||
@@ -497,7 +589,7 @@ func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, e
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("Extension not found")
|
||||
return nil, fmt.Errorf("extension not found")
|
||||
}
|
||||
return ext, nil
|
||||
}
|
||||
@@ -519,7 +611,7 @@ func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
return fmt.Errorf("extension not found")
|
||||
}
|
||||
|
||||
if enabled {
|
||||
@@ -597,12 +689,12 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
|
||||
|
||||
manifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
||||
}
|
||||
|
||||
indexPath := filepath.Join(dirPath, "index.js")
|
||||
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("Extension is missing index.js file")
|
||||
return nil, fmt.Errorf("extension is missing index.js file")
|
||||
}
|
||||
|
||||
if existing, exists := m.extensions[manifest.Name]; exists {
|
||||
@@ -665,12 +757,12 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||
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")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||
return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package")
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
@@ -695,16 +787,16 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
|
||||
}
|
||||
|
||||
if manifestData == nil {
|
||||
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
|
||||
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
|
||||
}
|
||||
|
||||
if !hasIndexJS {
|
||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||
return nil, fmt.Errorf("invalid extension package: index.js not found")
|
||||
}
|
||||
|
||||
newManifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
@@ -712,15 +804,15 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
|
||||
return nil, fmt.Errorf("extension '%s' is not installed; use install instead of upgrade", newManifest.DisplayName)
|
||||
}
|
||||
|
||||
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
||||
if versionCompare < 0 {
|
||||
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
||||
return nil, fmt.Errorf("cannot downgrade extension: current version: %s, new version: %s", existing.Manifest.Version, newManifest.Version)
|
||||
}
|
||||
if versionCompare == 0 {
|
||||
return nil, fmt.Errorf("Extension is already at version %s", existing.Manifest.Version)
|
||||
return nil, fmt.Errorf("extension is already at version %s", existing.Manifest.Version)
|
||||
}
|
||||
|
||||
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
||||
@@ -814,13 +906,13 @@ type ExtensionUpgradeInfo struct {
|
||||
|
||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
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")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file")
|
||||
return nil, fmt.Errorf("cannot open extension file")
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
@@ -847,7 +939,7 @@ func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
|
||||
newManifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
||||
return nil, fmt.Errorf("invalid manifest: %w", err)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
@@ -908,9 +1000,11 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||
SkipLyrics bool `json:"skip_lyrics"`
|
||||
StopProviderFallback bool `json:"stop_provider_fallback"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"service_health,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
@@ -965,9 +1059,11 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||
SkipLyrics: ext.Manifest.SkipLyrics,
|
||||
StopProviderFallback: ext.Manifest.StopsProviderFallback(),
|
||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||
TrackMatching: ext.Manifest.TrackMatching,
|
||||
PostProcessing: ext.Manifest.PostProcessing,
|
||||
ServiceHealth: ext.Manifest.ServiceHealth,
|
||||
Capabilities: ext.Manifest.Capabilities,
|
||||
}
|
||||
}
|
||||
@@ -986,7 +1082,7 @@ func (m *extensionManager) InitializeExtension(extensionID string, settings map[
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
return fmt.Errorf("extension not found")
|
||||
}
|
||||
|
||||
ext.VMMu.Lock()
|
||||
@@ -1004,7 +1100,7 @@ func (m *extensionManager) CleanupExtension(extensionID string) error {
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
return fmt.Errorf("extension not found")
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtensionManagerPackageLifecycle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
extensionsDir := filepath.Join(dir, "extensions")
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
|
||||
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
||||
t.Fatalf("SetDirectories: %v", err)
|
||||
}
|
||||
if err := GetExtensionSettingsStore().SetDataDir(dataDir); err != nil {
|
||||
t.Fatalf("settings data dir: %v", err)
|
||||
}
|
||||
|
||||
js := `
|
||||
var cleaned = false;
|
||||
registerExtension({
|
||||
initialize: function(settings) { this.settings = settings || {}; },
|
||||
cleanup: function() { cleaned = true; },
|
||||
doAction: function() { return { message: "done", setting_updates: { quality: "lossless" } }; },
|
||||
getHomeFeed: function() { return [{ id: "home", title: "Home" }]; },
|
||||
getBrowseCategories: function() { return [{ id: "cat", title: "Category" }]; },
|
||||
searchTracks: function() { return { tracks: [], total: 0 }; },
|
||||
fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; },
|
||||
getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; }
|
||||
});
|
||||
`
|
||||
pkgV1 := filepath.Join(dir, "manager-ext-v1.spotiflac-ext")
|
||||
createTestExtensionPackage(t, pkgV1, "manager-ext", "1.0.0", js, map[string]string{"../unsafe.txt": "skip"})
|
||||
pkgV2 := filepath.Join(dir, "manager-ext-v2.spotiflac-ext")
|
||||
createTestExtensionPackage(t, pkgV2, "manager-ext", "1.1.0", js, nil)
|
||||
|
||||
if compareVersions("v1.2.0", "1.1.9") <= 0 || compareVersions("1.0.0", "1.0") != 0 || compareVersions("1.0.0", "1.0.1") >= 0 {
|
||||
t.Fatal("compareVersions mismatch")
|
||||
}
|
||||
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "bad.txt")); err == nil {
|
||||
t.Fatal("expected bad extension suffix error")
|
||||
}
|
||||
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "missing.spotiflac-ext")); err == nil {
|
||||
t.Fatal("expected invalid package error")
|
||||
}
|
||||
|
||||
ext, err := manager.LoadExtensionFromFile(pkgV1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadExtensionFromFile: %v", err)
|
||||
}
|
||||
if ext.ID != "manager-ext" || ext.Enabled || ext.SourceDir == "" {
|
||||
t.Fatalf("loaded extension = %#v", ext)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ext.SourceDir, "unsafe.txt")); err == nil {
|
||||
t.Fatal("unsafe archive path should not be extracted")
|
||||
}
|
||||
if _, err := manager.LoadExtensionFromFile(pkgV1); err == nil {
|
||||
t.Fatal("expected duplicate version error")
|
||||
}
|
||||
|
||||
installedJSON, err := manager.GetInstalledExtensionsJSON()
|
||||
if err != nil || !strings.Contains(installedJSON, "manager-ext") || !strings.Contains(installedJSON, "icon_path") {
|
||||
t.Fatalf("GetInstalledExtensionsJSON = %q/%v", installedJSON, err)
|
||||
}
|
||||
var installed []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(installedJSON), &installed); err != nil || len(installed) != 1 {
|
||||
t.Fatalf("decode installed = %#v/%v", installed, err)
|
||||
}
|
||||
|
||||
if err := GetExtensionSettingsStore().Set("manager-ext", "quality", "lossless"); err != nil {
|
||||
t.Fatalf("settings Set: %v", err)
|
||||
}
|
||||
if err := manager.SetExtensionEnabled("manager-ext", true); err != nil {
|
||||
t.Fatalf("enable extension: %v", err)
|
||||
}
|
||||
if !ext.Enabled || ext.VM == nil || !ext.initialized {
|
||||
t.Fatalf("enabled extension = %#v", ext)
|
||||
}
|
||||
if err := manager.InitializeExtension("manager-ext", map[string]interface{}{"quality": "hires"}); err != nil {
|
||||
t.Fatalf("InitializeExtension: %v", err)
|
||||
}
|
||||
action, err := manager.InvokeAction("manager-ext", "doAction")
|
||||
if err != nil || action["success"] != true || action["message"] != "done" {
|
||||
t.Fatalf("InvokeAction = %#v/%v", action, err)
|
||||
}
|
||||
if err := manager.CleanupExtension("manager-ext"); err != nil {
|
||||
t.Fatalf("CleanupExtension: %v", err)
|
||||
}
|
||||
if err := manager.SetExtensionEnabled("manager-ext", false); err != nil {
|
||||
t.Fatalf("disable extension: %v", err)
|
||||
}
|
||||
if ext.VM != nil || ext.initialized {
|
||||
t.Fatalf("expected VM teardown, got %#v", ext)
|
||||
}
|
||||
if _, err := manager.InvokeAction("manager-ext", "doAction"); err == nil {
|
||||
t.Fatal("expected disabled action error")
|
||||
}
|
||||
|
||||
upgradeJSON, err := manager.CheckExtensionUpgradeJSON(pkgV2)
|
||||
if err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) {
|
||||
t.Fatalf("CheckExtensionUpgradeJSON = %q/%v", upgradeJSON, err)
|
||||
}
|
||||
upgraded, err := manager.UpgradeExtension(pkgV2)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeExtension: %v", err)
|
||||
}
|
||||
if upgraded.Manifest.Version != "1.1.0" {
|
||||
t.Fatalf("upgraded = %#v", upgraded.Manifest)
|
||||
}
|
||||
if _, err := manager.UpgradeExtension(pkgV1); err == nil {
|
||||
t.Fatal("expected downgrade error")
|
||||
}
|
||||
if err := manager.RemoveExtension("manager-ext"); err != nil {
|
||||
t.Fatalf("RemoveExtension: %v", err)
|
||||
}
|
||||
if _, err := manager.GetExtension("manager-ext"); err == nil {
|
||||
t.Fatal("expected removed extension missing")
|
||||
}
|
||||
|
||||
dirExt := filepath.Join(extensionsDir, "dir-ext")
|
||||
if err := os.MkdirAll(dirExt, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"name":"dir-ext","displayName":"dir-ext","version":"1.0.0","description":"Directory extension","type":["metadata_provider"],"permissions":{}}`
|
||||
if err := os.WriteFile(filepath.Join(dirExt, "manifest.json"), []byte(manifest), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dirExt, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, loadErrs := manager.LoadExtensionsFromDirectory(extensionsDir)
|
||||
if len(loadErrs) != 0 || len(loaded) != 1 || loaded[0] != "dir-ext" {
|
||||
t.Fatalf("LoadExtensionsFromDirectory = %#v/%#v", loaded, loadErrs)
|
||||
}
|
||||
manager.UnloadAllExtensions()
|
||||
if len(manager.GetAllExtensions()) != 0 {
|
||||
t.Fatal("expected all extensions unloaded")
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,17 @@ type PostProcessingConfig struct {
|
||||
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionHealthCheck struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method,omitempty"`
|
||||
ServiceKey string `json:"serviceKey,omitempty"`
|
||||
TimeoutMs int `json:"timeoutMs,omitempty"`
|
||||
CacheTTLSeconds int `json:"cacheTtlSeconds,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
@@ -115,11 +126,13 @@ type ExtensionManifest struct {
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
@@ -202,6 +215,28 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
for i, check := range m.ServiceHealth {
|
||||
if strings.TrimSpace(check.ID) == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("serviceHealth[%d].id", i),
|
||||
Message: "health check id is required",
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(check.URL) == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("serviceHealth[%d].url", i),
|
||||
Message: "health check url is required",
|
||||
}
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
||||
if method != "" && method != "GET" && method != "HEAD" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("serviceHealth[%d].method", i),
|
||||
Message: "health check method must be GET or HEAD",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -226,6 +261,13 @@ func (m *ExtensionManifest) IsLyricsProvider() bool {
|
||||
return m.HasType(ExtensionTypeLyricsProvider)
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) StopsProviderFallback() bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
return m.StopProviderFallback || m.SkipBuiltInFallback
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
for _, allowed := range m.Permissions.Network {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type extensionCallPerf struct {
|
||||
extensionID string
|
||||
operation string
|
||||
startedAt time.Time
|
||||
initMs float64
|
||||
jsMs float64
|
||||
parseMs float64
|
||||
items int
|
||||
payloadBytes int
|
||||
}
|
||||
|
||||
func newExtensionCallPerf(extensionID, operation string) *extensionCallPerf {
|
||||
if !GetLogBuffer().IsLoggingEnabled() {
|
||||
return nil
|
||||
}
|
||||
return &extensionCallPerf{
|
||||
extensionID: extensionID,
|
||||
operation: operation,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func extensionDurationMs(duration time.Duration) float64 {
|
||||
return float64(duration.Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordInit(duration time.Duration) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.initMs += extensionDurationMs(duration)
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordJS(duration time.Duration) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.jsMs += extensionDurationMs(duration)
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordParse(duration time.Duration) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.parseMs += extensionDurationMs(duration)
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordPayload(value goja.Value) {
|
||||
if p == nil || gojaValueIsEmpty(value) {
|
||||
return
|
||||
}
|
||||
if payload, err := json.Marshal(value); err == nil {
|
||||
p.payloadBytes = len(payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) setPayloadBytes(payloadBytes int) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.payloadBytes = payloadBytes
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) setItems(items int) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.items = items
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) finish() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
LogDebug(
|
||||
"ExtensionPerf",
|
||||
"extension=%s op=%s totalMs=%.1f initMs=%.1f jsMs=%.1f parseMs=%.1f items=%d payloadBytes=%d",
|
||||
p.extensionID,
|
||||
p.operation,
|
||||
extensionDurationMs(time.Since(p.startedAt)),
|
||||
p.initMs,
|
||||
p.jsMs,
|
||||
p.parseMs,
|
||||
p.items,
|
||||
p.payloadBytes,
|
||||
)
|
||||
}
|
||||
|
||||
func countExtensionTopLevelItems(vm *goja.Runtime, value goja.Value) int {
|
||||
if gojaValueIsEmpty(value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if length, err := gojaArrayLength(value, vm); err == nil && length > 0 {
|
||||
return length
|
||||
}
|
||||
|
||||
obj := value.ToObject(vm)
|
||||
for _, key := range []string{"items", "tracks", "sections", "albums", "artists", "playlists", "results"} {
|
||||
child := obj.Get(key)
|
||||
if gojaValueIsEmpty(child) {
|
||||
continue
|
||||
}
|
||||
if length, err := gojaArrayLength(child, vm); err == nil && length > 0 {
|
||||
return length
|
||||
}
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtensionProviderWrapperFullSurface(t *testing.T) {
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
search, err := provider.SearchTracks("query", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracks: %v", err)
|
||||
}
|
||||
if search.Total != 1 || search.Tracks[0].ProviderID != ext.ID || search.Tracks[0].ExternalLinks["tidal"] == "" {
|
||||
t.Fatalf("search = %#v", search)
|
||||
}
|
||||
|
||||
track, err := provider.GetTrack("track-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrack: %v", err)
|
||||
}
|
||||
if track.Name != "Track track-1" || track.ProviderID != ext.ID || track.AudioQuality == "" {
|
||||
t.Fatalf("track = %#v", track)
|
||||
}
|
||||
|
||||
album, err := provider.GetAlbum("album-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbum: %v", err)
|
||||
}
|
||||
if album.ProviderID != ext.ID || len(album.Tracks) != 1 || album.Tracks[0].ProviderID != ext.ID {
|
||||
t.Fatalf("album = %#v", album)
|
||||
}
|
||||
|
||||
playlist, err := provider.GetPlaylist("playlist-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlaylist: %v", err)
|
||||
}
|
||||
if playlist.Name != "Playlist playlist-1" || playlist.ProviderID != ext.ID {
|
||||
t.Fatalf("playlist = %#v", playlist)
|
||||
}
|
||||
|
||||
artist, err := provider.GetArtist("artist-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtist: %v", err)
|
||||
}
|
||||
if artist.ProviderID != ext.ID || len(artist.Releases) != 1 || artist.Releases[0].ProviderID != ext.ID {
|
||||
t.Fatalf("artist = %#v", artist)
|
||||
}
|
||||
|
||||
enriched, err := provider.EnrichTrack(&ExtTrackMetadata{ID: "track-1", Name: "Old", ProviderID: ext.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("EnrichTrack: %v", err)
|
||||
}
|
||||
if enriched.Name != "Enriched" || enriched.ProviderID != ext.ID {
|
||||
t.Fatalf("enriched = %#v", enriched)
|
||||
}
|
||||
|
||||
availability, err := provider.CheckAvailability("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAvailability: %v", err)
|
||||
}
|
||||
if !availability.Available || availability.TrackID != "download-track" || !availability.SkipFallback {
|
||||
t.Fatalf("availability = %#v", availability)
|
||||
}
|
||||
|
||||
downloadURL, err := provider.GetDownloadURL("track-1", "LOSSLESS")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDownloadURL: %v", err)
|
||||
}
|
||||
if downloadURL.Format != "flac" || downloadURL.BitDepth != 24 || downloadURL.SampleRate != 96000 {
|
||||
t.Fatalf("download URL = %#v", downloadURL)
|
||||
}
|
||||
|
||||
progress := []int{}
|
||||
download, err := provider.Download("track-1", "LOSSLESS", filepath.Join(t.TempDir(), "song.flac"), "", func(percent int) {
|
||||
progress = append(progress, percent)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Download: %v", err)
|
||||
}
|
||||
if !download.Success || download.Decryption == nil || download.DecryptionKey != "001122" || len(progress) != 1 || progress[0] != 100 {
|
||||
t.Fatalf("download = %#v progress=%v", download, progress)
|
||||
}
|
||||
|
||||
lyrics, err := provider.FetchLyrics("Song", "Artist", "Album", 180)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLyrics: %v", err)
|
||||
}
|
||||
if lyrics.Provider != ext.ID || len(lyrics.Lines) != 1 || lyrics.Lines[0].Words != "Hello" {
|
||||
t.Fatalf("lyrics = %#v", lyrics)
|
||||
}
|
||||
|
||||
urlResult, err := provider.HandleURL("https://example.test/track/1")
|
||||
if err != nil {
|
||||
t.Fatalf("HandleURL: %v", err)
|
||||
}
|
||||
if urlResult.Track == nil || urlResult.Track.Name == "" || len(urlResult.Tracks) != 1 || urlResult.Album == nil || urlResult.Artist == nil {
|
||||
t.Fatalf("url result = %#v", urlResult)
|
||||
}
|
||||
|
||||
match, err := provider.MatchTrack(
|
||||
map[string]interface{}{"name": "Song", "artists": "Artist"},
|
||||
[]map[string]interface{}{{"id": "download-track", "name": "Song"}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchTrack: %v", err)
|
||||
}
|
||||
if !match.Matched || match.TrackID != "download-track" {
|
||||
t.Fatalf("match = %#v", match)
|
||||
}
|
||||
|
||||
post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]interface{}{"title": "Song"}, "hook")
|
||||
if err != nil {
|
||||
t.Fatalf("PostProcess: %v", err)
|
||||
}
|
||||
if !post.Success || post.BitDepth != 24 || post.SampleRate != 96000 {
|
||||
t.Fatalf("post = %#v", post)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionProviderAndManagerSelectionHelpers(t *testing.T) {
|
||||
manifest := &ExtensionManifest{Capabilities: map[string]interface{}{
|
||||
"replacesBuiltInProviders": []interface{}{" Deezer ", 7, ""},
|
||||
}}
|
||||
if values := manifestCapabilityStringList(manifest, "replacesBuiltInProviders"); len(values) != 1 || values[0] != "deezer" {
|
||||
t.Fatalf("capability list = %#v", values)
|
||||
}
|
||||
if !extensionReplacesBuiltInProvider(&loadedExtension{Manifest: manifest}, "deezer") || extensionReplacesBuiltInProvider(nil, "deezer") {
|
||||
t.Fatal("extension replacement mismatch")
|
||||
}
|
||||
if trimKnownProviderPrefix("Deezer:101", "deezer") != "101" || trimKnownProviderPrefix("101", "deezer") != "101" {
|
||||
t.Fatal("trimKnownProviderPrefix mismatch")
|
||||
}
|
||||
if metadataTrackDedupKey(ExtTrackMetadata{ISRC: "usrc"}) != "isrc:USRC" ||
|
||||
metadataTrackDedupKey(ExtTrackMetadata{SpotifyID: "sp"}) != "spotify:sp" ||
|
||||
metadataTrackDedupKey(ExtTrackMetadata{ProviderID: "p", ID: "1"}) != "p:1" {
|
||||
t.Fatal("metadata dedup key mismatch")
|
||||
}
|
||||
|
||||
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
|
||||
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider, ExtensionTypeMetadataProvider)
|
||||
manager.extensions[downloadExt.ID] = downloadExt
|
||||
if providers := manager.GetDownloadProviders(); len(providers) != 1 {
|
||||
t.Fatalf("download providers = %#v", providers)
|
||||
}
|
||||
SetProviderPriority([]string{"deezer", "coverage-ext", "coverage-ext", " "})
|
||||
if priority := GetProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
|
||||
t.Fatalf("provider priority = %#v", priority)
|
||||
}
|
||||
SetExtensionFallbackProviderIDs([]string{"a", "a", " ", "b"})
|
||||
if ids := GetExtensionFallbackProviderIDs(); len(ids) != 2 || !isExtensionFallbackAllowed("a") || isExtensionFallbackAllowed("z") {
|
||||
t.Fatalf("fallback ids = %#v", ids)
|
||||
}
|
||||
SetExtensionFallbackProviderIDs(nil)
|
||||
if !isExtensionFallbackAllowed("z") {
|
||||
t.Fatal("nil fallback list should allow all")
|
||||
}
|
||||
SetMetadataProviderPriority([]string{"spotify", "deezer", "coverage-ext", "coverage-ext"})
|
||||
if priority := GetMetadataProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
|
||||
t.Fatalf("metadata priority = %#v", priority)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,38 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
func TestSetMetadataProviderPriorityStripsRetiredBuiltIns(t *testing.T) {
|
||||
original := GetMetadataProviderPriority()
|
||||
defer SetMetadataProviderPriority(original)
|
||||
|
||||
SetMetadataProviderPriority([]string{"tidal"})
|
||||
SetMetadataProviderPriority([]string{"qobuz"})
|
||||
got := GetMetadataProviderPriority()
|
||||
want := []string{"tidal", "qobuz"}
|
||||
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)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected retired built-in qobuz to be stripped, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
|
||||
func TestSetExtensionFallbackProviderIDsDedupesExtensions(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
|
||||
SetExtensionFallbackProviderIDs([]string{"ext-a", "ext-a", " ext-b "})
|
||||
|
||||
got := GetExtensionFallbackProviderIDs()
|
||||
want := []string{"ext-a", "ext-b"}
|
||||
@@ -50,9 +55,6 @@ func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
|
||||
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) {
|
||||
@@ -79,7 +81,7 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
||||
|
||||
got := GetProviderPriority()
|
||||
want := []string{"qobuz", "custom-ext", "tidal"}
|
||||
want := []string{"custom-ext"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
@@ -123,6 +125,110 @@ func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionDownloadUsesIsolatedRuntimeForConcurrentCalls(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer server.Close()
|
||||
setPrivateIPCache("download.test", false, time.Minute)
|
||||
|
||||
originalTransport := sharedTransport
|
||||
testTransport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
|
||||
},
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
sharedTransport = testTransport
|
||||
defer func() {
|
||||
testTransport.CloseIdleConnections()
|
||||
sharedTransport = originalTransport
|
||||
}()
|
||||
|
||||
extDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(extDir, "index.js"), []byte(`
|
||||
registerExtension({
|
||||
download: function(trackID, quality, outputPath, onProgress) {
|
||||
var result = file.download('https://download.test/' + trackID, outputPath, {
|
||||
onProgress: function(written, total) {
|
||||
if (onProgress) onProgress(50);
|
||||
}
|
||||
});
|
||||
if (!result || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error_message: result && result.error ? result.error : 'download failed',
|
||||
error_type: 'download_error'
|
||||
};
|
||||
}
|
||||
if (onProgress) onProgress(100);
|
||||
return { success: true, file_path: result.path };
|
||||
}
|
||||
});
|
||||
`), 0600); err != nil {
|
||||
t.Fatalf("write extension index: %v", err)
|
||||
}
|
||||
|
||||
outputDir := t.TempDir()
|
||||
SetAllowedDownloadDirs([]string{outputDir})
|
||||
defer SetAllowedDownloadDirs(nil)
|
||||
|
||||
ext := &loadedExtension{
|
||||
ID: "concurrent-download",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "concurrent-download",
|
||||
Description: "Concurrent download test",
|
||||
Version: "1.0.0",
|
||||
Types: []ExtensionType{ExtensionTypeDownloadProvider},
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"download.test"},
|
||||
File: true,
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
SourceDir: extDir,
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
start := time.Now()
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := provider.Download(
|
||||
fmt.Sprintf("track-%d", i),
|
||||
"LOSSLESS",
|
||||
filepath.Join(outputDir, fmt.Sprintf("track-%d.flac", i)),
|
||||
"",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if result == nil || !result.Success {
|
||||
errs <- fmt.Errorf("download failed: %#v", result)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed := time.Since(start); elapsed >= 850*time.Millisecond {
|
||||
t.Fatalf("expected same-extension downloads to overlap, elapsed %s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
@@ -180,6 +286,63 @@ func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldStopProviderFallback(t *testing.T) {
|
||||
if shouldStopProviderFallback(nil) {
|
||||
t.Fatal("nil availability should not stop fallback")
|
||||
}
|
||||
if shouldStopProviderFallback(&ExtAvailabilityResult{Available: false}) {
|
||||
t.Fatal("availability without skip_fallback should not stop fallback")
|
||||
}
|
||||
if !shouldStopProviderFallback(&ExtAvailabilityResult{Available: false, SkipFallback: true}) {
|
||||
t.Fatal("skip_fallback availability should stop fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtensionFallbackStoppedResponsePrefersAvailabilityReason(t *testing.T) {
|
||||
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
|
||||
Reason: "direct SoundCloud track ID",
|
||||
SkipFallback: true,
|
||||
}, errors.New("ignored"))
|
||||
|
||||
if resp.Service != "soundcloud" {
|
||||
t.Fatalf("service = %q", resp.Service)
|
||||
}
|
||||
if resp.Error != "Fallback stopped by soundcloud: direct SoundCloud track ID" {
|
||||
t.Fatalf("unexpected error message: %q", resp.Error)
|
||||
}
|
||||
if resp.ErrorType != "extension_error" {
|
||||
t.Fatalf("error type = %q", resp.ErrorType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtensionFallbackStoppedResponseFallsBackToError(t *testing.T) {
|
||||
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
|
||||
SkipFallback: true,
|
||||
}, errors.New("lookup failed"))
|
||||
|
||||
if resp.Error != "Fallback stopped by soundcloud: lookup failed" {
|
||||
t.Fatalf("unexpected error message: %q", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAbortCancelledFallbackWithCancelledError(t *testing.T) {
|
||||
if !shouldAbortCancelledFallback("", ErrDownloadCancelled) {
|
||||
t.Fatal("expected cancelled error to abort fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAbortCancelledFallbackWithCancelledItemState(t *testing.T) {
|
||||
const itemID = "cancelled-item"
|
||||
initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
|
||||
cancelDownload(itemID)
|
||||
|
||||
if !shouldAbortCancelledFallback(itemID, errors.New("generic failure")) {
|
||||
t.Fatal("expected cancelled item state to abort fallback even for generic errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
||||
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
||||
@@ -207,46 +370,260 @@ func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) {
|
||||
originalPriority := GetMetadataProviderPriority()
|
||||
originalSearch := searchBuiltInMetadataTracksFunc
|
||||
defer func() {
|
||||
SetMetadataProviderPriority(originalPriority)
|
||||
searchBuiltInMetadataTracksFunc = originalSearch
|
||||
}()
|
||||
|
||||
SetMetadataProviderPriority([]string{"qobuz", "tidal"})
|
||||
|
||||
var calls []string
|
||||
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
calls = append(calls, providerID)
|
||||
switch providerID {
|
||||
case "qobuz":
|
||||
return []ExtTrackMetadata{
|
||||
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
|
||||
}, nil
|
||||
case "tidal":
|
||||
return []ExtTrackMetadata{
|
||||
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
|
||||
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
SetMetadataProviderPriority([]string{"qobuz"})
|
||||
|
||||
manager := getExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||
}
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("unexpected track count: got %d want 2", len(tracks))
|
||||
}
|
||||
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" {
|
||||
t.Fatalf("unexpected track provider order: %+v", tracks)
|
||||
}
|
||||
if len(calls) != 2 || calls[0] != "qobuz" || calls[1] != "tidal" {
|
||||
t.Fatalf("unexpected provider call order: %v", calls)
|
||||
if len(tracks) != 0 {
|
||||
t.Fatalf("expected no tracks from retired built-in provider, got %+v", tracks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionSearchResultAcceptsObjectAndArrayShapes(t *testing.T) {
|
||||
vm := goja.New()
|
||||
value, err := vm.RunString(`({
|
||||
tracks: [{
|
||||
id: "track-1",
|
||||
name: "Song",
|
||||
artists: "Artist",
|
||||
album_name: "Album",
|
||||
duration_ms: 123000,
|
||||
cover_url: "https://img.test/cover.jpg",
|
||||
external_links: { spotify: "spotify:track:1" },
|
||||
audio_quality: "LOSSLESS"
|
||||
}],
|
||||
total: 9
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build object search result: %v", err)
|
||||
}
|
||||
|
||||
result, err := parseExtensionSearchResult(vm, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse object search result: %v", err)
|
||||
}
|
||||
if result.Total != 9 || len(result.Tracks) != 1 {
|
||||
t.Fatalf("unexpected object result: %+v", result)
|
||||
}
|
||||
track := result.Tracks[0]
|
||||
if track.ID != "track-1" ||
|
||||
track.AlbumName != "Album" ||
|
||||
track.DurationMS != 123000 ||
|
||||
track.CoverURL != "https://img.test/cover.jpg" ||
|
||||
track.ExternalLinks["spotify"] != "spotify:track:1" ||
|
||||
track.AudioQuality != "LOSSLESS" {
|
||||
t.Fatalf("unexpected parsed track: %+v", track)
|
||||
}
|
||||
|
||||
arrayValue, err := vm.RunString(`[
|
||||
{id: "track-2", name: "Other Song", artists: "Other Artist", albumName: "Other Album", durationMs: 456000}
|
||||
]`)
|
||||
if err != nil {
|
||||
t.Fatalf("build array search result: %v", err)
|
||||
}
|
||||
|
||||
arrayResult, err := parseExtensionSearchResult(vm, arrayValue)
|
||||
if err != nil {
|
||||
t.Fatalf("parse array search result: %v", err)
|
||||
}
|
||||
if arrayResult.Total != 1 ||
|
||||
len(arrayResult.Tracks) != 1 ||
|
||||
arrayResult.Tracks[0].AlbumName != "Other Album" ||
|
||||
arrayResult.Tracks[0].DurationMS != 456000 {
|
||||
t.Fatalf("unexpected array result: %+v", arrayResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionMetadataAndDownloadResults(t *testing.T) {
|
||||
vm := goja.New()
|
||||
value, err := vm.RunString(`({
|
||||
id: "album-1",
|
||||
name: "Album",
|
||||
artists: "Artist",
|
||||
artistId: "artist-1",
|
||||
coverUrl: "https://img.test/album.jpg",
|
||||
releaseDate: "2024-02-03",
|
||||
totalTracks: 2,
|
||||
albumType: "album",
|
||||
tracks: [
|
||||
{id: "track-1", name: "Song 1", artists: "Artist", durationMs: 180000},
|
||||
{id: "track-2", name: "Song 2", artists: "Artist", duration_ms: 181000}
|
||||
]
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build album value: %v", err)
|
||||
}
|
||||
|
||||
album, err := parseExtensionAlbumValue(vm, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse album: %v", err)
|
||||
}
|
||||
if album.ID != "album-1" ||
|
||||
album.ArtistID != "artist-1" ||
|
||||
album.CoverURL != "https://img.test/album.jpg" ||
|
||||
album.TotalTracks != 2 ||
|
||||
len(album.Tracks) != 2 ||
|
||||
album.Tracks[0].DurationMS != 180000 ||
|
||||
album.Tracks[1].DurationMS != 181000 {
|
||||
t.Fatalf("unexpected album: %+v", album)
|
||||
}
|
||||
|
||||
artistValue, err := vm.RunString(`({
|
||||
id: "artist-1",
|
||||
name: "Artist",
|
||||
imageUrl: "https://img.test/artist.jpg",
|
||||
headerImage: "https://img.test/header.jpg",
|
||||
listeners: 1234,
|
||||
albums: [{id: "album-1", name: "Album", tracks: [{id: "track-1", name: "Song"}]}],
|
||||
releases: [{id: "single-1", name: "Single"}],
|
||||
topTracks: [{id: "top-1", name: "Top Song"}]
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build artist value: %v", err)
|
||||
}
|
||||
|
||||
artist, err := parseExtensionArtistValue(vm, artistValue)
|
||||
if err != nil {
|
||||
t.Fatalf("parse artist: %v", err)
|
||||
}
|
||||
if artist.ID != "artist-1" ||
|
||||
artist.ImageURL != "https://img.test/artist.jpg" ||
|
||||
artist.HeaderImage != "https://img.test/header.jpg" ||
|
||||
artist.Listeners != 1234 ||
|
||||
len(artist.Albums) != 1 ||
|
||||
len(artist.Albums[0].Tracks) != 1 ||
|
||||
len(artist.Releases) != 1 ||
|
||||
len(artist.TopTracks) != 1 {
|
||||
t.Fatalf("unexpected artist: %+v", artist)
|
||||
}
|
||||
|
||||
downloadValue, err := vm.RunString(`({
|
||||
success: true,
|
||||
filePath: "/tmp/song.flac",
|
||||
alreadyExists: true,
|
||||
bitDepth: 24,
|
||||
sampleRate: 96000,
|
||||
title: "Song",
|
||||
albumArtist: "Album Artist",
|
||||
lyricsLrc: "[00:00.00]Line",
|
||||
decryptionKey: "001122",
|
||||
decryption: {
|
||||
strategy: "mp4_decryption_key",
|
||||
key: "001122",
|
||||
inputFormat: "m4a",
|
||||
options: { map: "0:a" }
|
||||
}
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build download value: %v", err)
|
||||
}
|
||||
|
||||
download := parseExtensionDownloadResultValue(vm, downloadValue)
|
||||
if !download.Success ||
|
||||
download.FilePath != "/tmp/song.flac" ||
|
||||
!download.AlreadyExists ||
|
||||
download.BitDepth != 24 ||
|
||||
download.SampleRate != 96000 ||
|
||||
download.AlbumArtist != "Album Artist" ||
|
||||
download.LyricsLRC != "[00:00.00]Line" ||
|
||||
download.Decryption == nil ||
|
||||
download.Decryption.InputFormat != "m4a" ||
|
||||
download.Decryption.Options["map"] != "0:a" {
|
||||
t.Fatalf("unexpected download result: %+v", download)
|
||||
}
|
||||
|
||||
availabilityValue, err := vm.RunString(`({ available: true, trackId: "track-1", skipFallback: true, reason: "direct" })`)
|
||||
if err != nil {
|
||||
t.Fatalf("build availability value: %v", err)
|
||||
}
|
||||
availability := parseExtensionAvailabilityValue(vm, availabilityValue)
|
||||
if !availability.Available || availability.TrackID != "track-1" || !availability.SkipFallback || availability.Reason != "direct" {
|
||||
t.Fatalf("unexpected availability: %+v", availability)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionURLHandleResult(t *testing.T) {
|
||||
vm := goja.New()
|
||||
value, err := vm.RunString(`({
|
||||
type: "album",
|
||||
name: "Shared Album",
|
||||
coverUrl: "https://img.test/shared.jpg",
|
||||
track: { id: "track-1", name: "Song" },
|
||||
tracks: [{ id: "track-2", name: "Song 2" }],
|
||||
album: { id: "album-1", name: "Album", tracks: [{ id: "track-3", name: "Song 3" }] },
|
||||
artist: { id: "artist-1", name: "Artist", topTracks: [{ id: "track-4", name: "Song 4" }] }
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build URL handle value: %v", err)
|
||||
}
|
||||
|
||||
result, err := parseExtensionURLHandleValue(vm, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse URL handle: %v", err)
|
||||
}
|
||||
if result.Type != "album" ||
|
||||
result.CoverURL != "https://img.test/shared.jpg" ||
|
||||
result.Track == nil ||
|
||||
result.Track.ID != "track-1" ||
|
||||
len(result.Tracks) != 1 ||
|
||||
result.Album == nil ||
|
||||
len(result.Album.Tracks) != 1 ||
|
||||
result.Artist == nil ||
|
||||
len(result.Artist.TopTracks) != 1 {
|
||||
t.Fatalf("unexpected URL handle result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionAuxiliaryResults(t *testing.T) {
|
||||
vm := goja.New()
|
||||
|
||||
matchValue, err := vm.RunString(`({ matched: true, trackId: "track-1", confidence: 0.92, reason: "isrc" })`)
|
||||
if err != nil {
|
||||
t.Fatalf("build match value: %v", err)
|
||||
}
|
||||
match := parseExtensionMatchTrackValue(vm, matchValue)
|
||||
if !match.Matched || match.TrackID != "track-1" || match.Confidence != 0.92 || match.Reason != "isrc" {
|
||||
t.Fatalf("unexpected match result: %+v", match)
|
||||
}
|
||||
|
||||
postValue, err := vm.RunString(`({ success: true, newFilePath: "/tmp/new.flac", newFileUri: "content://new", bitDepth: 24, sampleRate: 96000 })`)
|
||||
if err != nil {
|
||||
t.Fatalf("build post-process value: %v", err)
|
||||
}
|
||||
post := parseExtensionPostProcessValue(vm, postValue)
|
||||
if !post.Success || post.NewFilePath != "/tmp/new.flac" || post.NewFileURI != "content://new" || post.BitDepth != 24 || post.SampleRate != 96000 {
|
||||
t.Fatalf("unexpected post-process result: %+v", post)
|
||||
}
|
||||
|
||||
lyricsValue, err := vm.RunString(`({
|
||||
syncType: "LINE_SYNCED",
|
||||
instrumental: false,
|
||||
plainLyrics: "Line",
|
||||
provider: "Lyrics Provider",
|
||||
lines: [{ startTimeMs: 1000, words: "Line", endTimeMs: 2000 }]
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build lyrics value: %v", err)
|
||||
}
|
||||
lyrics, err := parseExtensionLyricsValue(vm, lyricsValue)
|
||||
if err != nil {
|
||||
t.Fatalf("parse lyrics: %v", err)
|
||||
}
|
||||
if lyrics.SyncType != "LINE_SYNCED" ||
|
||||
lyrics.PlainLyrics != "Line" ||
|
||||
lyrics.Provider != "Lyrics Provider" ||
|
||||
len(lyrics.Lines) != 1 ||
|
||||
lyrics.Lines[0].StartTimeMs != 1000 ||
|
||||
lyrics.Lines[0].EndTimeMs != 2000 {
|
||||
t.Fatalf("unexpected lyrics result: %+v", lyrics)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ type extensionRuntime struct {
|
||||
activeDownloadMu sync.RWMutex
|
||||
activeDownloadItemID string
|
||||
|
||||
activeRequestMu sync.RWMutex
|
||||
activeRequestID string
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
storageLoaded bool
|
||||
@@ -209,6 +212,24 @@ func (r *extensionRuntime) getActiveDownloadItemID() string {
|
||||
return r.activeDownloadItemID
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) setActiveRequestID(requestID string) {
|
||||
r.activeRequestMu.Lock()
|
||||
defer r.activeRequestMu.Unlock()
|
||||
r.activeRequestID = strings.TrimSpace(requestID)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) clearActiveRequestID() {
|
||||
r.activeRequestMu.Lock()
|
||||
defer r.activeRequestMu.Unlock()
|
||||
r.activeRequestID = ""
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) getActiveRequestID() string {
|
||||
r.activeRequestMu.RLock()
|
||||
defer r.activeRequestMu.RUnlock()
|
||||
return r.activeRequestID
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
@@ -216,7 +237,11 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re
|
||||
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
if itemID == "" {
|
||||
return req
|
||||
requestID := r.getActiveRequestID()
|
||||
if requestID == "" {
|
||||
return req
|
||||
}
|
||||
return req.WithContext(initExtensionRequestCancel(requestID))
|
||||
}
|
||||
|
||||
return req.WithContext(initDownloadCancel(itemID))
|
||||
@@ -479,6 +504,8 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("appUserAgent", r.appUserAgent)
|
||||
utilsObj.Set("sleep", r.sleep)
|
||||
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
|
||||
utilsObj.Set("isRequestCancelled", r.isRequestCancelled)
|
||||
utilsObj.Set("setDownloadStatus", r.setDownloadStatus)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
logObj := vm.NewObject()
|
||||
|
||||
@@ -461,7 +461,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
//lint:ignore SA1019 Blowfish is required for legacy extension crypto compatibility.
|
||||
"golang.org/x/crypto/blowfish"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
@@ -134,6 +135,8 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
|
||||
var onProgress goja.Callable
|
||||
var headers map[string]string
|
||||
var chunkedDownload bool
|
||||
var chunkSize int64
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
@@ -148,9 +151,30 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
onProgress = callable
|
||||
}
|
||||
}
|
||||
if chunked, ok := opts["chunked"]; ok {
|
||||
switch v := chunked.(type) {
|
||||
case bool:
|
||||
chunkedDownload = v
|
||||
case int64:
|
||||
if v > 0 {
|
||||
chunkedDownload = true
|
||||
chunkSize = v
|
||||
}
|
||||
case float64:
|
||||
if v > 0 {
|
||||
chunkedDownload = true
|
||||
chunkSize = int64(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default chunk size: 1MB (YouTube CDN max without poToken)
|
||||
if chunkedDownload && chunkSize <= 0 {
|
||||
chunkSize = 1024 * 1024
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -159,6 +183,20 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
}
|
||||
|
||||
ua := appUserAgent()
|
||||
if h, ok := headers["User-Agent"]; ok && h != "" {
|
||||
ua = h
|
||||
}
|
||||
|
||||
if chunkedDownload {
|
||||
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -172,12 +210,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -189,7 +222,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||
@@ -205,14 +238,19 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
activeItemID := r.getActiveDownloadItemID()
|
||||
if activeItemID != "" && contentLength > 0 {
|
||||
if activeItemID != "" {
|
||||
SetItemDownloading(activeItemID)
|
||||
}
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
|
||||
if shouldTrackItemBytes && contentLength > 0 {
|
||||
SetItemBytesTotal(activeItemID, contentLength)
|
||||
}
|
||||
|
||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||
if activeItemID != "" {
|
||||
if shouldTrackItemBytes {
|
||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||
}
|
||||
|
||||
@@ -272,6 +310,231 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileDownloadChunked downloads a URL using sequential Range requests.
|
||||
// This is needed for servers (like YouTube's googlevideo CDN) that reject
|
||||
// non-ranged or large-range requests with 403 and require small chunk downloads.
|
||||
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable) goja.Value {
|
||||
// First, get the total content length with a small probe request
|
||||
probeReq, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: probe request error: %v", err),
|
||||
})
|
||||
}
|
||||
probeReq = r.bindDownloadCancelContext(probeReq)
|
||||
probeReq.Header.Set("User-Agent", ua)
|
||||
for k, v := range headers {
|
||||
if k != "Range" { // Don't copy any existing Range header
|
||||
probeReq.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
probeReq.Header.Set("Range", "bytes=0-1")
|
||||
|
||||
probeResp, err := client.Do(probeReq)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: probe error: %v", err),
|
||||
})
|
||||
}
|
||||
io.Copy(io.Discard, probeResp.Body)
|
||||
probeResp.Body.Close()
|
||||
|
||||
if probeResp.StatusCode != 206 && probeResp.StatusCode != 200 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: probe HTTP %d", probeResp.StatusCode),
|
||||
})
|
||||
}
|
||||
|
||||
// Parse Content-Range to get total size: "bytes 0-1/TOTAL"
|
||||
var totalSize int64
|
||||
contentRange := probeResp.Header.Get("Content-Range")
|
||||
if contentRange != "" {
|
||||
// Format: "bytes 0-1/12345"
|
||||
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
|
||||
sizeStr := contentRange[idx+1:]
|
||||
if sizeStr != "*" {
|
||||
fmt.Sscanf(sizeStr, "%d", &totalSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalSize <= 0 {
|
||||
// Fallback: try Content-Length from a HEAD-like approach
|
||||
// If we can't determine size, download with unknown size
|
||||
GoLog("[Extension:%s] Chunked download: unknown total size, will download until server says done\n", r.extensionID)
|
||||
} else {
|
||||
GoLog("[Extension:%s] Chunked download: total size %d bytes, chunk size %d\n", r.extensionID, totalSize, chunkSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||
})
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
activeItemID := r.getActiveDownloadItemID()
|
||||
if activeItemID != "" {
|
||||
SetItemDownloading(activeItemID)
|
||||
}
|
||||
|
||||
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
|
||||
if shouldTrackItemBytes && totalSize > 0 {
|
||||
SetItemBytesTotal(activeItemID, totalSize)
|
||||
}
|
||||
|
||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||
if shouldTrackItemBytes {
|
||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||
}
|
||||
|
||||
var totalWritten int64
|
||||
buf := make([]byte, 32*1024)
|
||||
maxRetries := 3
|
||||
|
||||
for offset := int64(0); totalSize <= 0 || offset < totalSize; {
|
||||
end := offset + chunkSize - 1
|
||||
if totalSize > 0 && end >= totalSize {
|
||||
end = totalSize - 1
|
||||
}
|
||||
|
||||
var chunkResp *http.Response
|
||||
var chunkErr error
|
||||
|
||||
for retry := 0; retry < maxRetries; retry++ {
|
||||
chunkReq, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: request error at offset %d: %v", offset, err),
|
||||
})
|
||||
}
|
||||
chunkReq = r.bindDownloadCancelContext(chunkReq)
|
||||
chunkReq.Header.Set("User-Agent", ua)
|
||||
for k, v := range headers {
|
||||
if k != "Range" {
|
||||
chunkReq.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
chunkReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, end))
|
||||
|
||||
chunkResp, chunkErr = client.Do(chunkReq)
|
||||
if chunkErr != nil {
|
||||
if retry < maxRetries-1 {
|
||||
time.Sleep(time.Duration(retry+1) * time.Second)
|
||||
continue
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: error at offset %d after %d retries: %v", offset, maxRetries, chunkErr),
|
||||
})
|
||||
}
|
||||
|
||||
if chunkResp.StatusCode == 206 || chunkResp.StatusCode == 200 {
|
||||
break // Success
|
||||
}
|
||||
|
||||
// Non-success status
|
||||
io.Copy(io.Discard, chunkResp.Body)
|
||||
chunkResp.Body.Close()
|
||||
|
||||
if chunkResp.StatusCode == 403 || chunkResp.StatusCode == 429 {
|
||||
if retry < maxRetries-1 {
|
||||
time.Sleep(time.Duration(retry+1) * 2 * time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: HTTP %d at offset %d", chunkResp.StatusCode, offset),
|
||||
})
|
||||
}
|
||||
|
||||
// Read chunk body and write to file
|
||||
chunkWritten := int64(0)
|
||||
for {
|
||||
nr, er := chunkResp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := progressWriter.Write(buf[0:nr])
|
||||
if nw < 0 || nr < nw {
|
||||
nw = 0
|
||||
if ew == nil {
|
||||
ew = fmt.Errorf("invalid write result")
|
||||
}
|
||||
}
|
||||
chunkWritten += int64(nw)
|
||||
totalWritten += int64(nw)
|
||||
if ew != nil {
|
||||
chunkResp.Body.Close()
|
||||
if ew == ErrDownloadCancelled {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "download cancelled",
|
||||
})
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||
})
|
||||
}
|
||||
if nr != nw {
|
||||
chunkResp.Body.Close()
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "short write",
|
||||
})
|
||||
}
|
||||
|
||||
if onProgress != nil && totalSize > 0 {
|
||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize))
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if er != io.EOF {
|
||||
chunkResp.Body.Close()
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read chunk at offset %d: %v", offset, er),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
chunkResp.Body.Close()
|
||||
|
||||
offset += chunkWritten
|
||||
|
||||
// If server returned 200 (full content) instead of 206, we're done
|
||||
if chunkResp.StatusCode == 200 {
|
||||
break
|
||||
}
|
||||
|
||||
// If we got less data than expected and we know total size, check if done
|
||||
if totalSize > 0 && offset >= totalSize {
|
||||
break
|
||||
}
|
||||
|
||||
// Unknown size: if we got less than chunk size, assume done
|
||||
if totalSize <= 0 && chunkWritten < chunkSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
"size": totalWritten,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
|
||||
@@ -17,6 +17,24 @@ type HTTPResponse struct {
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
const maxExtensionHTTPResponseBytes = 16 << 20
|
||||
|
||||
func readExtensionHTTPResponseBody(resp *http.Response) ([]byte, error) {
|
||||
body, err := io.ReadAll(
|
||||
io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes+1),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > maxExtensionHTTPResponseBytes {
|
||||
return nil, fmt.Errorf(
|
||||
"response body exceeds %d byte limit; use file.download for large media",
|
||||
maxExtensionHTTPResponseBytes,
|
||||
)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
@@ -99,7 +117,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
@@ -197,7 +215,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
@@ -307,7 +325,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
@@ -433,7 +451,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
|
||||
@@ -75,7 +75,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -340,16 +340,6 @@ func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.credentialsMu.RLock()
|
||||
defer r.credentialsMu.RUnlock()
|
||||
return cloneInterfaceMap(r.credentialsCache), nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
||||
vm := goja.New()
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: "auth-ext",
|
||||
manifest: &ExtensionManifest{
|
||||
Name: "auth-ext",
|
||||
Description: "Auth extension",
|
||||
Version: "1.0.0",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"auth.example.com", "token.example.com", "api.example.com"},
|
||||
},
|
||||
},
|
||||
settings: map[string]interface{}{},
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Host {
|
||||
case "token.example.com":
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"access_token":"access","refresh_token":"refresh","expires_in":3600}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
case "api.example.com":
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"X-Test": []string{"yes"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"ok":true,"items":[1,2]}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})},
|
||||
vm: vm,
|
||||
}
|
||||
|
||||
if err := validateExtensionAuthURL("https://user:pass@auth.example.com/login"); err == nil {
|
||||
t.Fatal("expected embedded credential error")
|
||||
}
|
||||
if err := validateExtensionAuthURL("http://auth.example.com/login"); err == nil {
|
||||
t.Fatal("expected non-https auth URL error")
|
||||
}
|
||||
if got := summarizeURLForLog("https://auth.example.com/login?token=secret"); got != "https://auth.example.com/login" {
|
||||
t.Fatalf("summary = %q", got)
|
||||
}
|
||||
|
||||
openResult := runtime.authOpenUrl(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("https://auth.example.com/login"),
|
||||
vm.ToValue("app://callback"),
|
||||
}}).Export().(map[string]interface{})
|
||||
if openResult["success"] != true {
|
||||
t.Fatalf("authOpenUrl = %#v", openResult)
|
||||
}
|
||||
if pending := GetPendingAuthRequest("auth-ext"); pending == nil || pending.AuthURL == "" {
|
||||
t.Fatalf("pending auth = %#v", pending)
|
||||
}
|
||||
if code := runtime.authGetCode(goja.FunctionCall{}); !goja.IsUndefined(code) {
|
||||
t.Fatalf("expected undefined code, got %v", code)
|
||||
}
|
||||
if ok := runtime.authSetCode(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"code": "abc", "access_token": "access", "refresh_token": "refresh", "expires_in": float64(60)})}}); !ok.ToBoolean() {
|
||||
t.Fatal("authSetCode returned false")
|
||||
}
|
||||
if code := runtime.authGetCode(goja.FunctionCall{}).String(); code != "abc" {
|
||||
t.Fatalf("code = %q", code)
|
||||
}
|
||||
if !runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
|
||||
t.Fatal("expected authenticated runtime")
|
||||
}
|
||||
tokens := runtime.authGetTokens(goja.FunctionCall{}).Export().(map[string]interface{})
|
||||
if tokens["access_token"] != "access" {
|
||||
t.Fatalf("tokens = %#v", tokens)
|
||||
}
|
||||
|
||||
pkce := runtime.authGeneratePKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(50))}}).Export().(map[string]interface{})
|
||||
if pkce["method"] != "S256" || pkce["verifier"] == "" || pkce["challenge"] == "" {
|
||||
t.Fatalf("pkce = %#v", pkce)
|
||||
}
|
||||
if current := runtime.authGetPKCE(goja.FunctionCall{}).Export().(map[string]interface{}); current["verifier"] == "" {
|
||||
t.Fatalf("current pkce = %#v", current)
|
||||
}
|
||||
oauthConfig := map[string]interface{}{
|
||||
"authUrl": "https://auth.example.com/oauth",
|
||||
"clientId": "client",
|
||||
"redirectUri": "app://callback",
|
||||
"scope": "read",
|
||||
"extraParams": map[string]interface{}{"prompt": "login"},
|
||||
}
|
||||
oauth := runtime.authStartOAuthWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(oauthConfig)}}).Export().(map[string]interface{})
|
||||
if oauth["success"] != true || !strings.Contains(oauth["authUrl"].(string), "code_challenge") {
|
||||
t.Fatalf("oauth = %#v", oauth)
|
||||
}
|
||||
tokenConfig := map[string]interface{}{
|
||||
"tokenUrl": "https://token.example.com/token",
|
||||
"clientId": "client",
|
||||
"redirectUri": "app://callback",
|
||||
"code": "abc",
|
||||
}
|
||||
token := runtime.authExchangeCodeWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(tokenConfig)}}).Export().(map[string]interface{})
|
||||
if token["success"] != true || token["access_token"] != "access" {
|
||||
t.Fatalf("token = %#v", token)
|
||||
}
|
||||
|
||||
runtime.registerTextEncoderDecoder(vm)
|
||||
runtime.registerURLClass(vm)
|
||||
runtime.registerJSONGlobal(vm)
|
||||
vm.Set("fetch", func(call goja.FunctionCall) goja.Value {
|
||||
return runtime.fetchPolyfill(call)
|
||||
})
|
||||
vm.Set("atob", func(call goja.FunctionCall) goja.Value {
|
||||
return runtime.atobPolyfill(call)
|
||||
})
|
||||
vm.Set("btoa", func(call goja.FunctionCall) goja.Value {
|
||||
return runtime.btoaPolyfill(call)
|
||||
})
|
||||
|
||||
value, err := vm.RunString(`
|
||||
var encoded = btoa("hello");
|
||||
var decoded = atob(encoded);
|
||||
var te = new TextEncoder();
|
||||
var bytes = te.encode("hi");
|
||||
var into = te.encodeInto("hi", []);
|
||||
var td = new TextDecoder();
|
||||
var text = td.decode(bytes);
|
||||
var url = new URL("/path?a=1&a=2#frag", "https://api.example.com/base");
|
||||
var params = new URLSearchParams("?x=1");
|
||||
params.append("x", "2");
|
||||
params.set("y", "3");
|
||||
var response = fetch("https://api.example.com/data", {method: "POST", body: {q: "x"}, headers: {"X-Client": "test"}});
|
||||
JSON.stringify({
|
||||
encoded: encoded,
|
||||
decoded: decoded,
|
||||
text: text,
|
||||
read: into.read,
|
||||
host: url.hostname,
|
||||
first: url.searchParams.get("a"),
|
||||
all: url.searchParams.getAll("a").length,
|
||||
params: params.toString(),
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
jsonOk: response.json().ok,
|
||||
bufferLen: response.arrayBuffer().length
|
||||
});
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("polyfill script: %v", err)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(value.String()), &result); err != nil {
|
||||
t.Fatalf("decode polyfill result: %v", err)
|
||||
}
|
||||
if result["decoded"] != "hello" || result["host"] != "api.example.com" || result["ok"] != true {
|
||||
t.Fatalf("polyfill result = %#v", result)
|
||||
}
|
||||
|
||||
blocked := runtime.fetchPolyfill(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://blocked.example.com")}}).ToObject(vm)
|
||||
if blocked.Get("ok").ToBoolean() {
|
||||
t.Fatal("expected blocked fetch")
|
||||
}
|
||||
runtime.authClear(goja.FunctionCall{})
|
||||
if runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
|
||||
t.Fatal("expected auth cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := &extensionStore{
|
||||
registryURL: "https://registry.example.com/registry.json",
|
||||
cacheDir: dir,
|
||||
cacheTTL: time.Hour,
|
||||
cache: &storeRegistry{
|
||||
Version: 1,
|
||||
UpdatedAt: "2026-05-04",
|
||||
Extensions: []storeExtension{
|
||||
{
|
||||
ID: "coverage-ext",
|
||||
Name: "coverage-ext",
|
||||
DisplayNameAlt: "Coverage Extension",
|
||||
Version: "2.0.0",
|
||||
Description: "Metadata and lyrics provider",
|
||||
DownloadURLAlt: "https://registry.example.com/coverage.spotiflac-ext",
|
||||
IconURLAlt: "https://registry.example.com/icon.png",
|
||||
Category: CategoryMetadata,
|
||||
Tags: []string{"metadata", "lyrics"},
|
||||
Downloads: 10,
|
||||
UpdatedAt: "2026-05-04",
|
||||
MinAppVersionAlt: "4.5.0",
|
||||
},
|
||||
{
|
||||
ID: "utility-ext",
|
||||
Name: "utility-ext",
|
||||
Version: "1.0.0",
|
||||
Description: "Utility",
|
||||
DownloadURL: "https://registry.example.com/utility.spotiflac-ext",
|
||||
Category: CategoryUtility,
|
||||
UpdatedAt: "2026-05-04",
|
||||
},
|
||||
},
|
||||
},
|
||||
cacheTime: time.Now(),
|
||||
}
|
||||
store.saveDiskCache()
|
||||
loadedStore := &extensionStore{cacheDir: dir}
|
||||
loadedStore.loadDiskCache()
|
||||
if loadedStore.cache == nil || len(loadedStore.cache.Extensions) != 2 {
|
||||
t.Fatalf("loaded cache = %#v", loadedStore.cache)
|
||||
}
|
||||
if got := store.getRegistryURL(); got != "https://registry.example.com/registry.json" {
|
||||
t.Fatalf("registry URL = %q", got)
|
||||
}
|
||||
store.setRegistryURL("https://registry.example.com/new.json")
|
||||
if store.cache != nil {
|
||||
t.Fatal("expected cache reset after registry URL change")
|
||||
}
|
||||
store.cache = loadedStore.cache
|
||||
store.cacheTime = time.Now()
|
||||
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
if manager.extensions == nil {
|
||||
manager.extensions = map[string]*loadedExtension{}
|
||||
}
|
||||
manager.extensions["coverage-ext"] = &loadedExtension{
|
||||
ID: "coverage-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "coverage-ext",
|
||||
DisplayName: "Coverage Extension",
|
||||
Version: "1.0.0",
|
||||
Description: "Installed",
|
||||
Types: []ExtensionType{ExtensionTypeMetadataProvider},
|
||||
},
|
||||
Enabled: true,
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
defer func() {
|
||||
manager.mu.Lock()
|
||||
delete(manager.extensions, "coverage-ext")
|
||||
manager.mu.Unlock()
|
||||
}()
|
||||
|
||||
extensions, err := store.getExtensionsWithStatus(false)
|
||||
if err != nil {
|
||||
t.Fatalf("getExtensionsWithStatus: %v", err)
|
||||
}
|
||||
if len(extensions) != 2 || !extensions[0].IsInstalled || !extensions[0].HasUpdate {
|
||||
t.Fatalf("extensions = %#v", extensions)
|
||||
}
|
||||
found, err := store.searchExtensions("lyrics", CategoryMetadata)
|
||||
if err != nil || len(found) != 1 || found[0].ID != "coverage-ext" {
|
||||
t.Fatalf("search = %#v/%v", found, err)
|
||||
}
|
||||
all, err := store.searchExtensions("", "")
|
||||
if err != nil || len(all) != 2 {
|
||||
t.Fatalf("all search = %#v/%v", all, err)
|
||||
}
|
||||
if cats := store.getCategories(); len(cats) != 5 {
|
||||
t.Fatalf("categories = %#v", cats)
|
||||
}
|
||||
if !containsIgnoreCase("Hello Metadata", "metadata") || findSubstring("abcdef", "cd") != 2 || containsStr("abc", "z") {
|
||||
t.Fatal("string helper mismatch")
|
||||
}
|
||||
if err := requireHTTPSURL("http://example.com", "registry"); err == nil {
|
||||
t.Fatal("expected HTTPS validation error")
|
||||
}
|
||||
if _, err := resolveRegistryURL(""); err == nil {
|
||||
t.Fatal("expected empty registry URL error")
|
||||
}
|
||||
if resolved, err := resolveRegistryURL("http://github.com/owner/repo"); err != nil || !strings.Contains(resolved, "raw.githubusercontent.com/owner/repo") {
|
||||
t.Fatalf("resolved registry = %q/%v", resolved, err)
|
||||
}
|
||||
store.clearCache()
|
||||
if store.cache != nil {
|
||||
t.Fatal("expected cleared store cache")
|
||||
}
|
||||
|
||||
settingsStore := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
|
||||
if err := settingsStore.SetDataDir(filepath.Join(dir, "settings")); err != nil {
|
||||
t.Fatalf("SetDataDir: %v", err)
|
||||
}
|
||||
if err := settingsStore.Set("ext", "quality", "lossless"); err != nil {
|
||||
t.Fatalf("settings Set: %v", err)
|
||||
}
|
||||
if value, err := settingsStore.Get("ext", "quality"); err != nil || value != "lossless" {
|
||||
t.Fatalf("settings Get = %#v/%v", value, err)
|
||||
}
|
||||
if _, err := settingsStore.Get("ext", "missing"); err == nil {
|
||||
t.Fatal("expected missing setting error")
|
||||
}
|
||||
if err := settingsStore.SetAll("ext", map[string]interface{}{"a": float64(1), "_secret": "hidden"}); err != nil {
|
||||
t.Fatalf("settings SetAll: %v", err)
|
||||
}
|
||||
if all := settingsStore.GetAll("ext"); all["a"] != float64(1) {
|
||||
t.Fatalf("settings all = %#v", all)
|
||||
}
|
||||
if err := settingsStore.Remove("ext", "a"); err != nil {
|
||||
t.Fatalf("settings Remove: %v", err)
|
||||
}
|
||||
if err := settingsStore.RemoveAll("ext"); err != nil {
|
||||
t.Fatalf("settings RemoveAll: %v", err)
|
||||
}
|
||||
if jsonText, err := settingsStore.GetAllExtensionSettingsJSON(); err != nil || jsonText == "" {
|
||||
t.Fatalf("settings JSON = %q/%v", jsonText, err)
|
||||
}
|
||||
reloaded := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
|
||||
if err := reloaded.SetDataDir(settingsStore.dataDir); err != nil {
|
||||
t.Fatalf("reload settings: %v", err)
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: "storage-ext",
|
||||
dataDir: filepath.Join(dir, "runtime"),
|
||||
vm: vm,
|
||||
storageFlushDelay: time.Hour,
|
||||
}
|
||||
if err := os.MkdirAll(runtime.dataDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := runtime.storageGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("missing"), vm.ToValue("fallback")}}).String(); got != "fallback" {
|
||||
t.Fatalf("storage fallback = %q", got)
|
||||
}
|
||||
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
|
||||
t.Fatal("storageSet false")
|
||||
}
|
||||
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
|
||||
t.Fatal("storageSet equal false")
|
||||
}
|
||||
loaded, err := runtime.loadStorage()
|
||||
if err != nil || loaded["key"] == nil {
|
||||
t.Fatalf("loadStorage = %#v/%v", loaded, err)
|
||||
}
|
||||
if err := runtime.flushStorageNow(); err != nil {
|
||||
t.Fatalf("flushStorageNow: %v", err)
|
||||
}
|
||||
if ok := runtime.storageRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key")}}); !ok.ToBoolean() {
|
||||
t.Fatal("storageRemove false")
|
||||
}
|
||||
runtime.closeStorageFlusher()
|
||||
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("after_close"), vm.ToValue("x")}}); ok.ToBoolean() {
|
||||
t.Fatal("expected storageSet false after close")
|
||||
}
|
||||
|
||||
credRuntime := &extensionRuntime{
|
||||
extensionID: "cred-ext",
|
||||
dataDir: filepath.Join(dir, "creds"),
|
||||
vm: vm,
|
||||
}
|
||||
if err := os.MkdirAll(credRuntime.dataDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := credRuntime.credentialsStore(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token"), vm.ToValue("secret")}}).Export().(map[string]interface{}); result["success"] != true {
|
||||
t.Fatalf("credentialsStore = %#v", result)
|
||||
}
|
||||
if got := credRuntime.credentialsGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).String(); got != "secret" {
|
||||
t.Fatalf("credential = %q", got)
|
||||
}
|
||||
if !credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
|
||||
t.Fatal("expected credential")
|
||||
}
|
||||
if ok := credRuntime.credentialsRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}); !ok.ToBoolean() {
|
||||
t.Fatal("credentialsRemove false")
|
||||
}
|
||||
if credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
|
||||
t.Fatal("expected credential removed")
|
||||
}
|
||||
key, err := credRuntime.getEncryptionKey()
|
||||
if err != nil {
|
||||
t.Fatalf("getEncryptionKey: %v", err)
|
||||
}
|
||||
encrypted, err := encryptAES([]byte("plain"), key)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptAES: %v", err)
|
||||
}
|
||||
decrypted, err := decryptAES(encrypted, key)
|
||||
if err != nil || string(decrypted) != "plain" {
|
||||
t.Fatalf("decryptAES = %q/%v", decrypted, err)
|
||||
}
|
||||
if _, err := decryptAES([]byte("short"), key); err == nil {
|
||||
t.Fatal("expected short ciphertext error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntimeHTTPMatchingAndMetadataHelpers(t *testing.T) {
|
||||
vm := goja.New()
|
||||
jar, _ := newSimpleCookieJar()
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: "http-ext",
|
||||
manifest: &ExtensionManifest{
|
||||
Name: "http-ext",
|
||||
Description: "HTTP extension",
|
||||
Version: "1.0.0",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.example.com"},
|
||||
},
|
||||
},
|
||||
vm: vm,
|
||||
cookieJar: jar,
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
var body []byte
|
||||
if req.Body != nil {
|
||||
body, _ = io.ReadAll(req.Body)
|
||||
}
|
||||
header := make(http.Header)
|
||||
header.Set("X-Method", req.Method)
|
||||
if req.URL.Path == "/huge" {
|
||||
return &http.Response{StatusCode: 200, Header: header, Body: io.NopCloser(io.LimitReader(strings.NewReader(strings.Repeat("x", maxExtensionHTTPResponseBytes+2)), maxExtensionHTTPResponseBytes+2)), Request: req}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: 201,
|
||||
Header: header,
|
||||
Body: io.NopCloser(strings.NewReader(req.Method + ":" + string(body))),
|
||||
Request: req,
|
||||
}, nil
|
||||
})},
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||
t.Fatalf("validateDomain allowed: %v", err)
|
||||
}
|
||||
for _, rawURL := range []string{"notaurl", "http://api.example.com", "https://user:pass@api.example.com", "https://127.0.0.1/x", "https://blocked.example.com/x"} {
|
||||
if err := runtime.validateDomain(rawURL); err == nil {
|
||||
t.Fatalf("expected domain validation error for %s", rawURL)
|
||||
}
|
||||
}
|
||||
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/get"), vm.ToValue(map[string]interface{}{"X-Test": "yes"})}}).Export().(map[string]interface{}); got["status"] != 201 || !strings.Contains(got["body"].(string), "GET") {
|
||||
t.Fatalf("httpGet = %#v", got)
|
||||
}
|
||||
if got := runtime.httpPost(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/post"), vm.ToValue(map[string]interface{}{"a": "b"})}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "POST") {
|
||||
t.Fatalf("httpPost = %#v", got)
|
||||
}
|
||||
requestOptions := map[string]interface{}{"method": "patch", "body": []interface{}{"x"}, "headers": map[string]interface{}{"X-Req": "1"}}
|
||||
if got := runtime.httpRequest(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/request"), vm.ToValue(requestOptions)}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "PATCH") {
|
||||
t.Fatalf("httpRequest = %#v", got)
|
||||
}
|
||||
for _, method := range []struct {
|
||||
name string
|
||||
call func(goja.FunctionCall) goja.Value
|
||||
args []goja.Value
|
||||
}{
|
||||
{name: "PUT", call: runtime.httpPut, args: []goja.Value{vm.ToValue("https://api.example.com/put"), vm.ToValue("body")}},
|
||||
{name: "DELETE", call: runtime.httpDelete, args: []goja.Value{vm.ToValue("https://api.example.com/delete"), vm.ToValue(map[string]interface{}{"X-Delete": "1"})}},
|
||||
{name: "PATCH", call: runtime.httpPatch, args: []goja.Value{vm.ToValue("https://api.example.com/patch"), vm.ToValue(map[string]interface{}{"p": "q"})}},
|
||||
} {
|
||||
if got := method.call(goja.FunctionCall{Arguments: method.args}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), method.name) {
|
||||
t.Fatalf("%s = %#v", method.name, got)
|
||||
}
|
||||
}
|
||||
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/huge")}}).Export().(map[string]interface{}); !strings.Contains(got["error"].(string), "exceeds") {
|
||||
t.Fatalf("huge response = %#v", got)
|
||||
}
|
||||
if !runtime.httpClearCookies(goja.FunctionCall{}).ToBoolean() {
|
||||
t.Fatal("expected cookies cleared")
|
||||
}
|
||||
|
||||
if runtime.matchingCompareStrings(goja.FunctionCall{}).ToFloat() != 0 {
|
||||
t.Fatal("missing string compare args should be zero")
|
||||
}
|
||||
if runtime.matchingCompareStrings(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song"), vm.ToValue("song")}}).ToFloat() != 1 {
|
||||
t.Fatal("expected exact string similarity")
|
||||
}
|
||||
if runtime.matchingCompareDuration(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(180000), vm.ToValue(182000)}}).ToBoolean() != true {
|
||||
t.Fatal("expected duration match")
|
||||
}
|
||||
if runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String() != "song" {
|
||||
t.Fatalf("normalized = %q", runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String())
|
||||
}
|
||||
|
||||
if formatMusicBrainzGenre([]musicBrainzTag{{Count: 1, Name: "rock"}, {Count: 5, Name: "electronic"}, {Count: 10, Name: "rock"}}) != "Electronic" {
|
||||
t.Fatal("unexpected genre selection")
|
||||
}
|
||||
credits := []musicBrainzArtistCredit{{Name: "A", JoinPhrase: " & "}, {Name: "B"}}
|
||||
if formatMusicBrainzArtistCredit(credits) != "A & B" {
|
||||
t.Fatal("artist credit format mismatch")
|
||||
}
|
||||
releases := []musicBrainzRelease{
|
||||
{Title: "Other", ArtistCredit: []musicBrainzArtistCredit{{Name: "Fallback"}}},
|
||||
{Title: "Album", ArtistCredit: credits},
|
||||
}
|
||||
if selectMusicBrainzAlbumArtist(releases, "Album") != "A & B" || selectMusicBrainzAlbumArtist(releases, "") != "Fallback" {
|
||||
t.Fatal("album artist selection mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntimeFileAPIs(t *testing.T) {
|
||||
vm := goja.New()
|
||||
dir := t.TempDir()
|
||||
SetAllowedDownloadDirs(nil)
|
||||
defer SetAllowedDownloadDirs(nil)
|
||||
|
||||
fileBody := "chunk"
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: "file-ext",
|
||||
manifest: &ExtensionManifest{
|
||||
Name: "file-ext",
|
||||
Description: "File extension",
|
||||
Version: "1.0.0",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: true,
|
||||
Network: []string{"files.example.com"},
|
||||
},
|
||||
},
|
||||
dataDir: dir,
|
||||
vm: vm,
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("Range") == "" {
|
||||
body := "downloaded"
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
ContentLength: int64(len(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
rangeHeader := req.Header.Get("Range")
|
||||
start, end := 0, len(fileBody)-1
|
||||
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil {
|
||||
start, end = 0, 1
|
||||
}
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end >= len(fileBody) {
|
||||
end = len(fileBody) - 1
|
||||
}
|
||||
if start > len(fileBody) {
|
||||
start = len(fileBody)
|
||||
}
|
||||
body := fileBody[start : end+1]
|
||||
header := http.Header{"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(fileBody))}}
|
||||
return &http.Response{StatusCode: 206, Header: header, Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||
})},
|
||||
}
|
||||
runtime.downloadClient = runtime.httpClient
|
||||
|
||||
if _, err := (&extensionRuntime{manifest: &ExtensionManifest{}}).validatePath("x"); err == nil {
|
||||
t.Fatal("expected file permission error")
|
||||
}
|
||||
if _, err := runtime.validatePath("../escape.txt"); err == nil {
|
||||
t.Fatal("expected sandbox escape error")
|
||||
}
|
||||
AddAllowedDownloadDir(dir)
|
||||
absolutePath := filepath.Join(dir, "allowed.txt")
|
||||
if got, err := runtime.validatePath(absolutePath); err != nil || got != absolutePath {
|
||||
t.Fatalf("absolute validatePath = %q/%v", got, err)
|
||||
}
|
||||
|
||||
write := runtime.fileWrite(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt"), vm.ToValue("hello")}}).Export().(map[string]interface{})
|
||||
if write["success"] != true {
|
||||
t.Fatalf("fileWrite = %#v", write)
|
||||
}
|
||||
if !runtime.fileExists(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).ToBoolean() {
|
||||
t.Fatal("expected written file to exist")
|
||||
}
|
||||
read := runtime.fileRead(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).Export().(map[string]interface{})
|
||||
if read["data"] != "hello" {
|
||||
t.Fatalf("fileRead = %#v", read)
|
||||
}
|
||||
|
||||
writeBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("nested/bytes.bin"),
|
||||
vm.ToValue("4869"),
|
||||
vm.ToValue(map[string]interface{}{"encoding": "hex", "truncate": true}),
|
||||
}}).Export().(map[string]interface{})
|
||||
if writeBytes["success"] != true {
|
||||
t.Fatalf("fileWriteBytes = %#v", writeBytes)
|
||||
}
|
||||
appendBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("nested/bytes.bin"),
|
||||
vm.ToValue([]interface{}{float64('!')}),
|
||||
vm.ToValue(map[string]interface{}{"append": true}),
|
||||
}}).Export().(map[string]interface{})
|
||||
if appendBytes["success"] != true {
|
||||
t.Fatalf("append fileWriteBytes = %#v", appendBytes)
|
||||
}
|
||||
readBytes := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("nested/bytes.bin"),
|
||||
vm.ToValue(map[string]interface{}{"encoding": "text", "offset": float64(1), "length": float64(2)}),
|
||||
}}).Export().(map[string]interface{})
|
||||
if readBytes["data"] != "i!" || readBytes["bytes_read"] != 2 {
|
||||
t.Fatalf("fileReadBytes = %#v", readBytes)
|
||||
}
|
||||
if bad := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("nested/bad.bin"),
|
||||
vm.ToValue("x"),
|
||||
vm.ToValue(map[string]interface{}{"append": true, "offset": float64(1)}),
|
||||
}}).Export().(map[string]interface{}); bad["success"] != false {
|
||||
t.Fatalf("expected append+offset failure, got %#v", bad)
|
||||
}
|
||||
if bad := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("nested/bytes.bin"),
|
||||
vm.ToValue(map[string]interface{}{"encoding": "bad"}),
|
||||
}}).Export().(map[string]interface{}); bad["success"] != false {
|
||||
t.Fatalf("expected bad encoding failure, got %#v", bad)
|
||||
}
|
||||
|
||||
copyResult := runtime.fileCopy(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/bytes.bin"), vm.ToValue("nested/copy.bin")}}).Export().(map[string]interface{})
|
||||
if copyResult["success"] != true {
|
||||
t.Fatalf("fileCopy = %#v", copyResult)
|
||||
}
|
||||
moveResult := runtime.fileMove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/copy.bin"), vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
||||
if moveResult["success"] != true {
|
||||
t.Fatalf("fileMove = %#v", moveResult)
|
||||
}
|
||||
sizeResult := runtime.fileGetSize(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
||||
if sizeResult["success"] != true || sizeResult["size"] != int64(3) {
|
||||
t.Fatalf("fileGetSize = %#v", sizeResult)
|
||||
}
|
||||
deleteResult := runtime.fileDelete(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
||||
if deleteResult["success"] != true {
|
||||
t.Fatalf("fileDelete = %#v", deleteResult)
|
||||
}
|
||||
|
||||
download := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("https://files.example.com/file"),
|
||||
vm.ToValue("downloads/file.bin"),
|
||||
}}).Export().(map[string]interface{})
|
||||
if download["success"] != true {
|
||||
t.Fatalf("fileDownload = %#v", download)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(dir, "downloads/file.bin")); err != nil || string(data) != "downloaded" {
|
||||
t.Fatalf("downloaded data = %q/%v", data, err)
|
||||
}
|
||||
|
||||
chunked := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("https://files.example.com/chunk"),
|
||||
vm.ToValue("downloads/chunk.bin"),
|
||||
vm.ToValue(map[string]interface{}{"chunked": float64(2), "headers": map[string]interface{}{"X-Test": "yes"}}),
|
||||
}}).Export().(map[string]interface{})
|
||||
if chunked["success"] != true {
|
||||
t.Fatalf("chunked fileDownload = %#v", chunked)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(dir, "downloads/chunk.bin")); err != nil || string(data) != fileBody {
|
||||
t.Fatalf("chunked data = %q/%v", data, err)
|
||||
}
|
||||
|
||||
if missing := runtime.fileDownload(goja.FunctionCall{}).Export().(map[string]interface{}); missing["success"] != false {
|
||||
t.Fatalf("expected missing download args error, got %#v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntimeUtilityAPIs(t *testing.T) {
|
||||
vm := goja.New()
|
||||
runtime := &extensionRuntime{extensionID: "utils-ext", vm: vm}
|
||||
|
||||
if runtime.sha256Hash(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("abc")}}).String() == "" {
|
||||
t.Fatal("expected sha256")
|
||||
}
|
||||
if runtime.hmacSHA256(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
|
||||
t.Fatal("expected hmac sha256")
|
||||
}
|
||||
if runtime.hmacSHA256Base64(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
|
||||
t.Fatal("expected hmac sha256 base64")
|
||||
}
|
||||
if value := runtime.hmacSHA1(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue([]interface{}{float64(1), float64(2)}), vm.ToValue([]interface{}{float64(3)})}}); len(value.Export().([]interface{})) == 0 {
|
||||
t.Fatal("expected hmac sha1 bytes")
|
||||
}
|
||||
if !goja.IsUndefined(runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{bad`)}})) {
|
||||
t.Fatal("expected invalid JSON to return undefined")
|
||||
}
|
||||
parsed := runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{"ok":true}`)}}).Export().(map[string]interface{})
|
||||
if parsed["ok"] != true {
|
||||
t.Fatalf("parseJSON = %#v", parsed)
|
||||
}
|
||||
if text := runtime.stringifyJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"ok": true})}}).String(); !strings.Contains(text, "ok") {
|
||||
t.Fatalf("stringifyJSON = %q", text)
|
||||
}
|
||||
encrypted := runtime.cryptoEncrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("plain"), vm.ToValue("secret")}}).Export().(map[string]interface{})
|
||||
if encrypted["success"] != true || encrypted["data"] == "" {
|
||||
t.Fatalf("cryptoEncrypt = %#v", encrypted)
|
||||
}
|
||||
decrypted := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(encrypted["data"]), vm.ToValue("secret")}}).Export().(map[string]interface{})
|
||||
if decrypted["success"] != true || decrypted["data"] != "plain" {
|
||||
t.Fatalf("cryptoDecrypt = %#v", decrypted)
|
||||
}
|
||||
if bad := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("bad"), vm.ToValue("secret")}}).Export().(map[string]interface{}); bad["success"] != false {
|
||||
t.Fatalf("expected bad decrypt failure, got %#v", bad)
|
||||
}
|
||||
key := runtime.cryptoGenerateKey(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(8))}}).Export().(map[string]interface{})
|
||||
if key["success"] != true || key["key"] == "" || key["hex"] == "" {
|
||||
t.Fatalf("cryptoGenerateKey = %#v", key)
|
||||
}
|
||||
if runtime.randomUserAgent(goja.FunctionCall{}).String() == "" || runtime.appUserAgent(goja.FunctionCall{}).String() == "" {
|
||||
t.Fatal("expected user agents")
|
||||
}
|
||||
SetAppVersion("9.9.9")
|
||||
if runtime.appVersion(goja.FunctionCall{}).String() != "9.9.9" {
|
||||
t.Fatal("appVersion mismatch")
|
||||
}
|
||||
if !runtime.sleep(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(0))}}).ToBoolean() {
|
||||
t.Fatal("zero sleep should succeed")
|
||||
}
|
||||
|
||||
itemID := "utils-item"
|
||||
runtime.setActiveDownloadItemID(itemID)
|
||||
initDownloadCancel(itemID)
|
||||
if runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
|
||||
t.Fatal("item should not be cancelled yet")
|
||||
}
|
||||
runtime.setDownloadStatus(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(itemProgressStatusDownloading)}})
|
||||
cancelDownload(itemID)
|
||||
if !runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
|
||||
t.Fatal("item should be cancelled")
|
||||
}
|
||||
clearDownloadCancel(itemID)
|
||||
runtime.clearActiveDownloadItemID()
|
||||
|
||||
requestID := "utils-request"
|
||||
runtime.setActiveRequestID(requestID)
|
||||
initExtensionRequestCancel(requestID)
|
||||
if runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
|
||||
t.Fatal("request should not be cancelled yet")
|
||||
}
|
||||
cancelExtensionRequest(requestID)
|
||||
if !runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
|
||||
t.Fatal("request should be cancelled")
|
||||
}
|
||||
clearExtensionRequestCancel(requestID)
|
||||
runtime.clearActiveRequestID()
|
||||
|
||||
if msg := runtime.formatLogArgs([]goja.Value{vm.ToValue("a"), vm.ToValue(1)}); msg != "a 1" {
|
||||
t.Fatalf("formatLogArgs = %q", msg)
|
||||
}
|
||||
runtime.logDebug(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("debug")}})
|
||||
runtime.logInfo(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("info")}})
|
||||
runtime.logWarn(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("warn")}})
|
||||
runtime.logError(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("error")}})
|
||||
if clean := runtime.sanitizeFilenameWrapper(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("A/B?")}}).String(); strings.ContainsAny(clean, "/?") {
|
||||
t.Fatalf("sanitize wrapper = %q", clean)
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,33 @@ func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(isDownloadCancelled(itemID))
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) isRequestCancelled(call goja.FunctionCall) goja.Value {
|
||||
requestID := r.getActiveRequestID()
|
||||
if requestID == "" {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
return r.vm.ToValue(isExtensionRequestCancelled(requestID))
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) setDownloadStatus(call goja.FunctionCall) goja.Value {
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
if itemID == "" || len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
|
||||
switch status {
|
||||
case itemProgressStatusPreparing:
|
||||
SetItemPreparing(itemID)
|
||||
case itemProgressStatusDownloading:
|
||||
SetItemDownloading(itemID)
|
||||
case itemProgressStatusFinalizing:
|
||||
SetItemFinalizing(itemID)
|
||||
}
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
@@ -387,6 +414,82 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
"bitDepth": quality.BitDepth,
|
||||
"sampleRate": quality.SampleRate,
|
||||
"totalSamples": quality.TotalSamples,
|
||||
"duration": quality.Duration,
|
||||
})
|
||||
})
|
||||
|
||||
obj.Set("getLyricsLRC", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 3 {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "spotifyID, trackName, and artistName are required",
|
||||
})
|
||||
}
|
||||
|
||||
spotifyID := strings.TrimSpace(call.Arguments[0].String())
|
||||
trackName := strings.TrimSpace(call.Arguments[1].String())
|
||||
artistName := strings.TrimSpace(call.Arguments[2].String())
|
||||
filePath := ""
|
||||
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
|
||||
filePath = strings.TrimSpace(call.Arguments[3].String())
|
||||
}
|
||||
var durationMs int64
|
||||
if len(call.Arguments) > 4 && !goja.IsUndefined(call.Arguments[4]) && !goja.IsNull(call.Arguments[4]) {
|
||||
durationMs = call.Arguments[4].ToInteger()
|
||||
}
|
||||
|
||||
lyrics, err := GetLyricsLRC(spotifyID, trackName, artistName, filePath, durationMs)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"lyrics": lyrics,
|
||||
})
|
||||
})
|
||||
|
||||
obj.Set("checkISRCExists", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "outputDir and isrc are required",
|
||||
})
|
||||
}
|
||||
|
||||
outputDir := strings.TrimSpace(call.Arguments[0].String())
|
||||
isrc := strings.TrimSpace(call.Arguments[1].String())
|
||||
if outputDir == "" || isrc == "" {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "outputDir and isrc are required",
|
||||
})
|
||||
}
|
||||
|
||||
filePath, exists := checkISRCExistsInternal(outputDir, isrc)
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"exists": exists,
|
||||
"filePath": filePath,
|
||||
})
|
||||
})
|
||||
|
||||
obj.Set("addToISRCIndex", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 3 {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "outputDir, isrc, and filePath are required",
|
||||
})
|
||||
}
|
||||
|
||||
outputDir := strings.TrimSpace(call.Arguments[0].String())
|
||||
isrc := strings.TrimSpace(call.Arguments[1].String())
|
||||
filePath := strings.TrimSpace(call.Arguments[2].String())
|
||||
if outputDir == "" || isrc == "" || filePath == "" {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "outputDir, isrc, and filePath are required",
|
||||
})
|
||||
}
|
||||
|
||||
AddToISRCIndex(outputDir, isrc, filePath)
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -44,6 +46,23 @@ func TestParseManifest_Valid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionManifestStopsProviderFallback(t *testing.T) {
|
||||
modernManifest := &ExtensionManifest{StopProviderFallback: true}
|
||||
if !modernManifest.StopsProviderFallback() {
|
||||
t.Fatal("expected stopProviderFallback to stop provider fallback")
|
||||
}
|
||||
|
||||
legacyManifest := &ExtensionManifest{SkipBuiltInFallback: true}
|
||||
if !legacyManifest.StopsProviderFallback() {
|
||||
t.Fatal("expected legacy skipBuiltInFallback to stop provider fallback")
|
||||
}
|
||||
|
||||
defaultManifest := &ExtensionManifest{}
|
||||
if defaultManifest.StopsProviderFallback() {
|
||||
t.Fatal("expected default manifest to allow provider fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_MissingName(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"version": "1.0.0",
|
||||
@@ -97,7 +116,6 @@ func TestIsDomainAllowed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
// Create a mock extension with limited network permissions
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
@@ -234,7 +252,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("stringifyJSON failed: %v", err)
|
||||
}
|
||||
// JSON output may vary in order, just check it's valid
|
||||
if result.String() == "" {
|
||||
t.Error("Expected non-empty JSON string")
|
||||
}
|
||||
@@ -362,8 +379,49 @@ func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithTimeoutContextCancelsExecution(t *testing.T) {
|
||||
vm := goja.New()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := RunWithTimeoutContextAndRecover(ctx, vm, `while (true) {}`, 5*time.Second)
|
||||
if !errors.Is(err, ErrExtensionRequestCancelled) {
|
||||
t.Fatalf("expected extension request cancellation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
runtime := newExtensionRuntime(ext)
|
||||
|
||||
const requestID = "test-extension-request"
|
||||
clearExtensionRequestCancel(requestID)
|
||||
defer clearExtensionRequestCancel(requestID)
|
||||
|
||||
runtime.setActiveRequestID(requestID)
|
||||
defer runtime.clearActiveRequestID()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req = runtime.bindDownloadCancelContext(req)
|
||||
|
||||
cancelExtensionRequest(requestID)
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected request context to be cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
// Create extension with limited network permissions
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
|
||||
@@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string {
|
||||
}
|
||||
|
||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
return RunWithTimeoutContext(context.Background(), vm, script, timeout)
|
||||
}
|
||||
|
||||
func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
if vm == nil {
|
||||
return nil, fmt.Errorf("extension runtime unavailable")
|
||||
}
|
||||
@@ -28,7 +32,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
timeout = DefaultJSTimeout
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
@@ -67,11 +74,16 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
case res := <-resultCh:
|
||||
return res.value, res.err
|
||||
case <-ctx.Done():
|
||||
cancelled := ctx.Err() == context.Canceled
|
||||
interruptMu.Lock()
|
||||
interrupted = true
|
||||
interruptMu.Unlock()
|
||||
|
||||
vm.Interrupt("execution timeout")
|
||||
if cancelled {
|
||||
vm.Interrupt("extension request cancelled")
|
||||
} else {
|
||||
vm.Interrupt("execution timeout")
|
||||
}
|
||||
|
||||
// MUST wait for the goroutine to finish before returning.
|
||||
// The Goja VM is NOT thread-safe — if we return while the goroutine
|
||||
@@ -80,6 +92,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
// pointer dereference.
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
if cancelled {
|
||||
return nil, ErrExtensionRequestCancelled
|
||||
}
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
@@ -91,6 +106,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||
// 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")
|
||||
if cancelled {
|
||||
return nil, ErrExtensionRequestCancelled
|
||||
}
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
@@ -102,7 +120,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
|
||||
// This should be used when you want to continue using the VM after a timeout
|
||||
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
result, err := RunWithTimeout(vm, script, timeout)
|
||||
return RunWithTimeoutContextAndRecover(context.Background(), vm, script, timeout)
|
||||
}
|
||||
|
||||
func RunWithTimeoutContextAndRecover(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
result, err := RunWithTimeoutContext(ctx, vm, script, timeout)
|
||||
|
||||
if vm != nil {
|
||||
vm.ClearInterrupt()
|
||||
|
||||
@@ -48,7 +48,7 @@ func sanitizeFilename(filename string) string {
|
||||
}
|
||||
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = sanitized[:200]
|
||||
sanitized = truncateUTF8Bytes(sanitized, 200)
|
||||
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
}
|
||||
@@ -60,6 +60,25 @@ func sanitizeFilename(filename string) string {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func truncateUTF8Bytes(value string, maxBytes int) string {
|
||||
if maxBytes <= 0 || len(value) <= maxBytes {
|
||||
return value
|
||||
}
|
||||
|
||||
used := 0
|
||||
for i, r := range value {
|
||||
runeLen := utf8.RuneLen(r)
|
||||
if runeLen < 0 {
|
||||
runeLen = len(string(r))
|
||||
}
|
||||
if used+runeLen > maxBytes {
|
||||
return value[:i]
|
||||
}
|
||||
used += runeLen
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
||||
if template == "" {
|
||||
template = "{artist} - {title}"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
@@ -98,3 +102,13 @@ func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
|
||||
t.Fatalf("expected %q, got %q", "Unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilenameTruncatesWithoutSplittingUTF8(t *testing.T) {
|
||||
got := sanitizeFilename(strings.Repeat("あ", 80))
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("sanitizeFilename returned invalid UTF-8: %q", got)
|
||||
}
|
||||
if len(got) > 200 {
|
||||
t.Fatalf("sanitizeFilename length = %d, want <= 200", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.25.8
|
||||
toolchain go1.25.9
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||
@@ -10,9 +10,10 @@ require (
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -21,9 +22,8 @@ require (
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
)
|
||||
|
||||
@@ -30,22 +30,22 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
|
||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b h1:Qt2eaXcZ8x20iAcoZ6AceeMMtnjuPHvC51KRCH1DKSQ=
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b/go.mod h1:5Fu78lew5ucMXt8w2KYcwvxu2rkC/liHzUvaoiI+H/M=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHTTPUtilityHelpers(t *testing.T) {
|
||||
SetAppVersion("7.0.0")
|
||||
apiURL := mustParseURL(t, "https://api.zarz.moe/test")
|
||||
if ua := userAgentForURL(apiURL); !strings.Contains(ua, "7.0.0") {
|
||||
t.Fatalf("api user agent = %q", ua)
|
||||
}
|
||||
if userAgentForURL(nil) == "" || userAgentForURL(mustParseURL(t, "https://example.com")) == "" {
|
||||
t.Fatal("expected fallback user agent")
|
||||
}
|
||||
if NewHTTPClientWithTimeout(time.Second).Timeout != time.Second || NewMetadataHTTPClient(time.Second).Timeout != time.Second {
|
||||
t.Fatal("client timeout mismatch")
|
||||
}
|
||||
if GetSharedClient() == nil || GetDownloadClient() == nil {
|
||||
t.Fatal("expected shared clients")
|
||||
}
|
||||
SetNetworkCompatibilityOptions(true, true)
|
||||
if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS {
|
||||
t.Fatalf("network opts = %#v", opts)
|
||||
}
|
||||
SetNetworkCompatibilityOptions(false, false)
|
||||
if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) {
|
||||
t.Fatal("GET should fallback")
|
||||
}
|
||||
if canFallbackToHTTP(&http.Request{Method: http.MethodPost}) {
|
||||
t.Fatal("POST without GetBody should not fallback")
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://example.com/path", strings.NewReader("body"))
|
||||
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("body")), nil }
|
||||
cloned, err := cloneRequestWithHTTPScheme(req, "http")
|
||||
if err != nil || cloned.URL.Scheme != "http" || cloned.Body == nil {
|
||||
t.Fatalf("cloneRequestWithHTTPScheme = %#v/%v", cloned, err)
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
t.Fatal("missing User-Agent")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("ok")), Request: req}, nil
|
||||
})}
|
||||
resp, err := DoRequestWithUserAgent(client, mustNewRequest(t, "https://example.com/ok"))
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
t.Fatalf("DoRequestWithUserAgent = %#v/%v", resp, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
attempts := 0
|
||||
retryClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
switch attempts {
|
||||
case 1:
|
||||
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("server")), Request: req}, nil
|
||||
case 2:
|
||||
return &http.Response{StatusCode: 429, Header: http.Header{"Retry-After": []string{"0"}}, Body: io.NopCloser(strings.NewReader("rate")), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
|
||||
}
|
||||
})}
|
||||
resp, err = DoRequestWithRetry(retryClient, mustNewRequest(t, "https://example.com/retry"), RetryConfig{MaxRetries: 3, InitialDelay: 0, MaxDelay: time.Millisecond, BackoffFactor: 2})
|
||||
if err != nil || resp.StatusCode != 204 || attempts != 3 {
|
||||
t.Fatalf("DoRequestWithRetry = %#v/%v attempts=%d", resp, err, attempts)
|
||||
}
|
||||
resp.Body.Close()
|
||||
blockingClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 403, Body: io.NopCloser(strings.NewReader("access denied by region")), Request: req}, nil
|
||||
})}
|
||||
if _, err := DoRequestWithRetry(blockingClient, mustNewRequest(t, "https://blocked.example.com"), RetryConfig{MaxRetries: 0}); err == nil {
|
||||
t.Fatal("expected blocking retry error")
|
||||
}
|
||||
|
||||
if _, err := ReadResponseBody(nil); err == nil {
|
||||
t.Fatal("expected nil response body error")
|
||||
}
|
||||
if _, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader(""))}); err == nil {
|
||||
t.Fatal("expected empty response body error")
|
||||
}
|
||||
if body, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader("ok"))}); err != nil || string(body) != "ok" {
|
||||
t.Fatalf("ReadResponseBody = %q/%v", body, err)
|
||||
}
|
||||
if err := ValidateResponse(nil); err == nil {
|
||||
t.Fatal("expected nil response validation error")
|
||||
}
|
||||
if err := ValidateResponse(&http.Response{StatusCode: 404, Status: "404 Not Found"}); err == nil {
|
||||
t.Fatal("expected bad status validation error")
|
||||
}
|
||||
if err := ValidateResponse(&http.Response{StatusCode: 200}); err != nil {
|
||||
t.Fatalf("ValidateResponse: %v", err)
|
||||
}
|
||||
if msg := BuildErrorMessage("api", 500, strings.Repeat("x", 120)); !strings.Contains(msg, "...") {
|
||||
t.Fatalf("BuildErrorMessage = %q", msg)
|
||||
}
|
||||
if calculateNextDelay(10*time.Millisecond, RetryConfig{BackoffFactor: 3, MaxDelay: 20 * time.Millisecond}) != 20*time.Millisecond {
|
||||
t.Fatal("calculateNextDelay mismatch")
|
||||
}
|
||||
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
|
||||
t.Fatal("invalid retry-after should be zero")
|
||||
}
|
||||
if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
|
||||
t.Fatalf("IsISPBlocking = %#v", isp)
|
||||
}
|
||||
if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") {
|
||||
t.Fatal("expected logged ISP blocking")
|
||||
}
|
||||
if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
|
||||
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
|
||||
}
|
||||
if WrapErrorWithISPCheck(nil, "", "test") != nil {
|
||||
t.Fatal("nil wrap should stay nil")
|
||||
}
|
||||
if extractDomain("https://example.com/path") != "example.com" || extractDomain("bad://") != "unknown" || extractDomain("") != "unknown" {
|
||||
t.Fatal("extractDomain mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterHelpers(t *testing.T) {
|
||||
limiter := NewRateLimiter(1, time.Hour)
|
||||
if limiter.Available() != 1 {
|
||||
t.Fatalf("available = %d", limiter.Available())
|
||||
}
|
||||
if !limiter.TryAcquire() || limiter.TryAcquire() {
|
||||
t.Fatal("TryAcquire mismatch")
|
||||
}
|
||||
if limiter.Available() != 0 {
|
||||
t.Fatalf("available after acquire = %d", limiter.Available())
|
||||
}
|
||||
if GetSongLinkRateLimiter() == nil {
|
||||
t.Fatal("expected global limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func mustNewRequest(t *testing.T, rawURL string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func mustParseURL(t *testing.T, rawURL string) *url.URL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -10,16 +10,13 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
type utlsTransport struct {
|
||||
dialer *net.Dialer
|
||||
mu sync.Mutex
|
||||
h2Transports map[string]*http2.Transport
|
||||
dialer *net.Dialer
|
||||
}
|
||||
|
||||
func newUTLSTransport() *utlsTransport {
|
||||
@@ -28,7 +25,6 @@ func newUTLSTransport() *utlsTransport {
|
||||
Timeout: 30 * Second,
|
||||
KeepAlive: 30 * Second,
|
||||
},
|
||||
h2Transports: make(map[string]*http2.Transport),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ type scannedCueFileInfo struct {
|
||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||
var files []libraryAudioFileInfo
|
||||
|
||||
err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
err := filepath.WalkDir(folderPath, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
||||
default:
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
files = append(files, libraryAudioFileInfo{
|
||||
path: path,
|
||||
modTime: info.ModTime().UnixMilli(),
|
||||
@@ -271,18 +276,10 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
albumDir := filepath.Join(dir, "Album")
|
||||
if err := os.MkdirAll(albumDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mp3Path := filepath.Join(albumDir, "Artist - Song.mp3")
|
||||
if err := os.WriteFile(mp3Path, []byte("not really mp3"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
numberedPath := filepath.Join(albumDir, "01 - Intro.ogg")
|
||||
if err := os.WriteFile(numberedPath, []byte("not really ogg"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apePath := filepath.Join(albumDir, "tagged.ape")
|
||||
if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteAPETags(apePath, &APETag{Items: AudioMetadataToAPEItems(&AudioMetadata{
|
||||
Title: "Tagged",
|
||||
Artist: "APE Artist",
|
||||
Album: "APE Album",
|
||||
TrackNumber: 2,
|
||||
TotalTracks: 3,
|
||||
Date: "2026",
|
||||
Genre: "Pop",
|
||||
Composer: "Composer",
|
||||
})}); err != nil {
|
||||
t.Fatalf("write ape tags: %v", err)
|
||||
}
|
||||
cuePath, _ := writeExportCueFixture(t, albumDir)
|
||||
if err := os.WriteFile(filepath.Join(albumDir, "ignored.txt"), []byte("ignore"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
files, err := collectLibraryAudioFiles(dir, make(chan struct{}))
|
||||
if err != nil {
|
||||
t.Fatalf("collectLibraryAudioFiles: %v", err)
|
||||
}
|
||||
if len(files) < 4 {
|
||||
t.Fatalf("files = %#v", files)
|
||||
}
|
||||
cancelCh := make(chan struct{})
|
||||
close(cancelCh)
|
||||
if _, err := collectLibraryAudioFiles(dir, cancelCh); err == nil {
|
||||
t.Fatal("expected cancelled collect")
|
||||
}
|
||||
|
||||
jsonText, err := ScanLibraryFolder(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanLibraryFolder: %v", err)
|
||||
}
|
||||
var results []LibraryScanResult
|
||||
if err := json.Unmarshal([]byte(jsonText), &results); err != nil {
|
||||
t.Fatalf("decode scan results: %v", err)
|
||||
}
|
||||
if len(results) < 4 {
|
||||
t.Fatalf("scan results = %#v", results)
|
||||
}
|
||||
foundTagged := false
|
||||
for _, result := range results {
|
||||
if result.FilePath == apePath {
|
||||
foundTagged = result.TrackName == "Tagged" && result.ArtistName == "APE Artist"
|
||||
}
|
||||
}
|
||||
if !foundTagged {
|
||||
t.Fatalf("tagged APE not found in %#v", results)
|
||||
}
|
||||
if progress := GetLibraryScanProgress(); !strings.Contains(progress, `"IsComplete":true`) && !strings.Contains(progress, `"is_complete":true`) {
|
||||
t.Fatalf("progress = %s", progress)
|
||||
}
|
||||
|
||||
metaJSON, err := ReadAudioMetadataWithDisplayName(mp3Path, "Display Artist - Display Song.mp3")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAudioMetadataWithDisplayName: %v", err)
|
||||
}
|
||||
if !strings.Contains(metaJSON, "Display Song") {
|
||||
t.Fatalf("metadata json = %s", metaJSON)
|
||||
}
|
||||
noExtPath := filepath.Join(albumDir, "noext")
|
||||
if err := os.WriteFile(noExtPath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noExtJSON, err := ReadAudioMetadataWithDisplayNameAndCoverCacheKey(noExtPath, "Artist - No Ext.mp3", "cache-key")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAudioMetadataWithDisplayNameAndCoverCacheKey: %v", err)
|
||||
}
|
||||
if !strings.Contains(noExtJSON, "No Ext") {
|
||||
t.Fatalf("no ext metadata = %s", noExtJSON)
|
||||
}
|
||||
|
||||
existing := map[string]int64{}
|
||||
for _, file := range files {
|
||||
existing[file.path] = file.modTime
|
||||
}
|
||||
if info, err := os.Stat(cuePath); err == nil {
|
||||
existing[cuePath+"#track01"] = info.ModTime().UnixMilli()
|
||||
}
|
||||
incJSON, err := scanLibraryFolderIncrementalWithExistingFiles(dir, existing)
|
||||
if err != nil {
|
||||
t.Fatalf("incremental existing: %v", err)
|
||||
}
|
||||
var inc IncrementalScanResult
|
||||
if err := json.Unmarshal([]byte(incJSON), &inc); err != nil {
|
||||
t.Fatalf("decode incremental: %v", err)
|
||||
}
|
||||
if inc.SkippedCount == 0 {
|
||||
t.Fatalf("incremental = %#v", inc)
|
||||
}
|
||||
if _, err := ScanLibraryFolderIncremental("", "{}"); err == nil {
|
||||
t.Fatal("expected empty incremental folder error")
|
||||
}
|
||||
if incJSON, err := ScanLibraryFolderIncremental(dir, `not-json`); err != nil || incJSON == "" {
|
||||
t.Fatalf("incremental invalid existing JSON = %q/%v", incJSON, err)
|
||||
}
|
||||
|
||||
snapshot := filepath.Join(dir, "snapshot.txt")
|
||||
if err := os.WriteFile(snapshot, []byte("bad\n123\t"+mp3Path+"\nnotint\tpath\n999\t"+filepath.Join(dir, "deleted.mp3")+"\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromSnapshot, err := ScanLibraryFolderIncrementalFromSnapshot(dir, snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot incremental: %v", err)
|
||||
}
|
||||
if !strings.Contains(fromSnapshot, "deleted.mp3") {
|
||||
t.Fatalf("snapshot result = %s", fromSnapshot)
|
||||
}
|
||||
if _, err := ScanLibraryFolder(""); err == nil {
|
||||
t.Fatal("expected empty folder scan error")
|
||||
}
|
||||
fileInsteadOfFolder := filepath.Join(dir, "file.flac")
|
||||
if err := os.WriteFile(fileInsteadOfFolder, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ScanLibraryFolder(fileInsteadOfFolder); err == nil {
|
||||
t.Fatal("expected not folder error")
|
||||
}
|
||||
CancelLibraryScan()
|
||||
SetLibraryCoverCacheDir("")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestLogBufferExportedHelpersAndRedaction(t *testing.T) {
|
||||
ClearLogs()
|
||||
SetLoggingEnabled(false)
|
||||
LogInfo("test", "ignored access_token=secret")
|
||||
LogError("test", "Authorization: Bearer secret-token api_key=value")
|
||||
if GetLogCount() != 1 {
|
||||
t.Fatalf("disabled logging should keep errors only, got %d", GetLogCount())
|
||||
}
|
||||
|
||||
SetLoggingEnabled(true)
|
||||
defer SetLoggingEnabled(false)
|
||||
LogDebug("debug", "client_secret=secret")
|
||||
LogWarn("warn", "warning password=secret")
|
||||
GoLog("[GoTag] success token=abc")
|
||||
|
||||
var entries []LogEntry
|
||||
if err := json.Unmarshal([]byte(GetLogs()), &entries); err != nil {
|
||||
t.Fatalf("GetLogs JSON: %v", err)
|
||||
}
|
||||
if len(entries) < 4 {
|
||||
t.Fatalf("expected log entries, got %#v", entries)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Message, "secret-token") || strings.Contains(entry.Message, "api_key=value") || strings.Contains(entry.Message, "password=secret") {
|
||||
t.Fatalf("log was not redacted: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
sinceJSON := GetLogsSince(1)
|
||||
if !strings.Contains(sinceJSON, `"next_index"`) || !strings.Contains(sinceJSON, `"logs"`) {
|
||||
t.Fatalf("GetLogsSince = %q", sinceJSON)
|
||||
}
|
||||
if emptyJSON := GetLogsSince(999); !strings.Contains(emptyJSON, `"logs":[]`) {
|
||||
t.Fatalf("GetLogsSince empty = %q", emptyJSON)
|
||||
}
|
||||
if negativeJSON := GetLogsSince(-5); !strings.Contains(negativeJSON, `"logs"`) {
|
||||
t.Fatalf("GetLogsSince negative = %q", negativeJSON)
|
||||
}
|
||||
|
||||
ClearLogs()
|
||||
if GetLogCount() != 0 || GetLogs() != "[]" {
|
||||
t.Fatalf("logs were not cleared: count=%d logs=%s", GetLogCount(), GetLogs())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressItemHelpersAndWriter(t *testing.T) {
|
||||
ClearAllItemProgress()
|
||||
itemID := "progress-writer"
|
||||
StartItemProgress(itemID)
|
||||
SetItemBytesTotal(itemID, int64(progressUpdateThreshold*2))
|
||||
SetItemBytesReceived(itemID, int64(progressUpdateThreshold))
|
||||
|
||||
progressJSON := GetItemProgress(itemID)
|
||||
if !strings.Contains(progressJSON, `"bytes_received":131072`) || !strings.Contains(progressJSON, `"progress":0.5`) {
|
||||
t.Fatalf("GetItemProgress = %q", progressJSON)
|
||||
}
|
||||
if missing := GetItemProgress("missing"); missing != "{}" {
|
||||
t.Fatalf("missing progress = %q", missing)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
writer := NewItemProgressWriter(&out, itemID)
|
||||
payload := bytes.Repeat([]byte("x"), progressUpdateThreshold+1)
|
||||
n, err := writer.Write(payload)
|
||||
if err != nil || n != len(payload) {
|
||||
t.Fatalf("progress writer = %d/%v", n, err)
|
||||
}
|
||||
if out.Len() != len(payload) {
|
||||
t.Fatalf("writer output length = %d", out.Len())
|
||||
}
|
||||
if progressJSON = GetItemProgress(itemID); !strings.Contains(progressJSON, `"bytes_received":131073`) {
|
||||
t.Fatalf("progress after writer = %q", progressJSON)
|
||||
}
|
||||
|
||||
cancelDownload(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
n, err = writer.Write([]byte("cancelled"))
|
||||
if n != 0 || !errors.Is(err, ErrDownloadCancelled) {
|
||||
t.Fatalf("cancelled writer = %d/%v", n, err)
|
||||
}
|
||||
|
||||
ClearAllItemProgress()
|
||||
}
|
||||
|
||||
func TestRunWithTimeoutBranches(t *testing.T) {
|
||||
if _, err := RunWithTimeout(nil, "1 + 1", time.Millisecond); err == nil {
|
||||
t.Fatal("expected nil VM error")
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
value, err := RunWithTimeout(vm, "1 + 2", time.Second)
|
||||
if err != nil || value.ToInteger() != 3 {
|
||||
t.Fatalf("RunWithTimeout success = %v/%v", value, err)
|
||||
}
|
||||
|
||||
timeoutVM := goja.New()
|
||||
_, err = RunWithTimeoutAndRecover(timeoutVM, "for (;;) {}", 10*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
if !IsTimeoutError(&JSExecutionError{Message: "timeout", IsTimeout: true}) {
|
||||
t.Fatal("JSExecutionError should be recognized as timeout")
|
||||
}
|
||||
if IsTimeoutError(errors.New("plain")) {
|
||||
t.Fatal("plain error should not be timeout")
|
||||
}
|
||||
if (&JSExecutionError{Message: "boom"}).Error() != "boom" {
|
||||
t.Fatal("JSExecutionError Error mismatch")
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,7 @@ const (
|
||||
|
||||
var DefaultLyricsProviders = []string{
|
||||
LyricsProviderLRCLIB,
|
||||
LyricsProviderMusixmatch,
|
||||
LyricsProviderNetease,
|
||||
LyricsProviderAppleMusic,
|
||||
LyricsProviderQQMusic,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -81,6 +78,8 @@ var defaultLyricsFetchOptions = LyricsFetchOptions{
|
||||
MusixmatchLanguage: "",
|
||||
}
|
||||
|
||||
var instrumentalTrackPattern = regexp.MustCompile(`(?i)(?:^|[\s\[(\-])(?:instrumental|inst\.?)(?:[\s\])]|$)`)
|
||||
|
||||
var (
|
||||
lyricsFetchOptionsMu sync.RWMutex
|
||||
lyricsFetchOptions = defaultLyricsFetchOptions
|
||||
@@ -411,6 +410,16 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
fetchOptions := GetLyricsFetchOptions()
|
||||
|
||||
if isLikelyInstrumentalTrack(trackName) {
|
||||
GoLog("[Lyrics] Track marked instrumental by title heuristic, skipping lyrics search: %s - %s\n", artistName, trackName)
|
||||
instrumental := &LyricsResponse{
|
||||
Instrumental: true,
|
||||
Source: "Heuristic: Instrumental",
|
||||
}
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, instrumental)
|
||||
return instrumental, nil
|
||||
}
|
||||
|
||||
extManager := getExtensionManager()
|
||||
var extensionProviders []*extensionProviderWrapper
|
||||
if extManager != nil {
|
||||
@@ -847,6 +856,15 @@ func normalizeArtistName(name string) string {
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func isLikelyInstrumentalTrack(name string) bool {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return instrumentalTrackPattern.MatchString(trimmed)
|
||||
}
|
||||
|
||||
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
||||
if lrcContent == "" {
|
||||
return "", fmt.Errorf("empty LRC content")
|
||||
|
||||
@@ -16,32 +16,6 @@ type MusixmatchClient struct {
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type musixmatchSearchResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Artwork string `json:"artwork"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
Duration int `json:"duration"`
|
||||
URL string `json:"url"`
|
||||
AlbumID int64 `json:"albumId"`
|
||||
HasSyncedLyrics bool `json:"hasSyncedLyrics"`
|
||||
HasUnsyncedLyrics bool `json:"hasUnsyncedLyrics"`
|
||||
AvailableLanguages []string `json:"availableLanguages"`
|
||||
OriginalLanguage string `json:"originalLanguage"`
|
||||
SyncedLyrics *musixmatchLyricsResponse `json:"syncedLyrics"`
|
||||
UnsyncedLyrics *musixmatchLyricsResponse `json:"unsyncedLyrics"`
|
||||
}
|
||||
|
||||
type musixmatchLyricsResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Duration int `json:"duration"`
|
||||
Language string `json:"language"`
|
||||
UpdatedTime string `json:"updatedTime"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
}
|
||||
|
||||
func NewMusixmatchClient() *MusixmatchClient {
|
||||
return &MusixmatchClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
|
||||
SetAppVersion("4.5.0")
|
||||
if ua := appUserAgent(); !strings.Contains(ua, "4.5.0") {
|
||||
t.Fatalf("user agent = %q", ua)
|
||||
}
|
||||
SetLyricsProviderOrder([]string{"LRCLIB", "bad", "netease"})
|
||||
if providers := GetLyricsProviderOrder(); len(providers) != 2 || providers[0] != LyricsProviderLRCLIB {
|
||||
t.Fatalf("providers = %#v", providers)
|
||||
}
|
||||
SetLyricsProviderOrder(nil)
|
||||
SetLyricsFetchOptions(LyricsFetchOptions{MusixmatchLanguage: " EN_us!!too-long-value ", MultiPersonWordByWord: true})
|
||||
if opts := GetLyricsFetchOptions(); !strings.HasPrefix(opts.MusixmatchLanguage, "en_us") || len(opts.MusixmatchLanguage) > 16 {
|
||||
t.Fatalf("options = %#v", opts)
|
||||
}
|
||||
|
||||
cache := &lyricsCache{cache: map[string]*lyricsCacheEntry{}}
|
||||
response := &LyricsResponse{PlainLyrics: "Hello", Source: "test"}
|
||||
cache.Set(" Artist ", " Song ", 184, response)
|
||||
if got, ok := cache.Get("artist", "song", 180); !ok || got.PlainLyrics != "Hello" {
|
||||
t.Fatalf("cache get = %#v/%v", got, ok)
|
||||
}
|
||||
cache.cache["expired"] = &lyricsCacheEntry{response: response, expiresAt: time.Now().Add(-time.Hour)}
|
||||
if cleaned := cache.CleanExpired(); cleaned != 1 {
|
||||
t.Fatalf("cleaned = %d", cleaned)
|
||||
}
|
||||
if cache.Size() != 1 || cache.ClearAll() != 1 || cache.Size() != 0 {
|
||||
t.Fatalf("cache size after clear = %d", cache.Size())
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics("[00:01.20]Hello\n[bg:Harmony]\n[00:02.300]World\n[00:03.00]\n")
|
||||
if len(lines) != 2 || !strings.Contains(lines[0].Words, "[bg:Harmony]") || lines[0].EndTimeMs != lines[1].StartTimeMs {
|
||||
t.Fatalf("synced lines = %#v", lines)
|
||||
}
|
||||
if plain := plainLyricsFromTimedLines(lines); !strings.Contains(plain, "Hello") {
|
||||
t.Fatalf("plain = %q", plain)
|
||||
}
|
||||
if unsynced := plainTextLyricsLines("A\n\n B "); len(unsynced) != 2 {
|
||||
t.Fatalf("unsynced = %#v", unsynced)
|
||||
}
|
||||
if !lyricsHasUsableText(&LyricsResponse{Instrumental: true}) || lyricsHasUsableText(&LyricsResponse{}) {
|
||||
t.Fatal("unexpected usable lyrics result")
|
||||
}
|
||||
if msg, ok := detectLyricsErrorPayload(`{"success":false,"message":"nope"}`); !ok || msg != "nope" {
|
||||
t.Fatalf("error payload = %q/%v", msg, ok)
|
||||
}
|
||||
if lrcTimestampToMs("01", "02", "345") != 62345 || msToLRCTimestamp(62340) != "[01:02.34]" {
|
||||
t.Fatal("unexpected LRC timestamp conversion")
|
||||
}
|
||||
lrc := convertToLRCWithMetadata(&LyricsResponse{SyncType: "LINE_SYNCED", Lines: lines}, "Song", "Artist")
|
||||
if !strings.Contains(lrc, "[ti:Song]") || !strings.Contains(lrc, "Hello") {
|
||||
t.Fatalf("lrc = %q", lrc)
|
||||
}
|
||||
if got := simplifyTrackName("Song (feat. Guest) - 2020 Remaster"); got != "song" {
|
||||
t.Fatalf("simplified = %q", got)
|
||||
}
|
||||
if got := normalizeArtistName("Artist feat. Guest"); got != "Artist" {
|
||||
t.Fatalf("artist = %q", got)
|
||||
}
|
||||
if !isLikelyInstrumentalTrack("Song (Instrumental)") || isLikelyInstrumentalTrack("Song") {
|
||||
t.Fatal("instrumental heuristic mismatch")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
lrcPath, err := SaveLRCFile(filepath.Join(dir, "song.flac"), lrc)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveLRCFile: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(lrcPath, ".lrc") {
|
||||
t.Fatalf("lrc path = %q", lrcPath)
|
||||
}
|
||||
if _, err := SaveLRCFile(filepath.Join(dir, "empty.flac"), ""); err == nil {
|
||||
t.Fatal("expected empty LRC error")
|
||||
}
|
||||
|
||||
client := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/api/get":
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"id":1,"trackName":"Song","artistName":"Artist","duration":180,"syncedLyrics":"[00:01.00]Hello"}`)), Request: req}, nil
|
||||
case "/api/search":
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":2,"duration":180,"plainLyrics":"Plain\nLyric"},{"id":3,"duration":180,"syncedLyrics":"[00:02.00]Synced"}]`)), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})}}
|
||||
got, err := client.FetchLyricsWithMetadata("Artist", "Song")
|
||||
if err != nil || got.SyncType != "LINE_SYNCED" || len(got.Lines) != 1 {
|
||||
t.Fatalf("FetchLyricsWithMetadata = %#v/%v", got, err)
|
||||
}
|
||||
search, err := client.FetchLyricsFromLRCLibSearch("Artist Song", 180)
|
||||
if err != nil || len(search.Lines) == 0 {
|
||||
t.Fatalf("FetchLyricsFromLRCLibSearch = %#v/%v", search, err)
|
||||
}
|
||||
if best := client.findBestMatch([]LRCLibResponse{{Duration: 100, PlainLyrics: "A"}, {Duration: 180, SyncedLyrics: "[00:01.00]B"}}, 180); best == nil || best.SyncedLyrics == "" {
|
||||
t.Fatalf("best = %#v", best)
|
||||
}
|
||||
if !client.durationMatches(181, 180) || client.durationMatches(300, 180) {
|
||||
t.Fatal("duration match mismatch")
|
||||
}
|
||||
parsed := client.parseLRCLibResponse(&LRCLibResponse{PlainLyrics: "A\nB"})
|
||||
if parsed.SyncType != "UNSYNCED" || len(parsed.Lines) != 2 {
|
||||
t.Fatalf("parsed plain = %#v", parsed)
|
||||
}
|
||||
|
||||
allSources := &LyricsClient{httpClient: client.httpClient}
|
||||
SetLyricsProviderOrder([]string{LyricsProviderLRCLIB})
|
||||
globalLyricsCache.ClearAll()
|
||||
all, err := allSources.FetchLyricsAllSources("", "Song (Instrumental)", "Artist", 180)
|
||||
if err != nil || !all.Instrumental {
|
||||
t.Fatalf("instrumental all sources = %#v/%v", all, err)
|
||||
}
|
||||
globalLyricsCache.ClearAll()
|
||||
all, err = allSources.FetchLyricsAllSources("", "Song", "Artist", 180)
|
||||
if err != nil || len(all.Lines) == 0 {
|
||||
t.Fatalf("all sources = %#v/%v", all, err)
|
||||
}
|
||||
cached, err := allSources.FetchLyricsAllSources("", "Song", "Artist", 180)
|
||||
if err != nil || !strings.Contains(cached.Source, "cached") {
|
||||
t.Fatalf("cached all sources = %#v/%v", cached, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
|
||||
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/apple-music/search"):
|
||||
if req.URL.Query().Get("q") == "bad" {
|
||||
return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`error`)), Request: req}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":"apple-2","songName":"Other","artistName":"Other","duration":1000},{"id":"apple-1","songName":"Song","artistName":"Artist","albumName":"Album","duration":180000}]`)), Request: req}, nil
|
||||
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(paxJSON)), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})}}
|
||||
if best := selectBestAppleMusicSearchResult([]appleMusicSearchResult{{ID: "1", SongName: "Song", ArtistName: "Artist", Duration: 180000}}, "Song", "Artist", 180); best == nil || best.ID != "1" {
|
||||
t.Fatalf("best apple result = %#v", best)
|
||||
}
|
||||
appleID, err := apple.SearchSong("Song", "Artist", 180)
|
||||
if err != nil || appleID != "apple-1" {
|
||||
t.Fatalf("apple SearchSong = %q/%v", appleID, err)
|
||||
}
|
||||
rawApple, err := apple.FetchLyricsByID(appleID)
|
||||
if err != nil || !strings.Contains(rawApple, "Syllable") {
|
||||
t.Fatalf("apple raw = %q/%v", rawApple, err)
|
||||
}
|
||||
appleLyrics, err := apple.FetchLyrics("Song", "Artist", 180, true)
|
||||
if err != nil || appleLyrics.SyncType != "LINE_SYNCED" || appleLyrics.Provider != "Apple Music" {
|
||||
t.Fatalf("apple lyrics = %#v/%v", appleLyrics, err)
|
||||
}
|
||||
if plain, err := formatPaxLyricsToLRC(`[{"timestamp":2000,"text":[{"text":"Plain","part":false}]}]`, false); err != nil || !strings.Contains(plain, "Plain") {
|
||||
t.Fatalf("direct pax = %q/%v", plain, err)
|
||||
}
|
||||
if _, err := apple.SearchSong("", "", 0); err == nil {
|
||||
t.Fatal("expected empty apple search error")
|
||||
}
|
||||
|
||||
musixmatch := &MusixmatchClient{
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
lyricsType := req.URL.Query().Get("type")
|
||||
lang := req.URL.Query().Get("l")
|
||||
if req.URL.Query().Get("t") == "bad" {
|
||||
return &http.Response{StatusCode: 429, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":"rate limited"}`)), Request: req}, nil
|
||||
}
|
||||
if lyricsType == "translate" && lang == "id" {
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]Halo"`)), Request: req}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[00:01.00]Hello`)), Request: req}, nil
|
||||
})},
|
||||
baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
|
||||
}
|
||||
if localized, err := musixmatch.FetchLyricsInLanguage("Song", "Artist", 180, "id"); err != nil || localized.Source != "Musixmatch (id)" {
|
||||
t.Fatalf("localized musixmatch = %#v/%v", localized, err)
|
||||
}
|
||||
if normal, err := musixmatch.FetchLyrics("Song", "Artist", 180, "xx"); err != nil || normal.Provider != "Musixmatch" {
|
||||
t.Fatalf("musixmatch = %#v/%v", normal, err)
|
||||
}
|
||||
if _, err := musixmatch.FetchLyricsInLanguage("Song", "Artist", 180, " "); err == nil {
|
||||
t.Fatal("expected invalid language error")
|
||||
}
|
||||
if _, err := musixmatch.fetchLyricsPayload("bad", "Artist", 0, "word", ""); err == nil {
|
||||
t.Fatal("expected musixmatch proxy error")
|
||||
}
|
||||
|
||||
netease := &NeteaseClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/netease/search"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"code":200,"result":{"songCount":1,"songs":[{"name":"Song","id":123,"artists":[{"name":"Artist"}]}]}}`)), Request: req}, nil
|
||||
case strings.Contains(req.URL.Path, "/netease/lyrics"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"code":200,"lrc":{"lyric":"[00:01.00]Hello"},"tlyric":{"lyric":"[00:01.00]Halo"},"romalrc":{"lyric":"[00:01.00]Romaji"}}`)), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})}}
|
||||
songID, err := netease.SearchSong("Song", "Artist")
|
||||
if err != nil || songID != 123 {
|
||||
t.Fatalf("netease search = %d/%v", songID, err)
|
||||
}
|
||||
netLyrics, err := netease.FetchLyrics("Song", "Artist", 180, true, true)
|
||||
if err != nil || netLyrics.SyncType != "LINE_SYNCED" {
|
||||
t.Fatalf("netease lyrics = %#v/%v", netLyrics, err)
|
||||
}
|
||||
if _, err := netease.SearchSong("", ""); err == nil {
|
||||
t.Fatal("expected empty netease search error")
|
||||
}
|
||||
|
||||
qq := &QQMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("unexpected QQ method %s", req.Method)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics":[{"timestamp":1000,"text":[{"text":"QQ","part":false,"timestamp":1000}]}]}`)), Request: req}, nil
|
||||
})}}
|
||||
qqRaw, err := qq.fetchLyricsByMetadata("Song", "Artist", 180)
|
||||
if err != nil || !strings.Contains(qqRaw, "lyrics") {
|
||||
t.Fatalf("qq raw = %q/%v", qqRaw, err)
|
||||
}
|
||||
qqLyrics, err := qq.FetchLyrics("Song", "Artist", 180, false)
|
||||
if err != nil || qqLyrics.Provider != "QQ Music" {
|
||||
t.Fatalf("qq lyrics = %#v/%v", qqLyrics, err)
|
||||
}
|
||||
if _, err := formatQQLyricsMetadataToLRC(`{"lyrics":[]}`, false); err == nil {
|
||||
t.Fatal("expected empty QQ metadata error")
|
||||
}
|
||||
}
|
||||
@@ -1150,17 +1150,6 @@ func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, er
|
||||
return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil
|
||||
}
|
||||
|
||||
func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(payload) < 4 {
|
||||
return 0, fmt.Errorf("index payload too short in %s", parent.typ)
|
||||
}
|
||||
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 {
|
||||
@@ -1592,6 +1581,7 @@ type AudioQuality struct {
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
TotalSamples int64 `json:"total_samples"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
@@ -1632,10 +1622,16 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
int64(streamInfo[16])<<8 |
|
||||
int64(streamInfo[17])
|
||||
|
||||
duration := 0
|
||||
if sampleRate > 0 && totalSamples > 0 {
|
||||
duration = int(totalSamples / int64(sampleRate))
|
||||
}
|
||||
|
||||
return AudioQuality{
|
||||
BitDepth: bitsPerSample,
|
||||
SampleRate: sampleRate,
|
||||
TotalSamples: totalSamples,
|
||||
Duration: duration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1676,6 +1672,7 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
|
||||
moovStart := moovHeader.offset
|
||||
moovEnd := moovHeader.offset + moovHeader.size
|
||||
duration := readM4ADurationSeconds(f, moovHeader, fileSize)
|
||||
|
||||
sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
|
||||
if err != nil {
|
||||
@@ -1715,7 +1712,46 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
bitDepth = 16
|
||||
}
|
||||
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate, Duration: duration}, nil
|
||||
}
|
||||
|
||||
func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
|
||||
childStart := moovHeader.offset + moovHeader.headerSize
|
||||
childSize := moovHeader.size - moovHeader.headerSize
|
||||
mvhdHeader, found, err := findAtomInRange(f, childStart, childSize, "mvhd", fileSize)
|
||||
if err != nil || !found {
|
||||
return 0
|
||||
}
|
||||
|
||||
payloadOffset := mvhdHeader.offset + mvhdHeader.headerSize
|
||||
versionBuf := make([]byte, 1)
|
||||
if _, err := f.ReadAt(versionBuf, payloadOffset); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if versionBuf[0] == 1 {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := f.ReadAt(buf, payloadOffset); err != nil {
|
||||
return 0
|
||||
}
|
||||
timescale := binary.BigEndian.Uint32(buf[20:24])
|
||||
duration := binary.BigEndian.Uint64(buf[24:32])
|
||||
if timescale == 0 || duration == 0 {
|
||||
return 0
|
||||
}
|
||||
return int(math.Round(float64(duration) / float64(timescale)))
|
||||
}
|
||||
|
||||
buf := make([]byte, 20)
|
||||
if _, err := f.ReadAt(buf, payloadOffset); err != nil {
|
||||
return 0
|
||||
}
|
||||
timescale := binary.BigEndian.Uint32(buf[12:16])
|
||||
duration := binary.BigEndian.Uint32(buf[16:20])
|
||||
if timescale == 0 || duration == 0 {
|
||||
return 0
|
||||
}
|
||||
return int(math.Round(float64(duration) / float64(timescale)))
|
||||
}
|
||||
|
||||
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/go-flac/flacvorbis/v2"
|
||||
)
|
||||
|
||||
func TestReadFileMetadataAndCueLibraryWrappers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mp3Path := filepath.Join(dir, "tagged.mp3")
|
||||
tag := buildID3v23Tag(
|
||||
id3TextFrame("TIT2", "Title"),
|
||||
id3TextFrame("TPE1", "Artist"),
|
||||
id3TextFrame("TALB", "Album"),
|
||||
id3TextFrame("TRCK", "4/12"),
|
||||
id3CommentFrame("USLT", "[00:00.00]Lyric"),
|
||||
)
|
||||
if err := os.WriteFile(mp3Path, append(tag, []byte{0xFF, 0xFB, 0x90, 0x64, 0, 0, 0, 0}...), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if jsonText, err := ReadFileMetadata(mp3Path); err != nil || !strings.Contains(jsonText, `"title":"Title"`) {
|
||||
t.Fatalf("ReadFileMetadata mp3 = %q/%v", jsonText, err)
|
||||
}
|
||||
|
||||
m4aPath := filepath.Join(dir, "tagged.m4a")
|
||||
ilst := buildM4ATextTag("\xa9nam", "M4A Title")
|
||||
if err := os.WriteFile(m4aPath, buildM4AFileWithIlst(ilst, true), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if jsonText, err := ReadFileMetadata(m4aPath); err != nil || !strings.Contains(jsonText, "M4A Title") {
|
||||
t.Fatalf("ReadFileMetadata m4a = %q/%v", jsonText, err)
|
||||
}
|
||||
|
||||
cuePath, _ := writeExportCueFixture(t, dir)
|
||||
results, err := ScanCueFileForLibrary(cuePath, time.Now().Format(time.RFC3339))
|
||||
if err != nil || len(results) != 1 || results[0].TrackName != "Song" {
|
||||
t.Fatalf("ScanCueFileForLibrary = %#v/%v", results, err)
|
||||
}
|
||||
if _, err := ReadFileMetadata(filepath.Join(dir, "unsupported.txt")); err == nil {
|
||||
t.Fatal("expected unsupported metadata format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputFDFilePathBranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outputPath := filepath.Join(dir, "out.bin")
|
||||
file, err := openOutputForWrite(outputPath, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("openOutputForWrite path: %v", err)
|
||||
}
|
||||
if _, err := file.Write([]byte("data")); err != nil {
|
||||
t.Fatalf("write output: %v", err)
|
||||
}
|
||||
file.Close()
|
||||
if !isFDOutput(1) || isFDOutput(0) {
|
||||
t.Fatal("isFDOutput mismatch")
|
||||
}
|
||||
closeOwnedOutputFD(0)
|
||||
if err := prepareDupFDForWrite(11, 10); err != nil {
|
||||
t.Fatalf("prepareDupFDForWrite: %v", err)
|
||||
}
|
||||
closeOwnedOutputFD(11)
|
||||
cleanupOutputOnError(outputPath, 0)
|
||||
if _, err := os.Stat(outputPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("cleanup should remove output path, stat err=%v", err)
|
||||
}
|
||||
cleanupOutputOnError("", 0)
|
||||
cleanupOutputOnError("/proc/self/fd/1", 0)
|
||||
cleanupOutputOnError(filepath.Join(dir, "kept.bin"), 10)
|
||||
}
|
||||
|
||||
func TestMoreSmallConstructorsRuntimeAndMetadataHelpers(t *testing.T) {
|
||||
if cfg := DefaultRetryConfig(); cfg.MaxRetries == 0 || cfg.BackoffFactor <= 1 {
|
||||
t.Fatalf("DefaultRetryConfig = %#v", cfg)
|
||||
}
|
||||
if NewAppleMusicClient().httpClient == nil || NewNeteaseClient().httpClient == nil || NewMusixmatchClient().httpClient == nil || NewQQMusicClient().httpClient == nil {
|
||||
t.Fatal("expected lyric provider HTTP clients")
|
||||
}
|
||||
if NewIDHSClient().client == nil {
|
||||
t.Fatal("expected IDHS HTTP client")
|
||||
}
|
||||
ClearTrackCache()
|
||||
|
||||
vm := goja.New()
|
||||
runtime := &extensionRuntime{extensionID: "misc-runtime", vm: vm, settings: map[string]interface{}{}}
|
||||
if parseExtensionTimeoutSeconds(" 42 ") != 42 || parseExtensionTimeoutSeconds("bad") != 0 || parseExtensionTimeoutSeconds(float64(7)) != 7 {
|
||||
t.Fatal("parseExtensionTimeoutSeconds mismatch")
|
||||
}
|
||||
if (&RedirectBlockedError{Domain: "blocked.example"}).Error() == "" || (&RedirectBlockedError{IsPrivate: true}).Error() == "" {
|
||||
t.Fatal("RedirectBlockedError Error mismatch")
|
||||
}
|
||||
runtime.SetSettings(map[string]interface{}{"quality": "lossless"})
|
||||
if runtime.settings["quality"] != "lossless" {
|
||||
t.Fatal("SetSettings mismatch")
|
||||
}
|
||||
jar, _ := newSimpleCookieJar()
|
||||
cookieURL, _ := url.Parse("https://example.test/")
|
||||
jar.SetCookies(cookieURL, []*http.Cookie{{Name: "a", Value: "b"}})
|
||||
if cookies := jar.Cookies(cookieURL); len(cookies) != 1 || cookies[0].Value != "b" {
|
||||
t.Fatalf("cookies = %#v", cookies)
|
||||
}
|
||||
|
||||
if result := runtime.ffmpegExecute(goja.FunctionCall{}).Export().(map[string]interface{}); result["success"] != false {
|
||||
t.Fatalf("ffmpegExecute missing args = %#v", result)
|
||||
}
|
||||
if result := runtime.ffmpegGetInfo(goja.FunctionCall{}).Export().(map[string]interface{}); result["success"] != false {
|
||||
t.Fatalf("ffmpegGetInfo missing args = %#v", result)
|
||||
}
|
||||
if result := runtime.ffmpegGetInfo(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("missing.flac")}}).Export().(map[string]interface{}); result["success"] != false {
|
||||
t.Fatalf("ffmpegGetInfo missing file = %#v", result)
|
||||
}
|
||||
if result := runtime.ffmpegConvert(goja.FunctionCall{}).Export().(map[string]interface{}); result["success"] != false {
|
||||
t.Fatalf("ffmpegConvert missing args = %#v", result)
|
||||
}
|
||||
|
||||
cmt := flacvorbis.New()
|
||||
setComment(cmt, "TITLE", "Song")
|
||||
setComment(cmt, "ARTIST", "Artist")
|
||||
if getComment(cmt, "TITLE") != "Song" || getJoinedComment(cmt, "ARTIST") != "Artist" {
|
||||
t.Fatalf("comments = %#v", cmt.Comments)
|
||||
}
|
||||
setOrClearComment(cmt, "TITLE", "")
|
||||
if getComment(cmt, "TITLE") != "" {
|
||||
t.Fatal("setOrClearComment should remove empty value")
|
||||
}
|
||||
setOrClearArtistComments(cmt, "ARTIST", "A; B", artistTagModeSplitVorbis)
|
||||
if joined := getJoinedComment(cmt, "ARTIST"); !strings.Contains(joined, "A") || !strings.Contains(joined, "B") {
|
||||
t.Fatalf("split artist comments = %q", joined)
|
||||
}
|
||||
removeCommentKey(cmt, "ARTIST")
|
||||
if getComment(cmt, "ARTIST") != "" {
|
||||
t.Fatal("removeCommentKey failed")
|
||||
}
|
||||
if fileExists(filepath.Join(t.TempDir(), "missing")) {
|
||||
t.Fatal("missing file should not exist")
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("cover"))
|
||||
}))
|
||||
defer server.Close()
|
||||
SetNetworkCompatibilityOptions(true, false)
|
||||
defer SetNetworkCompatibilityOptions(false, false)
|
||||
coverPath := filepath.Join(t.TempDir(), "cover.jpg")
|
||||
if err := DownloadCoverToFile(server.URL+"/cover.jpg", coverPath, false); err != nil {
|
||||
t.Fatalf("DownloadCoverToFile: %v", err)
|
||||
}
|
||||
if string(mustReadFile(t, coverPath)) != "cover" {
|
||||
t.Fatal("downloaded cover mismatch")
|
||||
}
|
||||
|
||||
parallel := FetchCoverAndLyricsParallel(server.URL+"/cover.jpg", false, "spotify-1", "Song Instrumental", "Artist", true, 180000)
|
||||
if string(parallel.CoverData) != "cover" || parallel.CoverErr != nil || parallel.LyricsErr == nil {
|
||||
t.Fatalf("FetchCoverAndLyricsParallel = %#v", parallel)
|
||||
}
|
||||
emptyParallel := FetchCoverAndLyricsParallel("", false, "", "", "", false, 0)
|
||||
if emptyParallel.CoverData != nil || emptyParallel.LyricsData != nil {
|
||||
t.Fatalf("empty FetchCoverAndLyricsParallel = %#v", emptyParallel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionHealthInitializeVMAndCustomSearchWrappers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
extDir := filepath.Join(dir, "ext")
|
||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(extDir, "index.js"), []byte(testExtensionJS), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ext := &loadedExtension{
|
||||
ID: "health-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "health-ext",
|
||||
DisplayName: "Health",
|
||||
Version: "1.0.0",
|
||||
Description: "Health extension",
|
||||
Types: []ExtensionType{ExtensionTypeMetadataProvider},
|
||||
SearchBehavior: &SearchBehaviorConfig{
|
||||
Enabled: true,
|
||||
Primary: true,
|
||||
},
|
||||
ServiceHealth: []ExtensionHealthCheck{{
|
||||
ID: "bad",
|
||||
URL: "http://health.example.test/status",
|
||||
Required: true,
|
||||
}},
|
||||
},
|
||||
Enabled: true,
|
||||
SourceDir: extDir,
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
}
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
if manager.extensions == nil {
|
||||
manager.extensions = map[string]*loadedExtension{}
|
||||
}
|
||||
manager.extensions[ext.ID] = ext
|
||||
manager.mu.Unlock()
|
||||
defer func() {
|
||||
manager.mu.Lock()
|
||||
delete(manager.extensions, ext.ID)
|
||||
manager.mu.Unlock()
|
||||
}()
|
||||
|
||||
if err := manager.initializeVM(ext); err != nil {
|
||||
t.Fatalf("initializeVM: %v", err)
|
||||
}
|
||||
if ext.VM == nil {
|
||||
t.Fatal("expected initialized VM")
|
||||
}
|
||||
provider := &extensionProviderWrapper{extension: ext}
|
||||
if tracks, err := provider.CustomSearch("needle", map[string]interface{}{"type": "track"}); err != nil || len(tracks) == 0 {
|
||||
t.Fatalf("CustomSearch = %#v/%v", tracks, err)
|
||||
}
|
||||
cancelMu.Lock()
|
||||
delete(cancelMap, "custom-item-unique")
|
||||
cancelMu.Unlock()
|
||||
if tracks, err := provider.CustomSearchForItemID("needle", nil, "custom-item-unique"); err != nil || len(tracks) == 0 {
|
||||
t.Fatalf("CustomSearchForItemID = %#v/%v", tracks, err)
|
||||
}
|
||||
if healthJSON, err := CheckExtensionHealthJSON(ext.ID); err != nil || !strings.Contains(healthJSON, `"status":"offline"`) {
|
||||
t.Fatalf("CheckExtensionHealthJSON = %q/%v", healthJSON, err)
|
||||
}
|
||||
teardownVMLocked(ext)
|
||||
}
|
||||
|
||||
func TestManifestPerfMatchingAndTitleHelpers(t *testing.T) {
|
||||
manifest := &ExtensionManifest{
|
||||
Name: "misc-ext",
|
||||
DisplayName: "Misc",
|
||||
Version: "1.0.0",
|
||||
Description: "Misc extension",
|
||||
Types: []ExtensionType{ExtensionTypeMetadataProvider},
|
||||
URLHandler: &URLHandlerConfig{Enabled: true, Patterns: []string{"example.test"}},
|
||||
PostProcessing: &PostProcessingConfig{Hooks: []PostProcessingHook{{
|
||||
ID: "hook", Name: "Hook",
|
||||
}}},
|
||||
}
|
||||
data, err := manifest.ToJSON()
|
||||
if err != nil || !strings.Contains(string(data), "misc-ext") {
|
||||
t.Fatalf("ToJSON = %q/%v", string(data), err)
|
||||
}
|
||||
if !manifest.HasURLHandler() || !manifest.MatchesURL("https://example.test/track") || len(manifest.GetPostProcessingHooks()) != 1 {
|
||||
t.Fatal("manifest helpers mismatch")
|
||||
}
|
||||
if (&ManifestValidationError{Field: "name", Message: "required"}).Error() == "" {
|
||||
t.Fatal("manifest validation error string empty")
|
||||
}
|
||||
|
||||
if extensionDurationMs(1500*time.Microsecond) != 1.5 {
|
||||
t.Fatal("extensionDurationMs mismatch")
|
||||
}
|
||||
vm := goja.New()
|
||||
value := vm.ToValue(map[string]interface{}{"tracks": []interface{}{1, 2, 3}})
|
||||
if countExtensionTopLevelItems(vm, value) != 3 {
|
||||
t.Fatal("countExtensionTopLevelItems mismatch")
|
||||
}
|
||||
if countExtensionTopLevelItems(vm, goja.Undefined()) != 0 {
|
||||
t.Fatal("empty top-level item count mismatch")
|
||||
}
|
||||
|
||||
if calculateStringSimilarity("", "") != 1 || calculateStringSimilarity("", "x") != 0 || levenshteinDistance("kitten", "sitting") != 3 {
|
||||
t.Fatal("string similarity helpers mismatch")
|
||||
}
|
||||
var b strings.Builder
|
||||
writeNormalizedArtistRune(&b, 'ß')
|
||||
writeNormalizedArtistRune(&b, 'æ')
|
||||
if b.String() != "ssae" {
|
||||
t.Fatalf("writeNormalizedArtistRune = %q", b.String())
|
||||
}
|
||||
if !artistsMatch("Artist feat Guest", "Guest") || !sameWordsUnordered("B A", "A B") || !titlesMatch("Song (Remastered)", "Song") {
|
||||
t.Fatal("artist/title matching mismatch")
|
||||
}
|
||||
if len(splitArtists("A & B, C x D")) != 4 {
|
||||
t.Fatal("splitArtists mismatch")
|
||||
}
|
||||
if isLatinScript("東京") || !isLatinScript("Beyonce") {
|
||||
t.Fatal("isLatinScript mismatch")
|
||||
}
|
||||
|
||||
req := DownloadRequest{TrackName: "Song", ArtistName: "Artist", DurationMS: 180000}
|
||||
if !trackMatchesRequest(req, resolvedTrackInfo{Title: "Song", ArtistName: "Artist", Duration: 181}, "test") {
|
||||
t.Fatal("expected matching track")
|
||||
}
|
||||
if trackMatchesRequest(req, resolvedTrackInfo{Title: "Other", ArtistName: "Other", Duration: 240}, "test") {
|
||||
t.Fatal("expected mismatching track")
|
||||
}
|
||||
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal(data, &decoded); err != nil || decoded["name"] != "misc-ext" {
|
||||
t.Fatalf("manifest JSON decode = %#v/%v", decoded, err)
|
||||
}
|
||||
}
|
||||
@@ -4,120 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrackIDCacheEntry struct {
|
||||
TidalTrackID int64
|
||||
QobuzTrackID int64
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type TrackIDCache struct {
|
||||
cache map[string]*TrackIDCacheEntry
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
lastCleanup time.Time
|
||||
cleanupInterval time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
globalTrackIDCache *TrackIDCache
|
||||
trackIDCacheOnce sync.Once
|
||||
)
|
||||
|
||||
func GetTrackIDCache() *TrackIDCache {
|
||||
trackIDCacheOnce.Do(func() {
|
||||
globalTrackIDCache = &TrackIDCache{
|
||||
cache: make(map[string]*TrackIDCacheEntry),
|
||||
ttl: 30 * time.Minute,
|
||||
cleanupInterval: 5 * time.Minute,
|
||||
}
|
||||
})
|
||||
return globalTrackIDCache
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||
c.mu.RLock()
|
||||
entry, exists := c.cache[isrc]
|
||||
if !exists {
|
||||
c.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
expired := time.Now().After(entry.ExpiresAt)
|
||||
c.mu.RUnlock()
|
||||
|
||||
if !expired {
|
||||
return entry
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
entry, exists = c.cache[isrc]
|
||||
if exists && time.Now().After(entry.ExpiresAt) {
|
||||
delete(c.cache, isrc)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) pruneExpiredLocked(now time.Time) {
|
||||
for key, entry := range c.cache {
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(c.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
entry, exists := c.cache[isrc]
|
||||
if !exists {
|
||||
entry = &TrackIDCacheEntry{}
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.TidalTrackID = trackID
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||
c.pruneExpiredLocked(now)
|
||||
c.lastCleanup = now
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
entry, exists := c.cache[isrc]
|
||||
if !exists {
|
||||
entry = &TrackIDCacheEntry{}
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.QobuzTrackID = trackID
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||
c.pruneExpiredLocked(now)
|
||||
c.lastCleanup = now
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cache = make(map[string]*TrackIDCacheEntry)
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.cache)
|
||||
}
|
||||
|
||||
type ParallelDownloadResult struct {
|
||||
CoverData []byte
|
||||
LyricsData *LyricsResponse
|
||||
@@ -187,72 +75,7 @@ type PreWarmCacheRequest struct {
|
||||
}
|
||||
|
||||
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
if len(requests) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cache := GetTrackIDCache()
|
||||
|
||||
semaphore := make(chan struct{}, 3)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, req := range requests {
|
||||
if req.ISRC == "" {
|
||||
continue
|
||||
}
|
||||
if cached := cache.Get(req.ISRC); cached != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(r PreWarmCacheRequest) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
switch r.Service {
|
||||
case "tidal":
|
||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||
case "qobuz":
|
||||
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||
}
|
||||
}(req)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func preWarmTidalCache(isrc, _, _ string) {
|
||||
downloader := NewTidalDownloader()
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
GetTrackIDCache().SetTidal(isrc, track.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// preWarmQobuzCache tries to get Qobuz Track ID in the following order:
|
||||
// 1. From SongLink (fast, no Qobuz API call needed)
|
||||
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
||||
func preWarmQobuzCache(isrc, spotifyID string) {
|
||||
if spotifyID != "" {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
||||
GetTrackIDCache().SetQobuz(isrc, trackID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
downloader := NewQobuzDownloader()
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc)
|
||||
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||
}
|
||||
_ = requests
|
||||
}
|
||||
|
||||
func PreWarmCache(tracksJSON string) error {
|
||||
@@ -284,9 +107,8 @@ func PreWarmCache(tracksJSON string) error {
|
||||
}
|
||||
|
||||
func ClearTrackCache() {
|
||||
GetTrackIDCache().Clear()
|
||||
}
|
||||
|
||||
func GetCacheSize() int {
|
||||
return GetTrackIDCache().Size()
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -24,26 +25,85 @@ type ItemProgress struct {
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"`
|
||||
revision int64
|
||||
}
|
||||
|
||||
const (
|
||||
itemProgressStatusPreparing = "preparing"
|
||||
itemProgressStatusDownloading = "downloading"
|
||||
itemProgressStatusCompleted = "completed"
|
||||
itemProgressStatusFinalizing = "finalizing"
|
||||
)
|
||||
|
||||
type MultiProgress struct {
|
||||
Items map[string]*ItemProgress `json:"items"`
|
||||
}
|
||||
|
||||
var (
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
type MultiProgressDelta struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Reset bool `json:"reset,omitempty"`
|
||||
Items map[string]*ItemProgress `json:"items,omitempty"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
}
|
||||
|
||||
type progressBridgeState struct {
|
||||
bytesBucket int64
|
||||
bytesTotal int64
|
||||
progressPct int64
|
||||
speedDeciMBps int64
|
||||
downloading bool
|
||||
status string
|
||||
}
|
||||
|
||||
var (
|
||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||
multiMu sync.RWMutex
|
||||
multiProgressDirty = true
|
||||
cachedMultiProgress = "{\"items\":{}}"
|
||||
multiProgressSeq int64
|
||||
multiProgressReset int64
|
||||
removedProgressSeq = make(map[string]int64)
|
||||
)
|
||||
|
||||
func markMultiProgressDirtyLocked() {
|
||||
multiProgressDirty = true
|
||||
}
|
||||
|
||||
func nextMultiProgressSeqLocked() int64 {
|
||||
multiProgressSeq++
|
||||
return multiProgressSeq
|
||||
}
|
||||
|
||||
func itemProgressBridgeState(item *ItemProgress) progressBridgeState {
|
||||
progress := item.Progress
|
||||
if math.IsNaN(progress) || progress <= 0 {
|
||||
progress = 0
|
||||
} else if progress >= 1 {
|
||||
progress = 1
|
||||
}
|
||||
|
||||
speed := item.SpeedMBps
|
||||
if math.IsNaN(speed) || speed <= 0 {
|
||||
speed = 0
|
||||
}
|
||||
|
||||
return progressBridgeState{
|
||||
bytesBucket: item.BytesReceived / progressUpdateThreshold,
|
||||
bytesTotal: item.BytesTotal,
|
||||
progressPct: int64(math.Round(progress * 100)),
|
||||
speedDeciMBps: int64(math.Round(speed * 10)),
|
||||
downloading: item.IsDownloading,
|
||||
status: item.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func markMultiProgressDirtyIfChangedLocked(item *ItemProgress, before progressBridgeState) {
|
||||
if itemProgressBridgeState(item) != before {
|
||||
item.revision = nextMultiProgressSeqLocked()
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func getProgress() DownloadProgress {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -85,6 +145,54 @@ func GetMultiProgress() string {
|
||||
return cachedMultiProgress
|
||||
}
|
||||
|
||||
func GetMultiProgressDelta(sinceSeq int64) string {
|
||||
multiMu.RLock()
|
||||
currentSeq := multiProgressSeq
|
||||
if sinceSeq >= currentSeq {
|
||||
multiMu.RUnlock()
|
||||
return ""
|
||||
}
|
||||
|
||||
reset := sinceSeq <= 0 || sinceSeq < multiProgressReset
|
||||
delta := MultiProgressDelta{
|
||||
Seq: currentSeq,
|
||||
Reset: reset,
|
||||
}
|
||||
if reset {
|
||||
if len(multiProgress.Items) > 0 {
|
||||
delta.Items = make(map[string]*ItemProgress, len(multiProgress.Items))
|
||||
for id, item := range multiProgress.Items {
|
||||
copy := *item
|
||||
copy.revision = 0
|
||||
delta.Items[id] = ©
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for id, item := range multiProgress.Items {
|
||||
if item.revision > sinceSeq {
|
||||
if delta.Items == nil {
|
||||
delta.Items = make(map[string]*ItemProgress)
|
||||
}
|
||||
copy := *item
|
||||
copy.revision = 0
|
||||
delta.Items[id] = ©
|
||||
}
|
||||
}
|
||||
for id, revision := range removedProgressSeq {
|
||||
if revision > sinceSeq {
|
||||
delta.Removed = append(delta.Removed, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
multiMu.RUnlock()
|
||||
|
||||
jsonBytes, err := json.Marshal(delta)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func GetItemProgress(itemID string) string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -105,19 +213,50 @@ func StartItemProgress(itemID string) {
|
||||
BytesTotal: 0,
|
||||
BytesReceived: 0,
|
||||
Progress: 0,
|
||||
IsDownloading: true,
|
||||
Status: "downloading",
|
||||
IsDownloading: false,
|
||||
Status: itemProgressStatusPreparing,
|
||||
revision: nextMultiProgressSeqLocked(),
|
||||
}
|
||||
delete(removedProgressSeq, itemID)
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
func SetItemPreparing(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.Progress = 0
|
||||
item.BytesReceived = 0
|
||||
item.BytesTotal = 0
|
||||
item.SpeedMBps = 0
|
||||
item.IsDownloading = true
|
||||
item.Status = itemProgressStatusPreparing
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
func SetItemDownloading(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.IsDownloading = true
|
||||
item.Status = itemProgressStatusDownloading
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
func SetItemBytesTotal(itemID string, total int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.BytesTotal = total
|
||||
markMultiProgressDirtyLocked()
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,11 +265,16 @@ func SetItemBytesReceived(itemID string, received int64) {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.BytesReceived = received
|
||||
if item.BytesTotal > 0 {
|
||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
if received > 0 {
|
||||
item.IsDownloading = true
|
||||
item.Status = itemProgressStatusDownloading
|
||||
}
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,12 +283,17 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.BytesReceived = received
|
||||
item.SpeedMBps = speedMBps
|
||||
if item.BytesTotal > 0 {
|
||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
if received > 0 {
|
||||
item.IsDownloading = true
|
||||
item.Status = itemProgressStatusDownloading
|
||||
}
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +302,11 @@ func CompleteItemProgress(itemID string) {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.Progress = 1.0
|
||||
item.IsDownloading = false
|
||||
item.Status = "completed"
|
||||
markMultiProgressDirtyLocked()
|
||||
item.Status = itemProgressStatusCompleted
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,14 +315,24 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = progress
|
||||
before := itemProgressBridgeState(item)
|
||||
hasByteProgress := bytesReceived > 0 || bytesTotal > 0
|
||||
if item.Status != itemProgressStatusPreparing || hasByteProgress || progress >= 1 {
|
||||
item.Progress = progress
|
||||
} else {
|
||||
item.Progress = 0
|
||||
}
|
||||
if bytesReceived > 0 {
|
||||
item.BytesReceived = bytesReceived
|
||||
}
|
||||
if bytesTotal > 0 {
|
||||
item.BytesTotal = bytesTotal
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
if hasByteProgress || progress >= 1 || item.Status == itemProgressStatusDownloading {
|
||||
item.IsDownloading = true
|
||||
item.Status = itemProgressStatusDownloading
|
||||
}
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,9 +341,10 @@ func SetItemFinalizing(itemID string) {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.Progress = 1.0
|
||||
item.Status = "finalizing"
|
||||
markMultiProgressDirtyLocked()
|
||||
item.Status = itemProgressStatusFinalizing
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +352,10 @@ func RemoveItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
delete(multiProgress.Items, itemID)
|
||||
if _, ok := multiProgress.Items[itemID]; ok {
|
||||
delete(multiProgress.Items, itemID)
|
||||
removedProgressSeq[itemID] = nextMultiProgressSeqLocked()
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
@@ -200,13 +364,12 @@ func ClearAllItemProgress() {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
multiProgress.Items = make(map[string]*ItemProgress)
|
||||
removedProgressSeq = make(map[string]int64)
|
||||
multiProgressReset = nextMultiProgressSeqLocked()
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
func setDownloadDir(path string) error {
|
||||
downloadDirMu.Lock()
|
||||
defer downloadDirMu.Unlock()
|
||||
downloadDir = path
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -220,7 +383,7 @@ type ItemProgressWriter struct {
|
||||
lastBytes int64
|
||||
}
|
||||
|
||||
const progressUpdateThreshold = 64 * 1024
|
||||
const progressUpdateThreshold = 128 * 1024
|
||||
|
||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||
now := time.Now()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestItemProgressPreparingAndDownloadingStatuses(t *testing.T) {
|
||||
const itemID = "progress-phase-item"
|
||||
RemoveItemProgress(itemID)
|
||||
defer RemoveItemProgress(itemID)
|
||||
|
||||
StartItemProgress(itemID)
|
||||
SetItemPreparing(itemID)
|
||||
|
||||
if item := multiProgress.Items[itemID]; item == nil {
|
||||
t.Fatal("expected item progress entry to exist")
|
||||
} else {
|
||||
if item.Status != itemProgressStatusPreparing {
|
||||
t.Fatalf("status = %q, want %q", item.Status, itemProgressStatusPreparing)
|
||||
}
|
||||
if item.Progress != 0 {
|
||||
t.Fatalf("progress = %v, want 0", item.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
SetItemProgress(itemID, 0.05, 0, 0)
|
||||
if item := multiProgress.Items[itemID]; item == nil {
|
||||
t.Fatal("expected item progress entry to exist after update")
|
||||
} else if item.Status != itemProgressStatusPreparing {
|
||||
t.Fatalf("status after synthetic pre-download progress = %q, want %q", item.Status, itemProgressStatusPreparing)
|
||||
} else if item.Progress != 0 {
|
||||
t.Fatalf("progress after synthetic pre-download progress = %v, want 0", item.Progress)
|
||||
}
|
||||
|
||||
SetItemDownloading(itemID)
|
||||
if item := multiProgress.Items[itemID]; item == nil {
|
||||
t.Fatal("expected item progress entry to exist after downloading status")
|
||||
} else if item.Status != itemProgressStatusDownloading {
|
||||
t.Fatalf("status after download start = %q, want %q", item.Status, itemProgressStatusDownloading)
|
||||
}
|
||||
|
||||
SetItemProgress(itemID, 0.37, 0, 0)
|
||||
if item := multiProgress.Items[itemID]; item == nil {
|
||||
t.Fatal("expected item progress entry to exist after real update")
|
||||
} else if item.Status != itemProgressStatusDownloading {
|
||||
t.Fatalf("status after real progress update = %q, want %q", item.Status, itemProgressStatusDownloading)
|
||||
} else if item.Progress != 0.37 {
|
||||
t.Fatalf("progress after real update = %v, want 0.37", item.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemProgressFinalizingAndCompletedStatuses(t *testing.T) {
|
||||
const itemID = "progress-finalizing-item"
|
||||
RemoveItemProgress(itemID)
|
||||
defer RemoveItemProgress(itemID)
|
||||
|
||||
StartItemProgress(itemID)
|
||||
SetItemFinalizing(itemID)
|
||||
|
||||
if item := multiProgress.Items[itemID]; item == nil {
|
||||
t.Fatal("expected item progress entry to exist")
|
||||
} else if item.Status != itemProgressStatusFinalizing {
|
||||
t.Fatalf("status = %q, want %q", item.Status, itemProgressStatusFinalizing)
|
||||
}
|
||||
|
||||
CompleteItemProgress(itemID)
|
||||
if item := multiProgress.Items[itemID]; item == nil {
|
||||
t.Fatal("expected item progress entry to exist after completion")
|
||||
} else if item.Status != itemProgressStatusCompleted {
|
||||
t.Fatalf("status = %q, want %q", item.Status, itemProgressStatusCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiProgressDeltaResetChangedAndRemoved(t *testing.T) {
|
||||
ClearAllItemProgress()
|
||||
defer ClearAllItemProgress()
|
||||
|
||||
StartItemProgress("item-a")
|
||||
SetItemBytesTotal("item-a", 1000)
|
||||
|
||||
var initial MultiProgressDelta
|
||||
if err := json.Unmarshal([]byte(GetMultiProgressDelta(0)), &initial); err != nil {
|
||||
t.Fatalf("initial delta parse failed: %v", err)
|
||||
}
|
||||
if !initial.Reset {
|
||||
t.Fatal("initial delta should reset")
|
||||
}
|
||||
if initial.Seq <= 0 {
|
||||
t.Fatalf("initial seq = %d, want > 0", initial.Seq)
|
||||
}
|
||||
if _, ok := initial.Items["item-a"]; !ok {
|
||||
t.Fatal("initial delta missing item-a")
|
||||
}
|
||||
|
||||
if delta := GetMultiProgressDelta(initial.Seq); delta != "" {
|
||||
t.Fatalf("delta after same seq = %q, want empty", delta)
|
||||
}
|
||||
|
||||
SetItemBytesReceivedWithSpeed("item-a", 256*1024, 2.5)
|
||||
var changed MultiProgressDelta
|
||||
if err := json.Unmarshal([]byte(GetMultiProgressDelta(initial.Seq)), &changed); err != nil {
|
||||
t.Fatalf("changed delta parse failed: %v", err)
|
||||
}
|
||||
if changed.Reset {
|
||||
t.Fatal("changed delta should not reset")
|
||||
}
|
||||
if _, ok := changed.Items["item-a"]; !ok {
|
||||
t.Fatal("changed delta missing item-a")
|
||||
}
|
||||
|
||||
RemoveItemProgress("item-a")
|
||||
var removed MultiProgressDelta
|
||||
if err := json.Unmarshal([]byte(GetMultiProgressDelta(changed.Seq)), &removed); err != nil {
|
||||
t.Fatalf("removed delta parse failed: %v", err)
|
||||
}
|
||||
if len(removed.Removed) != 1 || removed.Removed[0] != "item-a" {
|
||||
t.Fatalf("removed = %#v, want item-a", removed.Removed)
|
||||
}
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func buildTestQobuzAlbum(id, title, artist string, tracks ...QobuzTrack) *qobuzAlbumDetails {
|
||||
album := &qobuzAlbumDetails{
|
||||
ID: id,
|
||||
Title: title,
|
||||
ReleaseDateOriginal: "2013-05-20",
|
||||
TracksCount: len(tracks),
|
||||
ProductType: "album",
|
||||
ReleaseType: "album",
|
||||
}
|
||||
album.Artist = qobuzArtistRef{ID: 1, Name: artist}
|
||||
album.Artists = []qobuzArtistRef{{ID: 1, Name: artist}}
|
||||
album.Tracks.Items = tracks
|
||||
return album
|
||||
}
|
||||
|
||||
func TestParseQobuzURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantType string
|
||||
wantID string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "store album url",
|
||||
input: "https://www.qobuz.com/us-en/album/harry-styles-harry-styles/0886446451985",
|
||||
wantType: "album",
|
||||
wantID: "0886446451985",
|
||||
},
|
||||
{
|
||||
name: "store playlist url",
|
||||
input: "https://www.qobuz.com/us-en/playlists/new-releases/2049430",
|
||||
wantType: "playlist",
|
||||
wantID: "2049430",
|
||||
},
|
||||
{
|
||||
name: "store artist url",
|
||||
input: "https://www.qobuz.com/us-en/interpreter/harry-styles/729886",
|
||||
wantType: "artist",
|
||||
wantID: "729886",
|
||||
},
|
||||
{
|
||||
name: "play track url",
|
||||
input: "https://play.qobuz.com/track/40681594",
|
||||
wantType: "track",
|
||||
wantID: "40681594",
|
||||
},
|
||||
{
|
||||
name: "custom scheme playlist url",
|
||||
input: "qobuzapp://playlist/2049430",
|
||||
wantType: "playlist",
|
||||
wantID: "2049430",
|
||||
},
|
||||
{
|
||||
name: "unsupported url",
|
||||
input: "https://example.com/not-qobuz",
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
gotType, gotID, err := parseQobuzURL(test.input)
|
||||
if test.expectErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if gotType != test.wantType || gotID != test.wantID {
|
||||
t.Fatalf("parseQobuzURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQobuzArtistAlbumIDs(t *testing.T) {
|
||||
body := []byte(`
|
||||
<div class="product__item">
|
||||
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
|
||||
</div>
|
||||
<div class="product__item">
|
||||
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
|
||||
</div>
|
||||
<div class="product__item">
|
||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||
</div>
|
||||
`)
|
||||
|
||||
matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
|
||||
if len(matches) != 3 {
|
||||
t.Fatalf("expected 3 regex matches, got %d", len(matches))
|
||||
}
|
||||
if string(matches[0][1]) != "yrpbt0lwm3g0y" {
|
||||
t.Fatalf("unexpected first album id: %q", matches[0][1])
|
||||
}
|
||||
if string(matches[2][1]) != "0886446451985" {
|
||||
t.Fatalf("unexpected last album id: %q", matches[2][1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
|
||||
body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`)
|
||||
|
||||
info, err := extractQobuzDownloadInfoFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if info.DownloadURL != "https://example.test/new.flac" {
|
||||
t.Fatalf("unexpected URL: %q", info.DownloadURL)
|
||||
}
|
||||
if info.BitDepth != 24 {
|
||||
t.Fatalf("unexpected bit depth: %d", info.BitDepth)
|
||||
}
|
||||
if info.SampleRate != 96000 {
|
||||
t.Fatalf("unexpected sample rate: %d", info.SampleRate)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reads nested data.url", func(t *testing.T) {
|
||||
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
|
||||
|
||||
got, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != "https://example.test/audio.flac" {
|
||||
t.Fatalf("unexpected URL: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reads top-level url", func(t *testing.T) {
|
||||
body := []byte(`{"url":"https://example.test/top.flac"}`)
|
||||
|
||||
got, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != "https://example.test/top.flac" {
|
||||
t.Fatalf("unexpected URL: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns API error", func(t *testing.T) {
|
||||
body := []byte(`{"error":"track not found"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "track not found" {
|
||||
t.Fatalf("expected track-not-found error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns message when success false", func(t *testing.T) {
|
||||
body := []byte(`{"success":false,"message":"blocked"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "blocked" {
|
||||
t.Fatalf("expected blocked error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns detail error", func(t *testing.T) {
|
||||
body := []byte(`{"detail":"Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']" {
|
||||
t.Fatalf("expected detail error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeQobuzQualityCode(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": "6",
|
||||
"5": "6",
|
||||
"6": "6",
|
||||
"cd": "6",
|
||||
"lossless": "6",
|
||||
"7": "7",
|
||||
"hi-res": "7",
|
||||
"27": "27",
|
||||
"hi-res-max": "27",
|
||||
"unexpected": "6",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := normalizeQobuzQualityCode(input); got != want {
|
||||
t.Fatalf("normalizeQobuzQualityCode(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
|
||||
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
|
||||
if err != nil {
|
||||
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||
t.Fatalf("payload is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
|
||||
t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
|
||||
}
|
||||
if got := payload["quality"]; got != "hi-res" {
|
||||
t.Fatalf("payload quality = %v, want hi-res", got)
|
||||
}
|
||||
if got := payload["upload_to_r2"]; got != false {
|
||||
t.Fatalf("payload upload_to_r2 = %v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||
body := []byte(`
|
||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||
<button data-itemtype="album" data-itemId="pvv406bth40ya"></button>
|
||||
`)
|
||||
|
||||
got := extractQobuzAlbumIDsFromArtistHTML(body)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 unique album IDs, got %d (%v)", len(got), got)
|
||||
}
|
||||
if got[0] != "0886446451985" || got[1] != "pvv406bth40ya" {
|
||||
t.Fatalf("unexpected album IDs: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzAvailableProviders(t *testing.T) {
|
||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||
if len(providers) != 6 {
|
||||
t.Fatalf("expected 6 Qobuz providers, got %d", len(providers))
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"musicdl": qobuzAPIKindMusicDL,
|
||||
"zarz": qobuzAPIKindMusicDL,
|
||||
"dabmusic": qobuzAPIKindStandard,
|
||||
"deeb": qobuzAPIKindStandard,
|
||||
"qbz": qobuzAPIKindStandard,
|
||||
"squid": qobuzAPIKindStandard,
|
||||
}
|
||||
|
||||
for _, provider := range providers {
|
||||
wantKind, ok := want[provider.Name]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected provider %q", provider.Name)
|
||||
}
|
||||
if provider.Kind != wantKind {
|
||||
t.Fatalf("provider %q has kind %q, want %q", provider.Name, provider.Kind, wantKind)
|
||||
}
|
||||
delete(want, provider.Name)
|
||||
}
|
||||
|
||||
if len(want) != 0 {
|
||||
t.Fatalf("missing providers: %v", want)
|
||||
}
|
||||
}
|
||||
|
||||
func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
|
||||
track := &QobuzTrack{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Duration: duration,
|
||||
}
|
||||
track.Performer.Name = artist
|
||||
return track
|
||||
}
|
||||
|
||||
func TestSelectQobuzTracksFromAlbumSearchResultsPrefersMatchingTrack(t *testing.T) {
|
||||
summaries := []qobuzAlbumDetails{
|
||||
{ID: "album-a"},
|
||||
{ID: "album-b"},
|
||||
}
|
||||
|
||||
match := *testQobuzTrack(1, "Get Lucky", "Daft Punk", 369)
|
||||
other := *testQobuzTrack(2, "Fragments of Time", "Daft Punk", 280)
|
||||
fallback := *testQobuzTrack(3, "Da Funk", "Daft Punk", 330)
|
||||
|
||||
albums := map[string]*qobuzAlbumDetails{
|
||||
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", match, other),
|
||||
"album-b": buildTestQobuzAlbum("album-b", "Homework", "Daft Punk", fallback),
|
||||
}
|
||||
|
||||
tracks, err := selectQobuzTracksFromAlbumSearchResults(
|
||||
"daft punk get lucky",
|
||||
3,
|
||||
summaries,
|
||||
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(tracks) == 0 {
|
||||
t.Fatal("expected tracks, got none")
|
||||
}
|
||||
if tracks[0].ID != 1 {
|
||||
t.Fatalf("expected Get Lucky to rank first, got track id %d", tracks[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectQobuzTracksFromAlbumSearchResultsDedupesTracks(t *testing.T) {
|
||||
summaries := []qobuzAlbumDetails{
|
||||
{ID: "album-a"},
|
||||
{ID: "album-b"},
|
||||
}
|
||||
|
||||
shared := *testQobuzTrack(42, "Get Lucky", "Daft Punk", 369)
|
||||
|
||||
albums := map[string]*qobuzAlbumDetails{
|
||||
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", shared),
|
||||
"album-b": buildTestQobuzAlbum("album-b", "Random Access Memories Deluxe", "Daft Punk", shared),
|
||||
}
|
||||
|
||||
tracks, err := selectQobuzTracksFromAlbumSearchResults(
|
||||
"daft punk get lucky",
|
||||
5,
|
||||
summaries,
|
||||
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("expected 1 deduped track, got %d", len(tracks))
|
||||
}
|
||||
if tracks[0].ID != 42 {
|
||||
t.Fatalf("unexpected deduped track id: %d", tracks[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
|
||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||
t.Cleanup(func() {
|
||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||
GetTrackIDCache().Clear()
|
||||
})
|
||||
GetTrackIDCache().Clear()
|
||||
|
||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||
if trackID != 111 {
|
||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||
}
|
||||
return testQobuzTrack(111, "Aperture", "Harry Styles", 180), nil
|
||||
}
|
||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
if isrc != "TESTISRC1" {
|
||||
t.Fatalf("unexpected ISRC lookup: %q", isrc)
|
||||
}
|
||||
if expectedDurationSec != 180 {
|
||||
t.Fatalf("unexpected duration: %d", expectedDurationSec)
|
||||
}
|
||||
return testQobuzTrack(222, "Taste Back", "Harry Styles", 180), nil
|
||||
}
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("metadata fallback should not run when ISRC fallback succeeds")
|
||||
return nil, nil
|
||||
}
|
||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
if spotifyTrackID != "spotify-track-id" {
|
||||
t.Fatalf("unexpected spotify ID: %q", spotifyTrackID)
|
||||
}
|
||||
if isrc != "TESTISRC1" {
|
||||
t.Fatalf("unexpected SongLink ISRC: %q", isrc)
|
||||
}
|
||||
return &TrackAvailability{QobuzID: "111"}, nil
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
ISRC: "TESTISRC1",
|
||||
SpotifyID: "spotify-track-id",
|
||||
TrackName: "Taste Back",
|
||||
ArtistName: "Harry Styles",
|
||||
DurationMS: 180000,
|
||||
}
|
||||
|
||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if track == nil || track.ID != 222 || track.Title != "Taste Back" {
|
||||
t.Fatalf("unexpected resolved track: %+v", track)
|
||||
}
|
||||
|
||||
cached := GetTrackIDCache().Get(req.ISRC)
|
||||
if cached == nil || cached.QobuzTrackID != 222 {
|
||||
t.Fatalf("expected validated fallback track to be cached, got %+v", cached)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
|
||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||
t.Cleanup(func() {
|
||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||
})
|
||||
|
||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||
if trackID != 333 {
|
||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||
}
|
||||
return testQobuzTrack(333, "American Girls", "Harry Styles", 181), nil
|
||||
}
|
||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("ISRC fallback should not run without an ISRC")
|
||||
return nil, nil
|
||||
}
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("metadata fallback should not run")
|
||||
return nil, nil
|
||||
}
|
||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
||||
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
QobuzID: "333",
|
||||
TrackName: "Taste Back",
|
||||
ArtistName: "Harry Styles",
|
||||
DurationMS: 181000,
|
||||
}
|
||||
|
||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got track %+v", track)
|
||||
}
|
||||
if track != nil {
|
||||
t.Fatalf("expected nil track, got %+v", track)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testing.T) {
|
||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||
t.Cleanup(func() {
|
||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||
})
|
||||
|
||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||
if trackID != 40681594 {
|
||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||
}
|
||||
return testQobuzTrack(40681594, "Sign of the Times", "Harry Styles", 341), nil
|
||||
}
|
||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("ISRC fallback should not run when request qobuz id succeeds")
|
||||
return nil, nil
|
||||
}
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("metadata fallback should not run when request qobuz id succeeds")
|
||||
return nil, nil
|
||||
}
|
||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
||||
t.Fatal("SongLink should not run when request qobuz id is provided")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
QobuzID: "qobuz:40681594",
|
||||
TrackName: "Sign of the Times",
|
||||
ArtistName: "Harry Styles",
|
||||
DurationMS: 341000,
|
||||
}
|
||||
|
||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if track == nil || track.ID != 40681594 {
|
||||
t.Fatalf("unexpected resolved track: %+v", track)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Ringišpil",
|
||||
ArtistName: "Djordje Balasevic",
|
||||
}
|
||||
|
||||
track := &QobuzTrack{
|
||||
Title: "Different Title",
|
||||
Duration: 0,
|
||||
}
|
||||
track.Performer.Name = "Different Artist"
|
||||
|
||||
if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTidalURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantType string
|
||||
wantID string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "track url",
|
||||
input: "https://tidal.com/track/77616174",
|
||||
wantType: "track",
|
||||
wantID: "77616174",
|
||||
},
|
||||
{
|
||||
name: "browse album url",
|
||||
input: "https://listen.tidal.com/browse/album/77616169",
|
||||
wantType: "album",
|
||||
wantID: "77616169",
|
||||
},
|
||||
{
|
||||
name: "artist url",
|
||||
input: "https://www.tidal.com/artist/3852143",
|
||||
wantType: "artist",
|
||||
wantID: "3852143",
|
||||
},
|
||||
{
|
||||
name: "playlist url",
|
||||
input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||
wantType: "playlist",
|
||||
wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||
},
|
||||
{
|
||||
name: "unsupported host",
|
||||
input: "https://example.com/track/123",
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
gotType, gotID, err := parseTidalURL(test.input)
|
||||
if test.expectErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if gotType != test.wantType || gotID != test.wantID {
|
||||
t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTidalRequestTrackID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{input: "40681594", want: 40681594, ok: true},
|
||||
{input: "tidal:40681594", want: 40681594, ok: true},
|
||||
{input: " tidal:40681594 ", want: 40681594, ok: true},
|
||||
{input: "", want: 0, ok: false},
|
||||
{input: "tidal:not-a-number", want: 0, ok: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
got, ok := parseTidalRequestTrackID(test.input)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalImageURL(t *testing.T) {
|
||||
got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280")
|
||||
want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg"
|
||||
if got != want {
|
||||
t.Fatalf("tidalImageURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalTrackToTrackMetadata(t *testing.T) {
|
||||
track := &TidalTrack{
|
||||
ID: 77616174,
|
||||
Title: "Bruckner: Symphony No. 5",
|
||||
ISRC: "GBUM71507433",
|
||||
Duration: 1172,
|
||||
TrackNumber: 5,
|
||||
VolumeNumber: 1,
|
||||
URL: "http://www.tidal.com/track/77616174",
|
||||
}
|
||||
track.Artist.ID = 3852143
|
||||
track.Artist.Name = "Staatskapelle Berlin"
|
||||
track.Artists = []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Picture string `json:"picture"`
|
||||
}{
|
||||
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||
}
|
||||
track.Album.ID = 77616169
|
||||
track.Album.Title = "Bruckner: Symphonies 4-9"
|
||||
track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3"
|
||||
track.Album.ReleaseDate = "2016-02-26"
|
||||
|
||||
got := tidalTrackToTrackMetadata(track)
|
||||
if got.SpotifyID != "tidal:77616174" {
|
||||
t.Fatalf("unexpected track ID: %q", got.SpotifyID)
|
||||
}
|
||||
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||
}
|
||||
if got.AlbumID != "tidal:77616169" {
|
||||
t.Fatalf("unexpected album ID: %q", got.AlbumID)
|
||||
}
|
||||
if got.ArtistID != "tidal:3852143" {
|
||||
t.Fatalf("unexpected artist ID: %q", got.ArtistID)
|
||||
}
|
||||
if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" {
|
||||
t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalAlbumToArtistAlbum(t *testing.T) {
|
||||
album := &tidalPublicAlbum{
|
||||
ID: 77616169,
|
||||
Title: "Bruckner: Symphonies 4-9",
|
||||
Type: "ALBUM",
|
||||
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||
ReleaseDate: "2016-02-26",
|
||||
NumberOfTracks: 23,
|
||||
Artists: []tidalPublicArtist{
|
||||
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||
},
|
||||
}
|
||||
|
||||
got := tidalAlbumToArtistAlbum(album)
|
||||
if got.ID != "tidal:77616169" {
|
||||
t.Fatalf("unexpected album ID: %q", got.ID)
|
||||
}
|
||||
if got.AlbumType != "album" {
|
||||
t.Fatalf("unexpected album type: %q", got.AlbumType)
|
||||
}
|
||||
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||
}
|
||||
if got.Images == "" {
|
||||
t.Fatalf("expected image URL, got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) {
|
||||
album := &tidalPublicAlbum{
|
||||
ID: 490623904,
|
||||
Title: "LET 'EM KNOW",
|
||||
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||
NumberOfTracks: 1,
|
||||
}
|
||||
|
||||
got := tidalAlbumToArtistAlbumWithType(album, "single")
|
||||
if got.AlbumType != "single" {
|
||||
t.Fatalf("unexpected fallback album type: %q", got.AlbumType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
title string
|
||||
want string
|
||||
}{
|
||||
{title: "Albums", want: "album"},
|
||||
{title: "EP & Singles", want: "single"},
|
||||
{title: "Compilations", want: "album"},
|
||||
{title: "Appears On", want: "album"},
|
||||
{title: "Unknown", want: ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want {
|
||||
t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalPlaylistImageUsesOrigin(t *testing.T) {
|
||||
got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin")
|
||||
want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg"
|
||||
if got != want {
|
||||
t.Fatalf("unexpected origin playlist image URL: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalPlaylistOwnerName(t *testing.T) {
|
||||
editorial := &tidalPublicPlaylist{Type: "EDITORIAL"}
|
||||
if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" {
|
||||
t.Fatalf("unexpected editorial owner: %q", got)
|
||||
}
|
||||
|
||||
artist := &tidalPublicPlaylist{Type: "ARTIST"}
|
||||
if got := tidalPlaylistOwnerName(artist); got != "Artist" {
|
||||
t.Fatalf("unexpected artist owner: %q", got)
|
||||
}
|
||||
|
||||
user := &tidalPublicPlaylist{}
|
||||
user.Creator.Name = "djtest"
|
||||
if got := tidalPlaylistOwnerName(user); got != "djtest" {
|
||||
t.Fatalf("unexpected creator owner: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,259 @@ func normalizeSymbolOnlyTitle(title string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func artistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := normalizeLooseArtistName(expectedArtist)
|
||||
normFound := normalizeLooseArtistName(foundArtist)
|
||||
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(normExpected, normFound) ||
|
||||
strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
expectedArtists := splitArtists(normExpected)
|
||||
foundArtists := splitArtists(normFound)
|
||||
|
||||
for _, expected := range expectedArtists {
|
||||
for _, found := range foundArtists {
|
||||
if expected == found {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(expected, found) ||
|
||||
strings.Contains(found, expected) {
|
||||
return true
|
||||
}
|
||||
if sameWordsUnordered(expected, found) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isLatinScript(expectedArtist) != isLatinScript(foundArtist)
|
||||
}
|
||||
|
||||
func splitArtists(artists string) []string {
|
||||
normalized := artists
|
||||
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " ft ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " & ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " and ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, ", ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " x ", "|")
|
||||
|
||||
parts := strings.Split(normalized, "|")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sameWordsUnordered(a, b string) bool {
|
||||
wordsA := strings.Fields(a)
|
||||
wordsB := strings.Fields(b)
|
||||
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
sortedA := make([]string, len(wordsA))
|
||||
sortedB := make([]string, len(wordsB))
|
||||
copy(sortedA, wordsA)
|
||||
copy(sortedB, wordsB)
|
||||
|
||||
for i := 0; i < len(sortedA)-1; i++ {
|
||||
for j := i + 1; j < len(sortedA); j++ {
|
||||
if sortedA[i] > sortedA[j] {
|
||||
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
|
||||
}
|
||||
if sortedB[i] > sortedB[j] {
|
||||
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range sortedA {
|
||||
if sortedA[i] != sortedB[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(normExpected, normFound) ||
|
||||
strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
cleanExpected := cleanTitle(normExpected)
|
||||
cleanFound := cleanTitle(normFound)
|
||||
if cleanExpected == cleanFound {
|
||||
return true
|
||||
}
|
||||
|
||||
if cleanExpected != "" && cleanFound != "" {
|
||||
if strings.Contains(cleanExpected, cleanFound) ||
|
||||
strings.Contains(cleanFound, cleanExpected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
coreExpected := extractCoreTitle(normExpected)
|
||||
coreFound := extractCoreTitle(normFound)
|
||||
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
||||
return true
|
||||
}
|
||||
|
||||
looseExpected := normalizeLooseTitle(normExpected)
|
||||
looseFound := normalizeLooseTitle(normFound)
|
||||
if looseExpected != "" && looseFound != "" {
|
||||
if looseExpected == looseFound {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(looseExpected, looseFound) ||
|
||||
strings.Contains(looseFound, looseExpected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
|
||||
strings.TrimSpace(expectedTitle) != "" &&
|
||||
strings.TrimSpace(foundTitle) != "" {
|
||||
expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle)
|
||||
foundSymbols := normalizeSymbolOnlyTitle(foundTitle)
|
||||
if expectedSymbols != "" &&
|
||||
foundSymbols != "" &&
|
||||
expectedSymbols == foundSymbols {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func extractCoreTitle(title string) string {
|
||||
parenIdx := strings.Index(title, "(")
|
||||
bracketIdx := strings.Index(title, "[")
|
||||
dashIdx := strings.Index(title, " - ")
|
||||
|
||||
cutIdx := len(title)
|
||||
if parenIdx > 0 && parenIdx < cutIdx {
|
||||
cutIdx = parenIdx
|
||||
}
|
||||
if bracketIdx > 0 && bracketIdx < cutIdx {
|
||||
cutIdx = bracketIdx
|
||||
}
|
||||
if dashIdx > 0 && dashIdx < cutIdx {
|
||||
cutIdx = dashIdx
|
||||
}
|
||||
|
||||
return strings.TrimSpace(title[:cutIdx])
|
||||
}
|
||||
|
||||
func cleanTitle(title string) string {
|
||||
cleaned := title
|
||||
|
||||
versionPatterns := []string{
|
||||
"remaster", "remastered", "deluxe", "bonus", "single",
|
||||
"album version", "radio edit", "original mix", "extended",
|
||||
"club mix", "remix", "live", "acoustic", "demo",
|
||||
}
|
||||
|
||||
for {
|
||||
startParen := strings.LastIndex(cleaned, "(")
|
||||
endParen := strings.LastIndex(cleaned, ")")
|
||||
if startParen >= 0 && endParen > startParen {
|
||||
content := strings.ToLower(cleaned[startParen+1 : endParen])
|
||||
isVersionIndicator := false
|
||||
for _, pattern := range versionPatterns {
|
||||
if strings.Contains(content, pattern) {
|
||||
isVersionIndicator = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isVersionIndicator {
|
||||
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
for {
|
||||
startBracket := strings.LastIndex(cleaned, "[")
|
||||
endBracket := strings.LastIndex(cleaned, "]")
|
||||
if startBracket >= 0 && endBracket > startBracket {
|
||||
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
|
||||
isVersionIndicator := false
|
||||
for _, pattern := range versionPatterns {
|
||||
if strings.Contains(content, pattern) {
|
||||
isVersionIndicator = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isVersionIndicator {
|
||||
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
dashPatterns := []string{
|
||||
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||
" - live", " - acoustic", " - demo", " - remix",
|
||||
}
|
||||
for _, pattern := range dashPatterns {
|
||||
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
|
||||
cleaned = cleaned[:len(cleaned)-len(pattern)]
|
||||
}
|
||||
}
|
||||
|
||||
for strings.Contains(cleaned, " ") {
|
||||
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
|
||||
func isLatinScript(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < 128 {
|
||||
continue
|
||||
}
|
||||
if (r >= 0x0100 && r <= 0x024F) ||
|
||||
(r >= 0x1E00 && r <= 0x1EFF) ||
|
||||
(r >= 0x00C0 && r <= 0x00FF) {
|
||||
continue
|
||||
}
|
||||
if (r >= 0x4E00 && r <= 0x9FFF) ||
|
||||
(r >= 0x3040 && r <= 0x309F) ||
|
||||
(r >= 0x30A0 && r <= 0x30FF) ||
|
||||
(r >= 0xAC00 && r <= 0xD7AF) ||
|
||||
(r >= 0x0600 && r <= 0x06FF) ||
|
||||
(r >= 0x0400 && r <= 0x04FF) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type resolvedTrackInfo struct {
|
||||
Title string
|
||||
ArtistName string
|
||||
|
||||
@@ -69,18 +69,3 @@ func TestTitlesMatch_EmojiStrict(t *testing.T) {
|
||||
t.Fatal("expected identical emoji titles to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzTitlesMatch_SeparatorVariants(t *testing.T) {
|
||||
if !qobuzTitlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
||||
t.Fatal("expected qobuzTitlesMatch to accept / vs _ variant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzTitlesMatch_EmojiStrict(t *testing.T) {
|
||||
if qobuzTitlesMatch("🪐", "Higher Power") {
|
||||
t.Fatal("expected emoji title not to match unrelated textual title")
|
||||
}
|
||||
if !qobuzTitlesMatch("🪐", "🪐") {
|
||||
t.Fatal("expected identical emoji titles to match")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import Gobackend // Import Go framework
|
||||
private let CHANNEL = "com.zarz.spotiflac/backend"
|
||||
private let DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream"
|
||||
private let LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream"
|
||||
private let LARGE_JSON_RESULT_FILE_KEY = "__json_file"
|
||||
private let LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
||||
private let streamQueue = DispatchQueue(label: "com.zarz.spotiflac.progress_stream", qos: .utility)
|
||||
private var downloadProgressTimer: DispatchSourceTimer?
|
||||
private var downloadProgressEventSink: FlutterEventSink?
|
||||
private var lastDownloadProgressPayload: String?
|
||||
private var lastDownloadProgressSeq: Int64 = 0
|
||||
private var libraryScanProgressTimer: DispatchSourceTimer?
|
||||
private var libraryScanProgressEventSink: FlutterEventSink?
|
||||
private var lastLibraryScanProgressPayload: String?
|
||||
@@ -131,15 +134,17 @@ import Gobackend // Import Go framework
|
||||
stopDownloadProgressStream()
|
||||
downloadProgressEventSink = eventSink
|
||||
lastDownloadProgressPayload = nil
|
||||
lastDownloadProgressSeq = 0
|
||||
|
||||
let timer = DispatchSource.makeTimerSource(queue: streamQueue)
|
||||
timer.schedule(deadline: .now(), repeating: .milliseconds(800))
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
let payload = GobackendGetAllDownloadProgress() as String? ?? "{}"
|
||||
if payload == self.lastDownloadProgressPayload {
|
||||
let payload = GobackendGetAllDownloadProgressDelta(self.lastDownloadProgressSeq) as String? ?? ""
|
||||
if payload.isEmpty || payload == self.lastDownloadProgressPayload {
|
||||
return
|
||||
}
|
||||
self.updateDownloadProgressSeq(payload)
|
||||
self.lastDownloadProgressPayload = payload
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.downloadProgressEventSink?(self?.parseJsonPayload(payload))
|
||||
@@ -155,6 +160,7 @@ import Gobackend // Import Go framework
|
||||
downloadProgressTimer = nil
|
||||
downloadProgressEventSink = nil
|
||||
lastDownloadProgressPayload = nil
|
||||
lastDownloadProgressSeq = 0
|
||||
}
|
||||
|
||||
private func startLibraryScanProgressStream(_ eventSink: @escaping FlutterEventSink) {
|
||||
@@ -197,6 +203,34 @@ import Gobackend // Import Go framework
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDownloadProgressSeq(_ payload: String) {
|
||||
guard let data = payload.data(using: .utf8) else { return }
|
||||
do {
|
||||
if let obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any],
|
||||
let seq = obj["seq"] as? NSNumber,
|
||||
seq.int64Value > lastDownloadProgressSeq {
|
||||
lastDownloadProgressSeq = seq.int64Value
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private func bridgeJsonResult(_ payload: String) -> Any {
|
||||
if payload.utf8.count < LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES {
|
||||
return payload
|
||||
}
|
||||
|
||||
do {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge_json_\(UUID().uuidString).json")
|
||||
try payload.write(to: url, atomically: true, encoding: .utf8)
|
||||
return [LARGE_JSON_RESULT_FILE_KEY: url.path]
|
||||
} catch {
|
||||
NSLog("SpotiFLAC: failed to spill large bridge JSON result to file: \(error.localizedDescription)")
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
@@ -424,26 +458,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchTidalAll":
|
||||
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 = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchQobuzAll":
|
||||
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 = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getDeezerRelatedArtists":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let artistId = args["artist_id"] as! String
|
||||
@@ -452,62 +466,20 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getDeezerMetadata":
|
||||
case "getProviderMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let providerId = args["provider_id"] as! String
|
||||
let resourceType = args["resource_type"] as! String
|
||||
let resourceId = args["resource_id"] as! String
|
||||
let response = GobackendGetDeezerMetadata(resourceType, resourceId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getQobuzMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let resourceType = args["resource_type"] as! String
|
||||
let resourceId = args["resource_id"] as! String
|
||||
let response = GobackendGetQobuzMetadata(resourceType, resourceId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getTidalMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let resourceType = args["resource_type"] as! String
|
||||
let resourceId = args["resource_id"] as! String
|
||||
let response = GobackendGetTidalMetadata(resourceType, resourceId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseDeezerUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendParseDeezerURLExport(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseQobuzUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendParseQobuzURLExport(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseTidalUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendParseTidalURLExport(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "convertTidalToSpotifyDeezer":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendConvertTidalToSpotifyDeezer(url, &error)
|
||||
let response = GobackendGetProviderMetadataJSON(providerId, resourceType, resourceId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchDeezerByISRC":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let isrc = args["isrc"] as! String
|
||||
let response = GobackendSearchDeezerByISRC(isrc, &error)
|
||||
let itemId = args["item_id"] as? String ?? ""
|
||||
let response = GobackendSearchDeezerByISRCForItemID(isrc, itemId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
@@ -676,6 +648,13 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendGetExtensionSettingsJSON(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkExtensionHealth":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendCheckExtensionHealthJSON(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setExtensionSettings":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -823,10 +802,17 @@ import Gobackend // Import Go framework
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let query = args["query"] as! String
|
||||
let optionsJson = args["options"] as? String ?? ""
|
||||
let response = GobackendCustomSearchWithExtensionJSON(extensionId, query, optionsJson, &error)
|
||||
let requestId = args["request_id"] as? String ?? ""
|
||||
let response = GobackendCustomSearchWithExtensionJSONWithRequestID(extensionId, query, optionsJson, requestId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
|
||||
case "cancelExtensionRequest":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let requestId = args["request_id"] as? String ?? ""
|
||||
GobackendCancelExtensionRequestJSON(requestId)
|
||||
return nil
|
||||
|
||||
case "getSearchProviders":
|
||||
let response = GobackendGetSearchProvidersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
@@ -851,30 +837,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getAlbumWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let albumId = args["album_id"] as! String
|
||||
let response = GobackendGetAlbumWithExtensionJSON(extensionId, albumId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getPlaylistWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let playlistId = args["playlist_id"] as! String
|
||||
let response = GobackendGetPlaylistWithExtensionJSON(extensionId, playlistId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getArtistWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let artistId = args["artist_id"] as! String
|
||||
let response = GobackendGetArtistWithExtensionJSON(extensionId, artistId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Post-Processing API
|
||||
case "runPostProcessing":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -959,7 +921,8 @@ import Gobackend // Import Go framework
|
||||
case "getExtensionHomeFeed":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error)
|
||||
let requestId = args["request_id"] as? String ?? ""
|
||||
let response = GobackendGetExtensionHomeFeedJSONWithRequestID(extensionId, requestId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
@@ -982,7 +945,7 @@ import Gobackend // Import Go framework
|
||||
let folderPath = args["folder_path"] as! String
|
||||
let response = GobackendScanLibraryFolderJSON(folderPath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
return bridgeJsonResult(response as String? ?? "[]")
|
||||
|
||||
case "scanLibraryFolderIncremental":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -990,7 +953,7 @@ import Gobackend // Import Go framework
|
||||
let existingFiles = args["existing_files"] as? String ?? "{}"
|
||||
let response = GobackendScanLibraryFolderIncrementalJSON(folderPath, existingFiles, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
return bridgeJsonResult(response as String? ?? "{}")
|
||||
|
||||
case "getLibraryScanProgress":
|
||||
let response = GobackendGetLibraryScanProgressJSON()
|
||||
@@ -1083,8 +1046,13 @@ import Gobackend // Import Go framework
|
||||
private func createIosBookmarkFromPath(_ path: String) throws -> String {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
do {
|
||||
#if os(macOS)
|
||||
let options: URL.BookmarkCreationOptions = .withSecurityScope
|
||||
#else
|
||||
let options: URL.BookmarkCreationOptions = []
|
||||
#endif
|
||||
let bookmarkData = try url.bookmarkData(
|
||||
options: .minimalBookmark,
|
||||
options: options,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
@@ -1112,9 +1080,14 @@ import Gobackend // Import Go framework
|
||||
var isStale = false
|
||||
let url: URL
|
||||
do {
|
||||
#if os(macOS)
|
||||
let options: URL.BookmarkResolutionOptions = .withSecurityScope
|
||||
#else
|
||||
let options: URL.BookmarkResolutionOptions = []
|
||||
#endif
|
||||
url = try URL(
|
||||
resolvingBookmarkData: bookmarkData,
|
||||
options: [],
|
||||
options: options,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
)
|
||||
@@ -1147,9 +1120,14 @@ import Gobackend // Import Go framework
|
||||
var isStale = false
|
||||
let url: URL
|
||||
do {
|
||||
#if os(macOS)
|
||||
let options: URL.BookmarkResolutionOptions = .withSecurityScope
|
||||
#else
|
||||
let options: URL.BookmarkResolutionOptions = []
|
||||
#endif
|
||||
url = try URL(
|
||||
resolvingBookmarkData: bookmarkData,
|
||||
options: [],
|
||||
options: options,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 603 B |
|
Before Width: | Height: | Size: 905 B After Width: | Height: | Size: 966 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 624 B After Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 905 B After Width: | Height: | Size: 966 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.4 KiB |