mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8615cde898 | |||
| 207c0653cc | |||
| de756e5d86 | |||
| fd5db3f7b6 | |||
| d087da9409 | |||
| 43469a7ef2 | |||
| add4af831e | |||
| 4e530ffbc3 | |||
| 14f6776fdc | |||
| da1c6e9171 | |||
| 9c3e934395 | |||
| 15d2c3b465 | |||
| 8aaa6d5cbe | |||
| 9158d0228d | |||
| 2bbcda3320 | |||
| a7622676dd | |||
| 5779f910a2 | |||
| 030f44a444 | |||
| 1248270fb4 | |||
| 413e3b0686 | |||
| ac711efadc | |||
| 59f2fe880a | |||
| 355f2eba2a | |||
| f2f45fa31d | |||
| 042937a8ed | |||
| 674e9af3d0 | |||
| 76d50fab3a | |||
| 81e25d7dab | |||
| 26f26f792a | |||
| 4dfa76b49e | |||
| f511f30ad0 | |||
| a1aa1319ce | |||
| c936bd7dd0 | |||
| 3a60ea2f4e | |||
| 7dba938299 | |||
| 93e77aeb84 |
@@ -4,5 +4,5 @@ contact_links:
|
||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||
about: Check the README for setup instructions and FAQ
|
||||
- name: Extension Development Guide
|
||||
url: https://zarz.moe/docs
|
||||
url: https://spotiflac.zarz.moe/docs
|
||||
about: Documentation for building SpotiFLAC extensions
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.7"
|
||||
go-version: "1.25.8"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
@@ -93,12 +93,12 @@ jobs:
|
||||
# Accept licenses
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
|
||||
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
||||
# Install NDK r29 (supports 16KB page size for Android 15+)
|
||||
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
|
||||
|
||||
# Set NDK path
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||
|
||||
- name: Install gomobile
|
||||
run: |
|
||||
@@ -164,17 +164,22 @@ jobs:
|
||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
needs: get-version # Only depends on version, NOT android build!
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode 26.1.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.7"
|
||||
go-version: "1.25.8"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
|
||||
@@ -77,6 +77,7 @@ flutter_*.log
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
.playwright-mcp/
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
@@ -170,5 +170,18 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|
||||
| [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) | |
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This repository and its contents are provided strictly for educational and research purposes. The software is provided "as-is" without warranty of any kind, express or implied, as stated in the [MIT License](LICENSE).
|
||||
|
||||
- No copyrighted content is hosted, stored, mirrored, or distributed by this repository.
|
||||
- Users must ensure that their use of this software is properly authorized and complies with all applicable laws, regulations, and third-party terms of service.
|
||||
- This software is provided free of charge by the maintainer. If you paid a third party for access to this software in its original form from this repository, you may have been misled or scammed. Any redistribution or commercial use by third parties must comply with the terms of the repository license. No affiliation, endorsement, or support by the maintainer is implied unless explicitly stated in writing.
|
||||
- SpotiFLAC Mobile is an independent project. It is not affiliated with, endorsed by, or connected to any other project or version on other platforms that may share a similar name. The maintainer of this repository has no control over or responsibility for third-party projects.
|
||||
- The author(s) disclaim all liability for any direct, indirect, incidental, or consequential damages arising from the use or misuse of this software. Users assume all risk associated with its use.
|
||||
- If you are a copyright holder or authorized representative and believe this repository infringes upon your rights, please contact the maintainer with sufficient detail (including relevant URLs and proof of ownership). The matter will be promptly investigated and appropriate action will be taken, which may include removal of the referenced material.
|
||||
|
||||
> [!TIP]
|
||||
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||
|
||||
@@ -57,6 +57,18 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
getByName("profile") {
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
release {
|
||||
// For local builds: use release signing if key.properties exists
|
||||
// For CI builds: APK is signed by GitHub Action after build
|
||||
@@ -71,6 +83,9 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,24 +94,6 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Audio playback service for media notification / background audio -->
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- flutter_local_notifications receivers -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
|
||||
@@ -163,10 +163,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"sm-t225",
|
||||
"hammerhead",
|
||||
)
|
||||
/**
|
||||
* Check if device should use Skia instead of Impeller.
|
||||
* Returns true for devices with old/problematic GPUs or old Android versions.
|
||||
*/
|
||||
private fun shouldDisableImpeller(): Boolean {
|
||||
val hardware = Build.HARDWARE.lowercase(Locale.ROOT)
|
||||
val board = Build.BOARD.lowercase(Locale.ROOT)
|
||||
@@ -215,7 +211,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to get GPU renderer string.
|
||||
* Note: This may return empty on some devices before OpenGL context is created.
|
||||
*/
|
||||
private fun getGpuRenderer(): String {
|
||||
@@ -949,16 +944,19 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
) {
|
||||
try {
|
||||
val srcFile = java.io.File(goFilePath)
|
||||
if (srcFile.exists() && srcFile.length() > 0) {
|
||||
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
srcFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
srcFile.delete()
|
||||
if (!srcFile.exists() || srcFile.length() <= 0) {
|
||||
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
||||
}
|
||||
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())
|
||||
@@ -2384,6 +2382,41 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"rewriteSplitArtistTags" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val artist = call.argument<String>("artist") ?: ""
|
||||
val albumArtist = call.argument<String>("album_artist") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
if (filePath.startsWith("content://")) {
|
||||
val uri = Uri.parse(filePath)
|
||||
val tempPath = copyUriToTemp(uri, ".flac")
|
||||
?: return@withContext errorJson("Failed to copy SAF file to temp")
|
||||
try {
|
||||
val raw = Gobackend.rewriteSplitArtistTagsExport(tempPath, artist, albumArtist)
|
||||
val obj = JSONObject(raw)
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
return@withContext raw
|
||||
}
|
||||
|
||||
if (!writeUriFromPath(uri, tempPath)) {
|
||||
return@withContext errorJson("Failed to write rewritten tags back to SAF file")
|
||||
}
|
||||
|
||||
obj.put("file_path", filePath)
|
||||
obj.toString()
|
||||
} catch (e: Exception) {
|
||||
errorJson("Failed to rewrite split artist tags in SAF file: ${e.message}")
|
||||
} finally {
|
||||
try {
|
||||
File(tempPath).delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
} else {
|
||||
Gobackend.rewriteSplitArtistTagsExport(filePath, artist, albumArtist)
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"cleanupConnections" -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.cleanupConnections()
|
||||
@@ -2516,12 +2549,27 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
val durationMs = call.argument<Number>("duration_ms")?.toLong() ?: 0L
|
||||
val outputPath = call.argument<String>("output_path") ?: ""
|
||||
val rawAudioFilePath = call.argument<String>("audio_file_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
var safAudioTemp: String? = null
|
||||
try {
|
||||
Gobackend.fetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath)
|
||||
// Resolve SAF content:// URI to a temp file the Go backend can read
|
||||
val audioFilePath = if (rawAudioFilePath.startsWith("content://")) {
|
||||
val uri = Uri.parse(rawAudioFilePath)
|
||||
val tempPath = copyUriToTemp(uri)
|
||||
safAudioTemp = tempPath
|
||||
tempPath ?: ""
|
||||
} else {
|
||||
rawAudioFilePath
|
||||
}
|
||||
Gobackend.fetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, audioFilePath)
|
||||
"""{"success":true}"""
|
||||
} catch (e: Exception) {
|
||||
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||
} finally {
|
||||
if (safAudioTemp != null) {
|
||||
try { File(safAudioTemp).delete() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
@@ -2920,6 +2968,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"setDownloadFallbackExtensionIds" -> {
|
||||
val extensionIdsJson = call.argument<String>("extension_ids") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setExtensionFallbackProviderIDsJSON(extensionIdsJson)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"setMetadataProviderPriority" -> {
|
||||
val priorityJson = call.argument<String>("priority") ?: "[]"
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
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.2.21" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// APEv2 tag format constants.
|
||||
const (
|
||||
apeTagPreamble = "APETAGEX"
|
||||
apeTagHeaderSize = 32
|
||||
apeTagVersion2 = 2000
|
||||
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
|
||||
apeTagFlagReadOnly = 1 << 0
|
||||
// Item flags: bits 1-2 encode content type
|
||||
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
|
||||
apeItemFlagBinary = 1 << 1 // 01: binary data
|
||||
apeItemFlagLink = 2 << 1 // 10: external link
|
||||
)
|
||||
|
||||
// APETagItem represents a single key-value item in an APEv2 tag.
|
||||
type APETagItem struct {
|
||||
Key string
|
||||
Value string
|
||||
Flags uint32
|
||||
}
|
||||
|
||||
// APETag represents a complete APEv2 tag block.
|
||||
type APETag struct {
|
||||
Version uint32
|
||||
Items []APETagItem
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// ReadAPETags reads APEv2 tags from a file.
|
||||
// APEv2 tags are typically appended at the end of the file.
|
||||
// The layout is: [audio data] [APEv2 header (optional)] [items...] [APEv2 footer]
|
||||
// We locate the footer first (last 32 bytes), then read the tag block.
|
||||
func ReadAPETags(filePath string) (*APETag, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
if fileSize < apeTagHeaderSize {
|
||||
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 {
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// Retry: skip ID3v1 tag (128 bytes) if present
|
||||
if fileSize > apeTagHeaderSize+128 {
|
||||
tag, err = readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize-128)
|
||||
if err == nil {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no APEv2 tag found")
|
||||
}
|
||||
|
||||
func readAPETagAtOffset(f *os.File, fileSize, footerOffset int64) (*APETag, error) {
|
||||
if footerOffset < 0 || footerOffset+apeTagHeaderSize > fileSize {
|
||||
return nil, fmt.Errorf("invalid footer offset")
|
||||
}
|
||||
|
||||
footer := make([]byte, apeTagHeaderSize)
|
||||
if _, err := f.ReadAt(footer, footerOffset); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||
}
|
||||
|
||||
if string(footer[0:8]) != apeTagPreamble {
|
||||
return nil, fmt.Errorf("APE preamble not found")
|
||||
}
|
||||
|
||||
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||
tagSize := binary.LittleEndian.Uint32(footer[12:16]) // size of items + footer (32 bytes)
|
||||
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||
|
||||
if version != apeTagVersion2 && version != 1000 {
|
||||
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||
}
|
||||
if tagSize < apeTagHeaderSize {
|
||||
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||
}
|
||||
if itemCount > 1000 {
|
||||
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||
}
|
||||
|
||||
// This should be the footer (bit 29 clear)
|
||||
isHeader := (flags & apeTagFlagHeader) != 0
|
||||
if isHeader {
|
||||
return nil, fmt.Errorf("expected APE footer but found header")
|
||||
}
|
||||
|
||||
// tagSize includes items + footer (32 bytes), but NOT the header.
|
||||
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||
if itemsSize < 0 {
|
||||
return nil, fmt.Errorf("invalid APE tag: items size negative")
|
||||
}
|
||||
|
||||
itemsOffset := footerOffset - itemsSize
|
||||
if itemsOffset < 0 {
|
||||
return nil, fmt.Errorf("APE tag items extend before file start")
|
||||
}
|
||||
|
||||
itemsData := make([]byte, itemsSize)
|
||||
if _, err := f.ReadAt(itemsData, itemsOffset); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||
}
|
||||
|
||||
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||
}
|
||||
|
||||
return &APETag{
|
||||
Version: version,
|
||||
Items: items,
|
||||
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseAPEItems(data []byte, count int) ([]APETagItem, error) {
|
||||
items := make([]APETagItem, 0, count)
|
||||
pos := 0
|
||||
|
||||
for i := 0; i < count && pos < len(data); i++ {
|
||||
if pos+8 > len(data) {
|
||||
break
|
||||
}
|
||||
|
||||
valueSize := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
|
||||
itemFlags := binary.LittleEndian.Uint32(data[pos+4 : pos+8])
|
||||
pos += 8
|
||||
|
||||
// Key is null-terminated ASCII (2-255 bytes, case-insensitive)
|
||||
keyEnd := pos
|
||||
for keyEnd < len(data) && data[keyEnd] != 0 {
|
||||
keyEnd++
|
||||
}
|
||||
if keyEnd >= len(data) {
|
||||
break
|
||||
}
|
||||
|
||||
key := string(data[pos:keyEnd])
|
||||
pos = keyEnd + 1
|
||||
|
||||
if pos+valueSize > len(data) {
|
||||
break
|
||||
}
|
||||
value := string(data[pos : pos+valueSize])
|
||||
pos += valueSize
|
||||
|
||||
items = append(items, APETagItem{
|
||||
Key: key,
|
||||
Value: value,
|
||||
Flags: itemFlags,
|
||||
})
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// WriteAPETags writes APEv2 tags to the end of a file.
|
||||
// If the file already has APEv2 tags, they are replaced.
|
||||
// The tag is written with both header and footer.
|
||||
func WriteAPETags(filePath string, tag *APETag) error {
|
||||
existingSize, err := findExistingAPETagSize(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check existing APE tag: %w", err)
|
||||
}
|
||||
|
||||
tagData, err := marshalAPETag(tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal APE tag: %w", err)
|
||||
}
|
||||
|
||||
if existingSize > 0 {
|
||||
fi, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
newSize := fi.Size() - int64(existingSize)
|
||||
if err := os.Truncate(filePath, newSize); err != nil {
|
||||
return fmt.Errorf("failed to truncate existing APE tag: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file for writing: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(tagData); err != nil {
|
||||
return fmt.Errorf("failed to write APE tag: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findExistingAPETagSize returns the total size of an existing APE tag
|
||||
// (header + items + footer) at the end of the file, or 0 if none exists.
|
||||
func findExistingAPETagSize(filePath string) (int64, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
offsets := []int64{fileSize - apeTagHeaderSize}
|
||||
if fileSize > apeTagHeaderSize+128 {
|
||||
offsets = append(offsets, fileSize-apeTagHeaderSize-128)
|
||||
}
|
||||
|
||||
for _, offset := range offsets {
|
||||
if offset < 0 {
|
||||
continue
|
||||
}
|
||||
footer := make([]byte, apeTagHeaderSize)
|
||||
if _, err := f.ReadAt(footer, offset); err != nil {
|
||||
continue
|
||||
}
|
||||
if string(footer[0:8]) != apeTagPreamble {
|
||||
continue
|
||||
}
|
||||
|
||||
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||
if (flags & apeTagFlagHeader) != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
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 {
|
||||
totalSize += apeTagHeaderSize
|
||||
}
|
||||
|
||||
// Include any trailing data after the footer (e.g. ID3v1 128-byte tag).
|
||||
// When truncating, we must remove the APE tag AND everything after it.
|
||||
trailingBytes := fileSize - (offset + apeTagHeaderSize)
|
||||
totalSize += trailingBytes
|
||||
|
||||
return totalSize, nil
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// marshalAPETag serializes an APETag into bytes (header + items + footer).
|
||||
func marshalAPETag(tag *APETag) ([]byte, error) {
|
||||
if tag == nil || len(tag.Items) == 0 {
|
||||
return nil, fmt.Errorf("empty APE tag")
|
||||
}
|
||||
|
||||
var itemsData []byte
|
||||
for _, item := range tag.Items {
|
||||
keyBytes := []byte(item.Key)
|
||||
valueBytes := []byte(item.Value)
|
||||
|
||||
// 4 bytes: value size (LE)
|
||||
sizeBuf := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(sizeBuf, uint32(len(valueBytes)))
|
||||
|
||||
// 4 bytes: item flags (LE)
|
||||
flagsBuf := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(flagsBuf, item.Flags)
|
||||
|
||||
itemsData = append(itemsData, sizeBuf...)
|
||||
itemsData = append(itemsData, flagsBuf...)
|
||||
itemsData = append(itemsData, keyBytes...)
|
||||
itemsData = append(itemsData, 0)
|
||||
itemsData = append(itemsData, valueBytes...)
|
||||
}
|
||||
|
||||
// tagSize = items data + footer (32 bytes)
|
||||
tagSize := uint32(len(itemsData) + apeTagHeaderSize)
|
||||
itemCount := uint32(len(tag.Items))
|
||||
|
||||
version := uint32(apeTagVersion2)
|
||||
if tag.Version != 0 {
|
||||
version = tag.Version
|
||||
}
|
||||
|
||||
// flags: bit 29 = 1 (is header), bit 31 = 1 (contains header)
|
||||
headerFlags := uint32(apeTagFlagHeader | (1 << 31))
|
||||
header := buildAPEHeaderFooter(version, tagSize, itemCount, headerFlags)
|
||||
|
||||
// flags: bit 29 = 0 (is footer), bit 31 = 1 (contains header)
|
||||
footerFlags := uint32(1 << 31)
|
||||
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||
|
||||
// Final layout: header + items + footer
|
||||
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||
result = append(result, header...)
|
||||
result = append(result, itemsData...)
|
||||
result = append(result, footer...)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildAPEHeaderFooter(version, tagSize, itemCount, flags uint32) []byte {
|
||||
buf := make([]byte, apeTagHeaderSize)
|
||||
copy(buf[0:8], apeTagPreamble)
|
||||
binary.LittleEndian.PutUint32(buf[8:12], version)
|
||||
binary.LittleEndian.PutUint32(buf[12:16], tagSize)
|
||||
binary.LittleEndian.PutUint32(buf[16:20], itemCount)
|
||||
binary.LittleEndian.PutUint32(buf[20:24], flags)
|
||||
// bytes 24-31 are reserved (zeros)
|
||||
return buf
|
||||
}
|
||||
|
||||
// APETagToAudioMetadata converts an APETag to our unified AudioMetadata struct.
|
||||
func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
|
||||
if tag == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
for _, item := range tag.Items {
|
||||
key := strings.ToUpper(strings.TrimSpace(item.Key))
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "TITLE":
|
||||
metadata.Title = value
|
||||
case "ARTIST":
|
||||
metadata.Artist = value
|
||||
case "ALBUM":
|
||||
metadata.Album = value
|
||||
case "ALBUMARTIST", "ALBUM ARTIST":
|
||||
metadata.AlbumArtist = value
|
||||
case "GENRE":
|
||||
metadata.Genre = value
|
||||
case "YEAR":
|
||||
metadata.Year = value
|
||||
case "DATE":
|
||||
metadata.Date = value
|
||||
case "TRACK", "TRACKNUMBER":
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "DISC", "DISCNUMBER":
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "LYRICS", "UNSYNCEDLYRICS":
|
||||
if metadata.Lyrics == "" {
|
||||
metadata.Lyrics = value
|
||||
}
|
||||
case "LABEL", "PUBLISHER":
|
||||
metadata.Label = value
|
||||
case "COPYRIGHT":
|
||||
metadata.Copyright = value
|
||||
case "COMPOSER":
|
||||
metadata.Composer = value
|
||||
case "COMMENT":
|
||||
metadata.Comment = value
|
||||
case "REPLAYGAIN_TRACK_GAIN":
|
||||
metadata.ReplayGainTrackGain = value
|
||||
case "REPLAYGAIN_TRACK_PEAK":
|
||||
metadata.ReplayGainTrackPeak = value
|
||||
case "REPLAYGAIN_ALBUM_GAIN":
|
||||
metadata.ReplayGainAlbumGain = value
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = value
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// AudioMetadataToAPEItems converts metadata fields to APE tag items.
|
||||
func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var items []APETagItem
|
||||
addItem := func(key, value string) {
|
||||
if value != "" {
|
||||
items = append(items, APETagItem{Key: key, Value: value})
|
||||
}
|
||||
}
|
||||
|
||||
addItem("Title", metadata.Title)
|
||||
addItem("Artist", metadata.Artist)
|
||||
addItem("Album", metadata.Album)
|
||||
addItem("Album Artist", metadata.AlbumArtist)
|
||||
addItem("Genre", metadata.Genre)
|
||||
if metadata.Date != "" {
|
||||
addItem("Year", metadata.Date)
|
||||
} else if metadata.Year != "" {
|
||||
addItem("Year", metadata.Year)
|
||||
}
|
||||
if metadata.TrackNumber > 0 {
|
||||
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||
}
|
||||
if metadata.DiscNumber > 0 {
|
||||
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||
}
|
||||
addItem("ISRC", metadata.ISRC)
|
||||
addItem("Lyrics", metadata.Lyrics)
|
||||
addItem("Label", metadata.Label)
|
||||
addItem("Copyright", metadata.Copyright)
|
||||
addItem("Composer", metadata.Composer)
|
||||
addItem("Comment", metadata.Comment)
|
||||
addItem("REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
|
||||
addItem("REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
|
||||
addItem("REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
|
||||
addItem("REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// apeKeysFromFields builds a set of upper-case APE tag keys corresponding to
|
||||
// the metadata fields map sent by the editor. This is used during merge to
|
||||
// ensure that even empty (cleared) fields override old values.
|
||||
func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||
mapping := map[string]string{
|
||||
"title": "TITLE",
|
||||
"artist": "ARTIST",
|
||||
"album": "ALBUM",
|
||||
"album_artist": "ALBUM ARTIST",
|
||||
"date": "DATE",
|
||||
"genre": "GENRE",
|
||||
"track_number": "TRACK",
|
||||
"disc_number": "DISC",
|
||||
"isrc": "ISRC",
|
||||
"lyrics": "LYRICS",
|
||||
"label": "LABEL",
|
||||
"copyright": "COPYRIGHT",
|
||||
"composer": "COMPOSER",
|
||||
"comment": "COMMENT",
|
||||
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
|
||||
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
|
||||
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
|
||||
"replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK",
|
||||
}
|
||||
result := make(map[string]struct{})
|
||||
for fk, apeKey := range mapping {
|
||||
if _, present := fields[fk]; present {
|
||||
result[strings.ToUpper(apeKey)] = struct{}{}
|
||||
}
|
||||
}
|
||||
// Some fields have reader aliases that must also be cleared when the
|
||||
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
|
||||
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
|
||||
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
||||
if _, present := fields["date"]; present {
|
||||
result["DATE"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["disc_number"]; present {
|
||||
result["DISCNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["disc_total"]; present {
|
||||
result["DISCNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["track_number"]; present {
|
||||
result["TRACKNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["track_total"]; present {
|
||||
result["TRACKNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["album_artist"]; present {
|
||||
result["ALBUMARTIST"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["label"]; present {
|
||||
result["PUBLISHER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["lyrics"]; present {
|
||||
result["UNSYNCEDLYRICS"] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MergeAPEItems overlays newItems on top of existing items.
|
||||
// For each new item, if a matching key exists (case-insensitive) in existing,
|
||||
// it is replaced. New keys are appended. Existing items whose keys are NOT
|
||||
// in newItems are preserved (cover art, ReplayGain, custom tags, etc.).
|
||||
//
|
||||
// overrideKeys is an optional set of upper-case keys that should be removed
|
||||
// from existing even if they do not appear in newItems. This handles field
|
||||
// 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{}{}
|
||||
}
|
||||
for _, item := range newItems {
|
||||
combined[strings.ToUpper(item.Key)] = struct{}{}
|
||||
}
|
||||
|
||||
var merged []APETagItem
|
||||
for _, item := range existing {
|
||||
if _, overwritten := combined[strings.ToUpper(item.Key)]; !overwritten {
|
||||
merged = append(merged, item)
|
||||
}
|
||||
}
|
||||
|
||||
merged = append(merged, newItems...)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ReadAPETagsFromReader reads APEv2 tags from an io.ReaderAt + size.
|
||||
// This is useful for reading APE tags from files opened via SAF or other abstractions.
|
||||
func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
||||
if fileSize < apeTagHeaderSize {
|
||||
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)
|
||||
}
|
||||
|
||||
if string(footer[0:8]) == apeTagPreamble {
|
||||
tag, err := parseAPETagFromFooter(r, fileSize, fileSize-apeTagHeaderSize, footer)
|
||||
if err == nil {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Retry: skip ID3v1 tag (128 bytes)
|
||||
if fileSize > apeTagHeaderSize+128 {
|
||||
offset := fileSize - apeTagHeaderSize - 128
|
||||
if _, err := r.ReadAt(footer, offset); err == nil {
|
||||
if string(footer[0:8]) == apeTagPreamble {
|
||||
tag, err := parseAPETagFromFooter(r, fileSize, offset, footer)
|
||||
if err == nil {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no APEv2 tag found")
|
||||
}
|
||||
|
||||
func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) {
|
||||
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||
tagSize := binary.LittleEndian.Uint32(footer[12:16])
|
||||
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||
|
||||
if version != apeTagVersion2 && version != 1000 {
|
||||
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||
}
|
||||
if tagSize < apeTagHeaderSize {
|
||||
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||
}
|
||||
if itemCount > 1000 {
|
||||
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||
}
|
||||
if (flags & apeTagFlagHeader) != 0 {
|
||||
return nil, fmt.Errorf("expected footer, found header")
|
||||
}
|
||||
|
||||
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||
itemsOffset := footerOffset - itemsSize
|
||||
if itemsOffset < 0 {
|
||||
return nil, fmt.Errorf("APE items extend before file start")
|
||||
}
|
||||
|
||||
itemsData := make([]byte, itemsSize)
|
||||
if _, err := r.ReadAt(itemsData, itemsOffset); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||
}
|
||||
|
||||
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||
}
|
||||
|
||||
return &APETag{
|
||||
Version: version,
|
||||
Items: items,
|
||||
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||
}, nil
|
||||
}
|
||||
@@ -21,13 +21,20 @@ type AudioMetadata struct {
|
||||
Year string
|
||||
Date string
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
ISRC string
|
||||
Lyrics string
|
||||
Label string
|
||||
Copyright string
|
||||
Composer string
|
||||
Comment string
|
||||
// ReplayGain fields (text values, e.g. "-6.50 dB", "0.988831")
|
||||
ReplayGainTrackGain string
|
||||
ReplayGainTrackPeak string
|
||||
ReplayGainAlbumGain string
|
||||
ReplayGainAlbumPeak string
|
||||
}
|
||||
|
||||
type MP3Quality struct {
|
||||
@@ -168,9 +175,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||
case "TCO":
|
||||
metadata.Genre = cleanGenre(value)
|
||||
case "TRK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "TPA":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "TCM":
|
||||
metadata.Composer = value
|
||||
case "TPB":
|
||||
@@ -287,9 +294,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
case "TCON":
|
||||
metadata.Genre = cleanGenre(value)
|
||||
case "TRCK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "TPOS":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "TSRC":
|
||||
metadata.ISRC = value
|
||||
case "TCOM":
|
||||
@@ -311,6 +318,17 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||
metadata.Lyrics = userValue
|
||||
}
|
||||
upperDesc := strings.ToUpper(desc)
|
||||
switch upperDesc {
|
||||
case "REPLAYGAIN_TRACK_GAIN":
|
||||
metadata.ReplayGainTrackGain = userValue
|
||||
case "REPLAYGAIN_TRACK_PEAK":
|
||||
metadata.ReplayGainTrackPeak = userValue
|
||||
case "REPLAYGAIN_ALBUM_GAIN":
|
||||
metadata.ReplayGainAlbumGain = userValue
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = userValue
|
||||
}
|
||||
}
|
||||
|
||||
pos += 10 + frameSize
|
||||
@@ -338,7 +356,6 @@ func readID3v1(file *os.File) (*AudioMetadata, error) {
|
||||
Year: strings.TrimRight(string(tag[93:97]), " \x00"),
|
||||
}
|
||||
|
||||
// ID3v1.1 track number (if byte 125 is 0 and byte 126 is not)
|
||||
if tag[125] == 0 && tag[126] != 0 {
|
||||
metadata.TrackNumber = int(tag[126])
|
||||
}
|
||||
@@ -373,27 +390,23 @@ func extractTextFrame(data []byte) string {
|
||||
}
|
||||
}
|
||||
|
||||
// extractCommentFrame parses an ID3v2 COMM frame.
|
||||
// Format: encoding(1) + language(3) + description(null-terminated) + text
|
||||
func extractCommentFrame(data []byte) string {
|
||||
if len(data) < 5 {
|
||||
return ""
|
||||
}
|
||||
encoding := data[0]
|
||||
// skip 3-byte language code
|
||||
rest := data[4:]
|
||||
|
||||
// find null terminator separating description from text
|
||||
var text []byte
|
||||
switch encoding {
|
||||
case 1, 2: // UTF-16 variants use double-null terminator
|
||||
case 1, 2:
|
||||
for i := 0; i+1 < len(rest); i += 2 {
|
||||
if rest[i] == 0 && rest[i+1] == 0 {
|
||||
text = rest[i+2:]
|
||||
break
|
||||
}
|
||||
}
|
||||
default: // ISO-8859-1 or UTF-8
|
||||
default:
|
||||
idx := bytes.IndexByte(rest, 0)
|
||||
if idx >= 0 && idx+1 < len(rest) {
|
||||
text = rest[idx+1:]
|
||||
@@ -406,33 +419,30 @@ func extractCommentFrame(data []byte) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// re-prepend encoding byte so extractTextFrame can decode properly
|
||||
framed := make([]byte, 1+len(text))
|
||||
framed[0] = encoding
|
||||
copy(framed[1:], text)
|
||||
return extractTextFrame(framed)
|
||||
}
|
||||
|
||||
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
|
||||
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
|
||||
func extractLyricsFrame(data []byte) string {
|
||||
if len(data) < 5 {
|
||||
return ""
|
||||
}
|
||||
|
||||
encoding := data[0]
|
||||
rest := data[4:] // skip 3-byte language code
|
||||
rest := data[4:]
|
||||
|
||||
var text []byte
|
||||
switch encoding {
|
||||
case 1, 2: // UTF-16 variants use double-null terminator
|
||||
case 1, 2:
|
||||
for i := 0; i+1 < len(rest); i += 2 {
|
||||
if rest[i] == 0 && rest[i+1] == 0 {
|
||||
text = rest[i+2:]
|
||||
break
|
||||
}
|
||||
}
|
||||
default: // ISO-8859-1 or UTF-8
|
||||
default:
|
||||
idx := bytes.IndexByte(rest, 0)
|
||||
if idx >= 0 && idx+1 < len(rest) {
|
||||
text = rest[idx+1:]
|
||||
@@ -451,8 +461,6 @@ func extractLyricsFrame(data []byte) string {
|
||||
return extractTextFrame(framed)
|
||||
}
|
||||
|
||||
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
|
||||
// encoding(1) + description + separator + value.
|
||||
func extractUserTextFrame(data []byte) (string, string) {
|
||||
if len(data) < 2 {
|
||||
return "", ""
|
||||
@@ -463,7 +471,7 @@ func extractUserTextFrame(data []byte) (string, string) {
|
||||
|
||||
var descRaw, valueRaw []byte
|
||||
switch encoding {
|
||||
case 1, 2: // UTF-16 variants
|
||||
case 1, 2:
|
||||
for i := 0; i+1 < len(payload); i += 2 {
|
||||
if payload[i] == 0 && payload[i+1] == 0 {
|
||||
descRaw = payload[:i]
|
||||
@@ -471,7 +479,7 @@ func extractUserTextFrame(data []byte) (string, string) {
|
||||
break
|
||||
}
|
||||
}
|
||||
default: // ISO-8859-1 or UTF-8
|
||||
default:
|
||||
idx := bytes.IndexByte(payload, 0)
|
||||
if idx >= 0 {
|
||||
descRaw = payload[:idx]
|
||||
@@ -574,14 +582,28 @@ func cleanGenre(genre string) string {
|
||||
}
|
||||
|
||||
func parseTrackNumber(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.Index(s, "/"); idx > 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
num, _ := strconv.Atoi(s)
|
||||
num, _ := parseIndexPair(s)
|
||||
return num
|
||||
}
|
||||
|
||||
func parseIndexPair(s string) (int, int) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
first := s
|
||||
second := ""
|
||||
if idx := strings.Index(s, "/"); idx > 0 {
|
||||
first = s[:idx]
|
||||
second = s[idx+1:]
|
||||
}
|
||||
|
||||
num, _ := strconv.Atoi(strings.TrimSpace(first))
|
||||
total, _ := strconv.Atoi(strings.TrimSpace(second))
|
||||
return num, total
|
||||
}
|
||||
|
||||
func removeUnsync(data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
@@ -665,7 +687,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
|
||||
file.Seek(audioStart, io.SeekStart)
|
||||
|
||||
// Find first valid MP3 frame sync
|
||||
frameHeader := make([]byte, 4)
|
||||
var frameStart int64 = -1
|
||||
for i := 0; i < 10000; i++ {
|
||||
@@ -692,8 +713,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
|
||||
channelMode := (frameHeader[3] >> 6) & 0x03
|
||||
|
||||
// Sample rate tables: [version][index]
|
||||
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
|
||||
sampleRates := [][]int{
|
||||
{11025, 12000, 8000},
|
||||
{0, 0, 0},
|
||||
@@ -704,15 +723,12 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
quality.SampleRate = sampleRates[version][sampleRateIdx]
|
||||
}
|
||||
|
||||
// Bitrate tables for all MPEG versions and layers
|
||||
// MPEG1 Layer III
|
||||
if version == 3 && layer == 1 {
|
||||
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
|
||||
if bitrateIdx < 16 {
|
||||
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
||||
}
|
||||
}
|
||||
// MPEG2/2.5 Layer III
|
||||
if (version == 0 || version == 2) && layer == 1 {
|
||||
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
|
||||
if bitrateIdx < 16 {
|
||||
@@ -720,14 +736,11 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine samples per frame for duration calculation
|
||||
samplesPerFrame := 1152 // MPEG1 Layer III
|
||||
if version == 0 || version == 2 {
|
||||
samplesPerFrame = 576 // MPEG2/2.5 Layer III
|
||||
}
|
||||
|
||||
// Try to read Xing/VBRI header from the first frame for VBR info
|
||||
// Xing header offset depends on MPEG version and channel mode
|
||||
var xingOffset int
|
||||
if version == 3 { // MPEG1
|
||||
if channelMode == 3 { // Mono
|
||||
@@ -743,7 +756,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read enough of the first frame to find Xing/VBRI header
|
||||
xingBuf := make([]byte, 200)
|
||||
file.Seek(frameStart+4, io.SeekStart)
|
||||
n, _ := io.ReadFull(file, xingBuf)
|
||||
@@ -753,7 +765,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
vbrBytes := int64(0)
|
||||
isVBR := false
|
||||
|
||||
// Check for Xing/Info header
|
||||
if xingOffset+8 <= n {
|
||||
tag := string(xingBuf[xingOffset : xingOffset+4])
|
||||
if tag == "Xing" || tag == "Info" {
|
||||
@@ -772,7 +783,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for VBRI header (always at offset 32 from frame start + 4)
|
||||
if !isVBR && 36+26 <= n {
|
||||
if string(xingBuf[32:36]) == "VBRI" {
|
||||
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
|
||||
@@ -784,11 +794,9 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
}
|
||||
|
||||
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
|
||||
// Accurate duration from total frames
|
||||
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
|
||||
quality.Duration = int(totalSamples / int64(quality.SampleRate))
|
||||
|
||||
// Accurate average bitrate
|
||||
if vbrBytes > 0 && quality.Duration > 0 {
|
||||
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
|
||||
} else if quality.Duration > 0 {
|
||||
@@ -796,7 +804,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
|
||||
}
|
||||
} else if quality.Bitrate > 0 {
|
||||
// CBR fallback: estimate duration from file size and frame bitrate
|
||||
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
|
||||
if audioSize > 0 {
|
||||
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
|
||||
@@ -983,7 +990,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
artistValues := make([]string, 0, 1)
|
||||
albumArtistValues := make([]string, 0, 1)
|
||||
|
||||
// Read vendor string length
|
||||
var vendorLen uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
||||
return
|
||||
@@ -1012,8 +1018,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
if commentLen > remaining {
|
||||
break
|
||||
}
|
||||
// Large comment entries are typically METADATA_BLOCK_PICTURE.
|
||||
// Skip them so we can continue parsing normal text tags after/before.
|
||||
if commentLen > 512*1024 {
|
||||
reader.Seek(int64(commentLen), io.SeekCurrent)
|
||||
continue
|
||||
@@ -1049,9 +1053,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
case "GENRE":
|
||||
metadata.Genre = value
|
||||
case "TRACKNUMBER", "TRACK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "DISCNUMBER", "DISC":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "COMPOSER":
|
||||
@@ -1066,6 +1070,14 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
metadata.Label = value
|
||||
case "COPYRIGHT":
|
||||
metadata.Copyright = value
|
||||
case "REPLAYGAIN_TRACK_GAIN":
|
||||
metadata.ReplayGainTrackGain = value
|
||||
case "REPLAYGAIN_TRACK_PEAK":
|
||||
metadata.ReplayGainTrackPeak = value
|
||||
case "REPLAYGAIN_ALBUM_GAIN":
|
||||
metadata.ReplayGainAlbumGain = value
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,7 +1135,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read granule position from the last Ogg page for accurate duration
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return quality, nil
|
||||
@@ -1133,7 +1144,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
granule := readLastOggGranulePosition(file, fileSize)
|
||||
if granule > 0 {
|
||||
if isOpus {
|
||||
// Opus always uses 48kHz granule position internally
|
||||
totalSamples := granule - int64(preSkip)
|
||||
if totalSamples > 0 {
|
||||
durationSec := float64(totalSamples) / 48000.0
|
||||
@@ -1151,11 +1161,9 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback bitrate estimate if duration exists but bitrate couldn't be derived.
|
||||
if quality.Bitrate <= 0 && quality.Duration > 0 {
|
||||
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
||||
}
|
||||
// Guard against obviously invalid values from corrupted/unreliable granule reads.
|
||||
if quality.Duration > 24*60*60 {
|
||||
quality.Duration = 0
|
||||
quality.Bitrate = 0
|
||||
@@ -1167,10 +1175,7 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
return quality, nil
|
||||
}
|
||||
|
||||
// readLastOggGranulePosition seeks to the end of the file and scans backwards
|
||||
// to find the last Ogg page, then reads its granule position (bytes 6-13).
|
||||
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||
// Read the last chunk of the file to find the last OggS sync
|
||||
searchSize := int64(65536)
|
||||
if searchSize > fileSize {
|
||||
searchSize = fileSize
|
||||
@@ -1194,7 +1199,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||
if i+27 > n {
|
||||
continue
|
||||
}
|
||||
// Validate minimal header fields to avoid false positives inside payload bytes.
|
||||
version := buf[i+4]
|
||||
headerType := buf[i+5]
|
||||
if version != 0 || headerType > 0x07 {
|
||||
@@ -1212,7 +1216,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||
if i+headerLen+payloadLen > n {
|
||||
continue
|
||||
}
|
||||
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64).
|
||||
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
|
||||
}
|
||||
return 0
|
||||
@@ -1272,7 +1275,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Parse frames looking for APIC (Attached Picture)
|
||||
pos := 0
|
||||
var frameIDLen, headerLen int
|
||||
if majorVersion == 2 {
|
||||
@@ -1303,7 +1305,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||
break
|
||||
}
|
||||
|
||||
// Check for APIC (ID3v2.3/2.4) or PIC (ID3v2.2)
|
||||
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
|
||||
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
|
||||
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CueSheet represents a parsed .cue file
|
||||
type CueSheet struct {
|
||||
Performer string `json:"performer"`
|
||||
Title string `json:"title"`
|
||||
@@ -24,7 +23,6 @@ type CueSheet struct {
|
||||
Tracks []CueTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
// CueTrack represents a single track in a cue sheet
|
||||
type CueTrack struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
@@ -35,7 +33,6 @@ type CueTrack struct {
|
||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||
}
|
||||
|
||||
// CueSplitInfo represents the information needed to split a CUE+audio file
|
||||
type CueSplitInfo struct {
|
||||
CuePath string `json:"cue_path"`
|
||||
AudioPath string `json:"audio_path"`
|
||||
@@ -46,7 +43,6 @@ type CueSplitInfo struct {
|
||||
Tracks []CueSplitTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
// CueSplitTrack has the FFmpeg split parameters for a single track
|
||||
type CueSplitTrack struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
@@ -62,7 +58,6 @@ var (
|
||||
reQuoted = regexp.MustCompile(`"([^"]*)"`)
|
||||
)
|
||||
|
||||
// ParseCueFile parses a .cue file and returns a CueSheet
|
||||
func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
f, err := os.Open(cuePath)
|
||||
if err != nil {
|
||||
@@ -202,7 +197,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
return sheet, nil
|
||||
}
|
||||
|
||||
// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
|
||||
func parseCueTimestamp(ts string) float64 {
|
||||
parts := strings.Split(ts, ":")
|
||||
if len(parts) != 3 {
|
||||
@@ -216,7 +210,6 @@ func parseCueTimestamp(ts string) float64 {
|
||||
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
|
||||
}
|
||||
|
||||
// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg
|
||||
func formatCueTimestamp(seconds float64) string {
|
||||
if seconds < 0 {
|
||||
return "0"
|
||||
@@ -227,7 +220,6 @@ func formatCueTimestamp(seconds float64) string {
|
||||
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
|
||||
}
|
||||
|
||||
// unquoteCue removes surrounding quotes from a CUE value
|
||||
func unquoteCue(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
|
||||
@@ -236,14 +228,12 @@ func unquoteCue(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// parseCueFileLine parses the FILE command's filename and type
|
||||
func parseCueFileLine(rest string) (string, string) {
|
||||
rest = strings.TrimSpace(rest)
|
||||
|
||||
var filename, ftype string
|
||||
|
||||
if strings.HasPrefix(rest, "\"") {
|
||||
// Quoted filename
|
||||
endQuote := strings.Index(rest[1:], "\"")
|
||||
if endQuote >= 0 {
|
||||
filename = rest[1 : endQuote+1]
|
||||
@@ -253,7 +243,6 @@ func parseCueFileLine(rest string) (string, string) {
|
||||
filename = rest
|
||||
}
|
||||
} else {
|
||||
// Unquoted filename - last word is the type
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) >= 2 {
|
||||
ftype = parts[len(parts)-1]
|
||||
@@ -266,18 +255,14 @@ func parseCueFileLine(rest string) (string, string) {
|
||||
return filename, strings.TrimSpace(ftype)
|
||||
}
|
||||
|
||||
// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet.
|
||||
// It checks relative to the cue file's directory.
|
||||
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
cueDir := filepath.Dir(cuePath)
|
||||
|
||||
// 1. Try the exact filename from the .cue
|
||||
candidate := filepath.Join(cueDir, cueFileName)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
|
||||
// 2. Try common case variations
|
||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||
for _, ext := range commonExts {
|
||||
@@ -285,14 +270,12 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
// Try uppercase ext
|
||||
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try to find any audio file with the same base name as the .cue file
|
||||
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
|
||||
for _, ext := range commonExts {
|
||||
candidate = filepath.Join(cueDir, cueBase+ext)
|
||||
@@ -301,7 +284,6 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If there's only one audio file in the directory, use that
|
||||
entries, err := os.ReadDir(cueDir)
|
||||
if err == nil {
|
||||
audioExts := map[string]bool{
|
||||
@@ -326,13 +308,9 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// BuildCueSplitInfo creates the split information from a parsed CUE sheet.
|
||||
// This is returned to the Dart side so FFmpeg can perform the splitting.
|
||||
// audioDir, if non-empty, overrides the directory for audio file resolution.
|
||||
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
|
||||
resolveDir := cuePath
|
||||
if audioDir != "" {
|
||||
// Create a virtual path in audioDir so ResolveCueAudioPath looks there
|
||||
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||
}
|
||||
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
|
||||
@@ -360,11 +338,9 @@ func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSp
|
||||
composer = sheet.Composer
|
||||
}
|
||||
|
||||
// End time is the start of the next track, or -1 for the last track
|
||||
endSec := float64(-1)
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextTrack := sheet.Tracks[i+1]
|
||||
// Use pre-gap of next track if available, otherwise its start time
|
||||
if nextTrack.PreGap >= 0 {
|
||||
endSec = nextTrack.PreGap
|
||||
} else {
|
||||
@@ -386,11 +362,6 @@ func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSp
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ParseCueFileJSON parses a .cue file and returns JSON with split info.
|
||||
// This is the main entry point called from Dart via the platform bridge.
|
||||
// audioDir, if non-empty, overrides the directory used for resolving the
|
||||
// referenced audio file (useful when the .cue was copied to a temp dir
|
||||
// but the audio still lives in the original location, e.g. SAF).
|
||||
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
@@ -410,9 +381,6 @@ func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult
|
||||
// entries, one per track. This is used by the library scanner to populate the
|
||||
// library with individual track entries from a single CUE+FLAC album.
|
||||
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
@@ -425,13 +393,6 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
|
||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
|
||||
}
|
||||
|
||||
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
||||
// for SAF (Storage Access Framework) scenarios:
|
||||
// - audioDir: if non-empty, overrides the directory used to find the audio file
|
||||
// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for
|
||||
// virtual file paths (e.g. a content:// URI). IDs are also based on this.
|
||||
// - fileModTime: if > 0, used as the FileModTime for all results instead of
|
||||
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
|
||||
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||
return ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||
cuePath,
|
||||
@@ -483,7 +444,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||
}
|
||||
|
||||
// Try to get quality info from the audio file
|
||||
var bitDepth, sampleRate int
|
||||
var totalDurationSec float64
|
||||
audioExt := strings.ToLower(filepath.Ext(audioPath))
|
||||
@@ -505,7 +465,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
}
|
||||
}
|
||||
|
||||
// Extract cover from audio file for all tracks
|
||||
var coverPath string
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
@@ -522,13 +481,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the base path for virtual paths and IDs
|
||||
pathBase := cuePath
|
||||
if virtualPathPrefix != "" {
|
||||
pathBase = virtualPathPrefix
|
||||
}
|
||||
|
||||
// Determine fileModTime
|
||||
modTime := fileModTime
|
||||
if modTime <= 0 {
|
||||
if info, err := os.Stat(cuePath); err == nil {
|
||||
@@ -556,7 +513,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
album = "Unknown Album"
|
||||
}
|
||||
|
||||
// Calculate duration for this track
|
||||
composer := track.Composer
|
||||
if composer == "" {
|
||||
composer = sheet.Composer
|
||||
}
|
||||
|
||||
var duration int
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextStart := sheet.Tracks[i+1].StartTime
|
||||
@@ -570,9 +531,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
|
||||
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
|
||||
|
||||
// Use a virtual file path that includes the track number to ensure
|
||||
// uniqueness in the database (file_path has a UNIQUE constraint).
|
||||
// Format: /path/to/album.cue#track01 or content://...album.cue#track01
|
||||
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
|
||||
|
||||
result := LibraryScanResult{
|
||||
@@ -586,12 +544,15 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
ScannedAt: scanTime,
|
||||
ISRC: track.ISRC,
|
||||
TrackNumber: track.Number,
|
||||
TotalTracks: len(sheet.Tracks),
|
||||
DiscNumber: 1,
|
||||
TotalDiscs: 1,
|
||||
Duration: duration,
|
||||
ReleaseDate: sheet.Date,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Genre: sheet.Genre,
|
||||
Composer: composer,
|
||||
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||
}
|
||||
|
||||
|
||||
+80
-5
@@ -196,15 +196,22 @@ type deezerAlbumSimple struct {
|
||||
RecordType string `json:"record_type"`
|
||||
}
|
||||
|
||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
artistName := track.Artist.Name
|
||||
// deezerTrackArtistDisplay returns the display artist string for a track,
|
||||
// preferring the Contributors list (comma-joined) when available, falling
|
||||
// back to the primary Artist.Name.
|
||||
func deezerTrackArtistDisplay(track deezerTrack) string {
|
||||
if len(track.Contributors) > 0 {
|
||||
names := make([]string, len(track.Contributors))
|
||||
for i, a := range track.Contributors {
|
||||
names[i] = a.Name
|
||||
}
|
||||
artistName = strings.Join(names, ", ")
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
return track.Artist.Name
|
||||
}
|
||||
|
||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
artistName := deezerTrackArtistDisplay(track)
|
||||
|
||||
albumImage := track.Album.CoverXL
|
||||
if albumImage == "" {
|
||||
@@ -623,6 +630,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
}
|
||||
|
||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||
totalDiscs := 0
|
||||
for _, track := range allTracks {
|
||||
if track.DiskNumber > totalDiscs {
|
||||
totalDiscs = track.DiskNumber
|
||||
}
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||
albumType := album.RecordType
|
||||
@@ -641,7 +654,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||
Artists: track.Artist.Name,
|
||||
Artists: deezerTrackArtistDisplay(track),
|
||||
Name: track.Title,
|
||||
AlbumName: album.Title,
|
||||
AlbumArtist: artistName,
|
||||
@@ -651,6 +664,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
TrackNumber: trackNum,
|
||||
TotalTracks: album.NbTracks,
|
||||
DiscNumber: track.DiskNumber,
|
||||
TotalDiscs: totalDiscs,
|
||||
ExternalURL: track.Link,
|
||||
ISRC: isrc,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||
@@ -741,6 +755,10 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
Artists: artist.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// The Deezer /artist/{id}/albums endpoint does not return nb_tracks.
|
||||
// Fetch track counts in parallel from individual /album/{id} endpoints.
|
||||
c.fetchAlbumTrackCounts(ctx, albums)
|
||||
}
|
||||
|
||||
result := &ArtistResponsePayload{
|
||||
@@ -760,6 +778,63 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using
|
||||
// individual /album/{id} calls, since the /artist/{id}/albums endpoint does
|
||||
// not include this field. Albums whose track count is already known (non-zero)
|
||||
// are skipped.
|
||||
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
||||
// Find albums that need track counts
|
||||
type indexedID struct {
|
||||
idx int
|
||||
albumID string
|
||||
}
|
||||
var toFetch []indexedID
|
||||
for i, a := range albums {
|
||||
if a.TotalTracks == 0 {
|
||||
rawID := strings.TrimPrefix(a.ID, "deezer:")
|
||||
if rawID != "" {
|
||||
toFetch = append(toFetch, indexedID{idx: i, albumID: rawID})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(toFetch) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
const maxParallel = 10
|
||||
sem := make(chan struct{}, maxParallel)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, item := range toFetch {
|
||||
wg.Add(1)
|
||||
go func(it indexedID) {
|
||||
defer wg.Done()
|
||||
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID)
|
||||
var resp struct {
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
}
|
||||
if err := c.getJSON(ctx, albumURL, &resp); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
albums[it.idx].TotalTracks = resp.NbTracks
|
||||
mu.Unlock()
|
||||
}(item)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
|
||||
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:"))
|
||||
if normalizedArtistID == "" {
|
||||
@@ -892,7 +967,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
||||
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||
Artists: track.Artist.Name,
|
||||
Artists: deezerTrackArtistDisplay(track),
|
||||
Name: track.Title,
|
||||
AlbumName: track.Album.Title,
|
||||
AlbumArtist: track.Artist.Name,
|
||||
|
||||
@@ -1,608 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
|
||||
const deezerMusicDLURL = "https://www.musicdl.me/api/download"
|
||||
|
||||
type YoinkifyRequest struct {
|
||||
URL string `json:"url"`
|
||||
Format string `json:"format"`
|
||||
GenreSource string `json:"genreSource"`
|
||||
}
|
||||
|
||||
type DeezerDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) {
|
||||
rawSpotify := strings.TrimSpace(req.SpotifyID)
|
||||
if rawSpotify != "" {
|
||||
if isLikelySpotifyTrackID(rawSpotify) {
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil
|
||||
}
|
||||
|
||||
if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" {
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil
|
||||
}
|
||||
}
|
||||
|
||||
deezerID := strings.TrimSpace(req.DeezerID)
|
||||
if deezerID == "" {
|
||||
if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found {
|
||||
deezerID = strings.TrimSpace(prefixed)
|
||||
}
|
||||
}
|
||||
|
||||
if deezerID != "" {
|
||||
songlink := NewSongLinkClient()
|
||||
spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err)
|
||||
}
|
||||
spotifyID = strings.TrimSpace(spotifyID)
|
||||
if spotifyID == "" {
|
||||
return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID)
|
||||
}
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify")
|
||||
}
|
||||
|
||||
func isLikelySpotifyTrackID(value string) bool {
|
||||
if len(value) != 22 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error {
|
||||
payload := YoinkifyRequest{
|
||||
URL: spotifyURL,
|
||||
Format: "flac",
|
||||
GenreSource: "spotify",
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode Yoinkify request: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Yoinkify request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := GetDownloadClient().Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("failed to call Yoinkify: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
bodyText := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyText != "" {
|
||||
return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText)
|
||||
}
|
||||
return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if strings.Contains(contentType, "application/json") {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
bodyText := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyText == "" {
|
||||
bodyText = "empty JSON payload"
|
||||
}
|
||||
return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||
deezerID := strings.TrimSpace(req.DeezerID)
|
||||
if deezerID == "" {
|
||||
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
|
||||
deezerID = strings.TrimSpace(prefixed)
|
||||
}
|
||||
}
|
||||
if deezerID != "" {
|
||||
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
|
||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||
}
|
||||
return trackURL, nil
|
||||
}
|
||||
|
||||
// Try SongLink
|
||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
|
||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
// Fall through to ISRC search instead of using wrong track.
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try ISRC
|
||||
isrc := strings.TrimSpace(req.ISRC)
|
||||
if isrc != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||
if err == nil && track != nil {
|
||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
|
||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||
}
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||
}
|
||||
|
||||
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||
if err != nil {
|
||||
return nil // Can't verify — don't block the download.
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: trackResp.Track.Name,
|
||||
ArtistName: trackResp.Track.Artists,
|
||||
ISRC: trackResp.Track.ISRC,
|
||||
Duration: trackResp.Track.DurationMS / 1000,
|
||||
SkipNameVerification: skipNameVerification,
|
||||
}
|
||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
type deezerMusicDLRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
|
||||
payload := deezerMusicDLRequest{
|
||||
Platform: "deezer",
|
||||
URL: deezerTrackURL,
|
||||
}
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("MusicDL request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
|
||||
}
|
||||
|
||||
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||
}
|
||||
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
if data, ok := raw["data"].(map[string]any); ok {
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no download URL found in MusicDL response")
|
||||
}
|
||||
|
||||
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
|
||||
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
|
||||
|
||||
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
|
||||
|
||||
ctx := context.Background()
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := GetDownloadClient().Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
deezerClient := GetDeezerClient()
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
embedLyrics = false
|
||||
}
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
coverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
embedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}()
|
||||
|
||||
// Try MusicDL first (better quality), fallback to Yoinkify
|
||||
var downloadErr error
|
||||
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
||||
if deezerURLErr == nil {
|
||||
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
||||
downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID)
|
||||
if downloadErr != nil {
|
||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr)
|
||||
}
|
||||
} else {
|
||||
GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr)
|
||||
}
|
||||
|
||||
if downloadErr != nil || deezerURLErr != nil {
|
||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
||||
if err != nil {
|
||||
if deezerURLErr != nil {
|
||||
return DeezerDownloadResult{}, fmt.Errorf(
|
||||
"deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w",
|
||||
deezerURLErr,
|
||||
err,
|
||||
)
|
||||
}
|
||||
return DeezerDownloadResult{}, err
|
||||
}
|
||||
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
|
||||
if downloadErr != nil {
|
||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr)
|
||||
}
|
||||
}
|
||||
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
}
|
||||
|
||||
if isSafOutput || !req.EmbedMetadata {
|
||||
if !req.EmbedMetadata {
|
||||
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
||||
} else {
|
||||
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
}
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
}
|
||||
|
||||
bitDepth, sampleRate := 0, 0
|
||||
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return DeezerDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
@@ -25,7 +25,6 @@ var (
|
||||
)
|
||||
|
||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
// Fast path: check cache first
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists := isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
@@ -34,13 +33,11 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||
mu := buildLock.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Double-check cache after acquiring lock (another goroutine may have built it)
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists = isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
+585
-260
File diff suppressed because it is too large
Load Diff
+120
-1
@@ -2,6 +2,21 @@ package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
|
||||
|
||||
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := GetExtensionFallbackProviderIDs(); got != nil {
|
||||
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Bonus Track",
|
||||
@@ -114,6 +129,38 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Track",
|
||||
Artist: "Artist",
|
||||
DecryptionKey: "00112233",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"amazon",
|
||||
"ok",
|
||||
"/tmp/test.m4a",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.Decryption == nil {
|
||||
t.Fatal("expected decryption descriptor to be present")
|
||||
}
|
||||
if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", resp.Decryption.Strategy)
|
||||
}
|
||||
if resp.Decryption.Key != result.DecryptionKey {
|
||||
t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
SpotifyID: "spotify-track-id",
|
||||
@@ -193,7 +240,7 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||
Copyright: "",
|
||||
}
|
||||
|
||||
metadata := buildReEnrichFFmpegMetadata(req, "")
|
||||
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||
|
||||
if metadata["TITLE"] != "Song" {
|
||||
t.Fatalf("title = %q", metadata["TITLE"])
|
||||
@@ -222,3 +269,75 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Sign of the Times",
|
||||
ArtistName: "Unknown Artist",
|
||||
AlbumName: "Harry Styles",
|
||||
}
|
||||
|
||||
query := buildReEnrichSearchQuery(req)
|
||||
if query != "Sign of the Times" {
|
||||
t.Fatalf("query = %q", query)
|
||||
}
|
||||
|
||||
req = reEnrichRequest{
|
||||
TrackName: "Unknown Title",
|
||||
ArtistName: "Unknown Artist",
|
||||
AlbumName: "Harry Styles",
|
||||
}
|
||||
query = buildReEnrichSearchQuery(req)
|
||||
if query != "Harry Styles" {
|
||||
t.Fatalf("fallback album query = %q", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
|
||||
req := reEnrichRequest{}
|
||||
|
||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||
Name: "Resolved Song",
|
||||
Artists: "Resolved Artist",
|
||||
TrackNumber: 7,
|
||||
TotalTracks: 12,
|
||||
DiscNumber: 2,
|
||||
TotalDiscs: 3,
|
||||
Composer: "Composer",
|
||||
})
|
||||
|
||||
if req.TrackNumber != 7 || req.TotalTracks != 12 {
|
||||
t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks)
|
||||
}
|
||||
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
|
||||
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
|
||||
}
|
||||
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
|
||||
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
|
||||
}
|
||||
if req.Composer != "Composer" {
|
||||
t.Fatalf("composer = %q", req.Composer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackNumber: 7,
|
||||
TotalTracks: 12,
|
||||
DiscNumber: 2,
|
||||
TotalDiscs: 3,
|
||||
Composer: "Composer",
|
||||
}
|
||||
|
||||
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||
|
||||
if metadata["TRACKNUMBER"] != "7/12" {
|
||||
t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"])
|
||||
}
|
||||
if metadata["DISCNUMBER"] != "2/3" {
|
||||
t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"])
|
||||
}
|
||||
if metadata["COMPOSER"] != "Composer" {
|
||||
t.Fatalf("COMPOSER = %q", metadata["COMPOSER"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,12 @@ func compareVersions(v1, v2 string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
type LoadedExtension struct {
|
||||
type loadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
runtime *extensionRuntime
|
||||
initialized bool
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
@@ -73,7 +73,7 @@ func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||
return filtered
|
||||
}
|
||||
|
||||
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
||||
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
|
||||
if ext.VM == nil || ext.runtime == nil {
|
||||
if err := initializeVMLocked(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
@@ -100,14 +100,14 @@ func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
||||
func (ext *loadedExtension) ensureRuntimeReady() error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
return ensureRuntimeReadyLocked(ext, true)
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
ext.VMMu.Lock()
|
||||
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||
ext.VMMu.Unlock()
|
||||
@@ -116,28 +116,28 @@ func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
return ext.VM, nil
|
||||
}
|
||||
|
||||
type ExtensionManager struct {
|
||||
type extensionManager struct {
|
||||
mu sync.RWMutex
|
||||
extensions map[string]*LoadedExtension
|
||||
extensions map[string]*loadedExtension
|
||||
extensionsDir string
|
||||
dataDir string
|
||||
}
|
||||
|
||||
var (
|
||||
globalExtManager *ExtensionManager
|
||||
globalExtManager *extensionManager
|
||||
globalExtManagerOnce sync.Once
|
||||
)
|
||||
|
||||
func GetExtensionManager() *ExtensionManager {
|
||||
func getExtensionManager() *extensionManager {
|
||||
globalExtManagerOnce.Do(func() {
|
||||
globalExtManager = &ExtensionManager{
|
||||
extensions: make(map[string]*LoadedExtension),
|
||||
globalExtManager = &extensionManager{
|
||||
extensions: make(map[string]*loadedExtension),
|
||||
}
|
||||
})
|
||||
return globalExtManager
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -154,7 +154,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
||||
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -272,7 +272,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||
}
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
Enabled: false, // New extensions start disabled
|
||||
@@ -292,7 +292,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func initializeVMLocked(ext *LoadedExtension) error {
|
||||
func initializeVMLocked(ext *loadedExtension) error {
|
||||
ext.VM = nil
|
||||
ext.runtime = nil
|
||||
ext.initialized = false
|
||||
@@ -305,7 +305,7 @@ func initializeVMLocked(ext *LoadedExtension) error {
|
||||
return fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
ext.runtime = runtime
|
||||
runtime.RegisterAPIs(vm)
|
||||
runtime.RegisterGoBackendAPIs(vm)
|
||||
@@ -342,14 +342,14 @@ func initializeVMLocked(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
return initializeVMLocked(ext)
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *LoadedExtension,
|
||||
ext *loadedExtension,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
@@ -405,7 +405,7 @@ func initializeExtensionWithSettingsLocked(
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCleanupLocked(ext *LoadedExtension) error {
|
||||
func runCleanupLocked(ext *loadedExtension) error {
|
||||
if ext.VM != nil {
|
||||
script := `
|
||||
(function() {
|
||||
@@ -446,7 +446,7 @@ func runCleanupLocked(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func teardownVMLocked(ext *LoadedExtension) {
|
||||
func teardownVMLocked(ext *loadedExtension) {
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
@@ -461,7 +461,7 @@ func teardownVMLocked(ext *LoadedExtension) {
|
||||
ext.initialized = false
|
||||
}
|
||||
|
||||
func validateExtensionLoad(ext *LoadedExtension) error {
|
||||
func validateExtensionLoad(ext *loadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
@@ -472,7 +472,7 @@ func validateExtensionLoad(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
func (m *extensionManager) UnloadExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -491,7 +491,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
||||
func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -502,18 +502,18 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||
func (m *extensionManager) GetAllExtensions() []*loadedExtension {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]*LoadedExtension, 0, len(m.extensions))
|
||||
result := make([]*loadedExtension, 0, len(m.extensions))
|
||||
for _, ext := range m.extensions {
|
||||
result = append(result, ext)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||
func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -547,7 +547,7 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||
func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||
var loaded []string
|
||||
var errors []error
|
||||
|
||||
@@ -585,7 +585,7 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
||||
return loaded, errors
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
||||
func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -615,7 +615,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||
}
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
Enabled: false, // Will be restored from settings store
|
||||
@@ -643,7 +643,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
ext, err := m.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -663,7 +663,7 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
}
|
||||
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
||||
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -777,7 +777,7 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
}
|
||||
}
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: newManifest.Name,
|
||||
Manifest: newManifest,
|
||||
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
||||
@@ -812,7 +812,7 @@ type ExtensionUpgradeInfo struct {
|
||||
IsInstalled bool `json:"is_installed"`
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -871,7 +871,7 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||
func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -885,7 +885,7 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
extensions := m.GetAllExtensions()
|
||||
|
||||
type ExtensionInfo struct {
|
||||
@@ -908,6 +908,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
HasDownloadProvider bool `json:"has_download_provider"`
|
||||
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||
SkipLyrics bool `json:"skip_lyrics"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||
@@ -965,6 +966,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
||||
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||
SkipLyrics: ext.Manifest.SkipLyrics,
|
||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||
TrackMatching: ext.Manifest.TrackMatching,
|
||||
PostProcessing: ext.Manifest.PostProcessing,
|
||||
@@ -980,7 +982,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||
func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -998,7 +1000,7 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
func (m *extensionManager) CleanupExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -1020,7 +1022,7 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
func (m *extensionManager) UnloadAllExtensions() {
|
||||
m.mu.Lock()
|
||||
extensionIDs := make([]string, 0, len(m.extensions))
|
||||
for id := range m.extensions {
|
||||
@@ -1035,7 +1037,7 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
GoLog("[Extension] All extensions unloaded\n")
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||
func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -1044,13 +1046,14 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||
}
|
||||
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ext.Enabled {
|
||||
return nil, fmt.Errorf("extension is disabled")
|
||||
}
|
||||
vm, err := ext.lockReadyVM()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
@@ -1070,7 +1073,7 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
})()
|
||||
`, actionName, actionName, actionName)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
|
||||
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
||||
return nil, fmt.Errorf("action failed: %v", err)
|
||||
|
||||
@@ -115,6 +115,7 @@ type ExtensionManifest struct {
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
|
||||
+369
-103
@@ -26,7 +26,9 @@ type ExtTrackMetadata struct {
|
||||
Images string `json:"images,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ItemType string `json:"item_type,omitempty"`
|
||||
@@ -41,6 +43,7 @@ type ExtTrackMetadata struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
}
|
||||
|
||||
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
||||
@@ -93,6 +96,15 @@ type ExtDownloadURLResult struct {
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadDecryptionInfo struct {
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
IV string `json:"iv,omitempty"`
|
||||
InputFormat string `json:"input_format,omitempty"`
|
||||
OutputExtension string `json:"output_extension,omitempty"`
|
||||
Options map[string]interface{} `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type ExtDownloadResult struct {
|
||||
Success bool `json:"success"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
@@ -101,31 +113,105 @@ type ExtDownloadResult struct {
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionProviderWrapper struct {
|
||||
extension *LoadedExtension
|
||||
const genericFFmpegMOVDecryptionStrategy = "ffmpeg.mov_key"
|
||||
|
||||
func cloneDownloadDecryptionInfo(info *DownloadDecryptionInfo) *DownloadDecryptionInfo {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := &DownloadDecryptionInfo{
|
||||
Strategy: strings.TrimSpace(info.Strategy),
|
||||
Key: strings.TrimSpace(info.Key),
|
||||
IV: strings.TrimSpace(info.IV),
|
||||
InputFormat: strings.TrimSpace(info.InputFormat),
|
||||
OutputExtension: strings.TrimSpace(info.OutputExtension),
|
||||
}
|
||||
if len(info.Options) > 0 {
|
||||
cloned.Options = make(map[string]interface{}, len(info.Options))
|
||||
for key, value := range info.Options {
|
||||
cloned.Options[key] = value
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func normalizeDownloadDecryptionStrategy(strategy string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(strategy)) {
|
||||
case "", "ffmpeg.mov_key", "ffmpeg_mov_key", "mov_decryption_key", "mp4_decryption_key", "ffmpeg.mp4_decryption_key":
|
||||
return genericFFmpegMOVDecryptionStrategy
|
||||
default:
|
||||
return strings.TrimSpace(strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDownloadDecryptionInfo(info *DownloadDecryptionInfo, legacyKey string) *DownloadDecryptionInfo {
|
||||
normalized := cloneDownloadDecryptionInfo(info)
|
||||
trimmedLegacyKey := strings.TrimSpace(legacyKey)
|
||||
|
||||
if normalized == nil {
|
||||
if trimmedLegacyKey == "" {
|
||||
return nil
|
||||
}
|
||||
return &DownloadDecryptionInfo{
|
||||
Strategy: genericFFmpegMOVDecryptionStrategy,
|
||||
Key: trimmedLegacyKey,
|
||||
InputFormat: "mov",
|
||||
}
|
||||
}
|
||||
|
||||
normalized.Strategy = normalizeDownloadDecryptionStrategy(normalized.Strategy)
|
||||
if normalized.Key == "" && trimmedLegacyKey != "" {
|
||||
normalized.Key = trimmedLegacyKey
|
||||
}
|
||||
if normalized.Strategy == "" && normalized.Key != "" {
|
||||
normalized.Strategy = genericFFmpegMOVDecryptionStrategy
|
||||
}
|
||||
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.InputFormat == "" {
|
||||
normalized.InputFormat = "mov"
|
||||
}
|
||||
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.Key == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizedDownloadDecryptionKey(info *DownloadDecryptionInfo, legacyKey string) string {
|
||||
if normalized := normalizeDownloadDecryptionInfo(info, legacyKey); normalized != nil {
|
||||
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy {
|
||||
return normalized.Key
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(legacyKey)
|
||||
}
|
||||
|
||||
type extensionProviderWrapper struct {
|
||||
extension *loadedExtension
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper {
|
||||
return &ExtensionProviderWrapper{
|
||||
func newExtensionProviderWrapper(ext *loadedExtension) *extensionProviderWrapper {
|
||||
return &extensionProviderWrapper{
|
||||
extension: ext,
|
||||
vm: ext.VM,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
||||
func (p *extensionProviderWrapper) lockReadyVM() error {
|
||||
vm, err := p.extension.lockReadyVM()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -134,7 +220,7 @@ func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||
func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
}
|
||||
@@ -194,7 +280,7 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
return &searchResult, nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
|
||||
func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
}
|
||||
@@ -243,7 +329,7 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
|
||||
func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
}
|
||||
@@ -295,7 +381,7 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
||||
func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
}
|
||||
@@ -350,7 +436,7 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
||||
func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return track, nil
|
||||
}
|
||||
@@ -412,7 +498,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
return &enrichedTrack, nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) {
|
||||
func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
}
|
||||
@@ -460,7 +546,7 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
||||
return &availability, nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
|
||||
func (p *extensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
}
|
||||
@@ -510,7 +596,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
|
||||
const ExtDownloadTimeout = DownloadTimeout
|
||||
|
||||
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||
func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
}
|
||||
@@ -597,44 +683,52 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID
|
||||
ErrorType: "internal_error",
|
||||
}, nil
|
||||
}
|
||||
downloadResult.Decryption = normalizeDownloadDecryptionInfo(
|
||||
downloadResult.Decryption,
|
||||
downloadResult.DecryptionKey,
|
||||
)
|
||||
downloadResult.DecryptionKey = normalizedDownloadDecryptionKey(
|
||||
downloadResult.Decryption,
|
||||
downloadResult.DecryptionKey,
|
||||
)
|
||||
|
||||
return &downloadResult, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetMetadataProviders() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.IsMetadataProvider() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetDownloadProviders() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.IsDownloadProvider() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
func (m *extensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
providers := m.GetMetadataProviders()
|
||||
if len(providers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
providerByID := make(map[string]*ExtensionProviderWrapper, len(providers))
|
||||
orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers))
|
||||
providerByID := make(map[string]*extensionProviderWrapper, len(providers))
|
||||
orderedProviders := make([]*extensionProviderWrapper, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
providerByID[provider.extension.ID] = provider
|
||||
}
|
||||
@@ -673,6 +767,9 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
|
||||
var providerPriority []string
|
||||
var providerPriorityMu sync.RWMutex
|
||||
|
||||
var extensionFallbackProviderIDs []string
|
||||
var extensionFallbackProviderIDsMu sync.RWMutex
|
||||
|
||||
var metadataProviderPriority []string
|
||||
var metadataProviderPriorityMu sync.RWMutex
|
||||
|
||||
@@ -681,8 +778,8 @@ var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
|
||||
func SetProviderPriority(providerIDs []string) {
|
||||
providerPriorityMu.Lock()
|
||||
defer providerPriorityMu.Unlock()
|
||||
providerPriority = providerIDs
|
||||
GoLog("[Extension] Download provider priority set: %v\n", providerIDs)
|
||||
providerPriority = sanitizeDownloadProviderPriority(providerIDs)
|
||||
GoLog("[Extension] Download provider priority set: %v\n", providerPriority)
|
||||
}
|
||||
|
||||
func GetProviderPriority() []string {
|
||||
@@ -690,7 +787,7 @@ func GetProviderPriority() []string {
|
||||
defer providerPriorityMu.RUnlock()
|
||||
|
||||
if len(providerPriority) == 0 {
|
||||
return []string{"tidal", "qobuz", "deezer"}
|
||||
return []string{"tidal", "qobuz"}
|
||||
}
|
||||
|
||||
result := make([]string, len(providerPriority))
|
||||
@@ -698,6 +795,102 @@ func GetProviderPriority() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func sanitizeDownloadProviderPriority(providerIDs []string) []string {
|
||||
sanitized := make([]string, 0, len(providerIDs)+2)
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
for _, providerID := range providerIDs {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedBuiltIn := strings.ToLower(providerID)
|
||||
if normalizedBuiltIn == "deezer" {
|
||||
continue
|
||||
}
|
||||
if isBuiltInDownloadProvider(normalizedBuiltIn) {
|
||||
providerID = normalizedBuiltIn
|
||||
}
|
||||
|
||||
seenKey := strings.ToLower(providerID)
|
||||
if _, exists := seen[seenKey]; exists {
|
||||
continue
|
||||
}
|
||||
seen[seenKey] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
for _, providerID := range []string{"tidal", "qobuz"} {
|
||||
if _, exists := seen[providerID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func SetExtensionFallbackProviderIDs(providerIDs []string) {
|
||||
extensionFallbackProviderIDsMu.Lock()
|
||||
defer extensionFallbackProviderIDsMu.Unlock()
|
||||
|
||||
if providerIDs == nil {
|
||||
extensionFallbackProviderIDs = nil
|
||||
GoLog("[Extension] Extension fallback providers reset to default (all enabled download extensions)\n")
|
||||
return
|
||||
}
|
||||
|
||||
sanitized := make([]string, 0, len(providerIDs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, providerID := range providerIDs {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" || isBuiltInDownloadProvider(strings.ToLower(providerID)) {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[providerID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
extensionFallbackProviderIDs = sanitized
|
||||
GoLog("[Extension] Extension fallback providers set: %v\n", sanitized)
|
||||
}
|
||||
|
||||
func GetExtensionFallbackProviderIDs() []string {
|
||||
extensionFallbackProviderIDsMu.RLock()
|
||||
defer extensionFallbackProviderIDsMu.RUnlock()
|
||||
|
||||
if extensionFallbackProviderIDs == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, len(extensionFallbackProviderIDs))
|
||||
copy(result, extensionFallbackProviderIDs)
|
||||
return result
|
||||
}
|
||||
|
||||
func isExtensionFallbackAllowed(providerID string) bool {
|
||||
if isBuiltInDownloadProvider(strings.ToLower(providerID)) {
|
||||
return true
|
||||
}
|
||||
|
||||
allowed := GetExtensionFallbackProviderIDs()
|
||||
if allowed == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, allowedProviderID := range allowed {
|
||||
if allowedProviderID == providerID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func SetMetadataProviderPriority(providerIDs []string) {
|
||||
metadataProviderPriorityMu.Lock()
|
||||
defer metadataProviderPriorityMu.Unlock()
|
||||
@@ -749,6 +942,15 @@ func isBuiltInProvider(providerID string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isBuiltInDownloadProvider(providerID string) bool {
|
||||
switch providerID {
|
||||
case "tidal", "qobuz":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
|
||||
deezerID := ""
|
||||
tidalID := ""
|
||||
@@ -775,7 +977,9 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr
|
||||
Images: track.Images,
|
||||
ReleaseDate: track.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TotalTracks: track.TotalTracks,
|
||||
DiscNumber: track.DiscNumber,
|
||||
TotalDiscs: track.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
ProviderID: providerID,
|
||||
SpotifyID: prefixedID,
|
||||
@@ -783,6 +987,7 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr
|
||||
TidalID: tidalID,
|
||||
QobuzID: qobuzID,
|
||||
AlbumType: track.AlbumType,
|
||||
Composer: track.Composer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,13 +1029,13 @@ func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrac
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
|
||||
func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
|
||||
priority := GetMetadataProviderPriority()
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
extensionProviders := make(map[string]*ExtensionProviderWrapper)
|
||||
extensionProviders := make(map[string]*extensionProviderWrapper)
|
||||
if includeExtensions {
|
||||
for _, provider := range m.GetMetadataProviders() {
|
||||
extensionProviders[provider.extension.ID] = provider
|
||||
@@ -910,7 +1115,7 @@ func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit
|
||||
|
||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||
priority := GetProviderPriority()
|
||||
extManager := GetExtensionManager()
|
||||
extManager := getExtensionManager()
|
||||
strictMode := !req.UseFallback
|
||||
selectedProvider := strings.TrimSpace(req.Service)
|
||||
|
||||
@@ -924,7 +1129,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
if !strictMode && req.Service != "" && isBuiltInProvider(strings.ToLower(req.Service)) {
|
||||
if !strictMode && req.Service != "" && isBuiltInDownloadProvider(strings.ToLower(req.Service)) {
|
||||
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
|
||||
newPriority := []string{req.Service}
|
||||
for _, p := range priority {
|
||||
@@ -934,7 +1139,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
priority = newPriority
|
||||
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
||||
} else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) {
|
||||
} else if !strictMode && req.Service != "" && !isBuiltInDownloadProvider(strings.ToLower(req.Service)) {
|
||||
found := false
|
||||
for _, p := range priority {
|
||||
if strings.EqualFold(p, req.Service) {
|
||||
@@ -965,7 +1170,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
trackMeta := &ExtTrackMetadata{
|
||||
ID: req.SpotifyID,
|
||||
Name: req.TrackName,
|
||||
@@ -975,8 +1180,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
ISRC: req.ISRC,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ProviderID: req.Source,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
|
||||
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
||||
@@ -1037,13 +1245,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate)
|
||||
req.ReleaseDate = enrichedTrack.ReleaseDate
|
||||
}
|
||||
if enrichedTrack.TrackNumber > 0 && req.TrackNumber == 0 {
|
||||
GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber)
|
||||
req.TrackNumber = enrichedTrack.TrackNumber
|
||||
}
|
||||
if enrichedTrack.TotalTracks > 0 && req.TotalTracks == 0 {
|
||||
GoLog("[DownloadWithExtensionFallback] TotalTracks from enrichment: %d\n", enrichedTrack.TotalTracks)
|
||||
req.TotalTracks = enrichedTrack.TotalTracks
|
||||
}
|
||||
if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 {
|
||||
GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber)
|
||||
req.DiscNumber = enrichedTrack.DiscNumber
|
||||
}
|
||||
if enrichedTrack.TotalDiscs > 0 && req.TotalDiscs == 0 {
|
||||
GoLog("[DownloadWithExtensionFallback] TotalDiscs from enrichment: %d\n", enrichedTrack.TotalDiscs)
|
||||
req.TotalDiscs = enrichedTrack.TotalDiscs
|
||||
}
|
||||
if enrichedTrack.Composer != "" && req.Composer == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Composer from enrichment: %s\n", enrichedTrack.Composer)
|
||||
req.Composer = enrichedTrack.Composer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If key metadata is still missing after extension enrichment, search
|
||||
// configured metadata providers (Spotify/Deezer/Tidal/Qobuz) — same
|
||||
// logic that ReEnrichFile uses.
|
||||
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||
req.TrackName != "" && req.ArtistName != "" &&
|
||||
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
|
||||
@@ -1072,9 +1297,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if track.TrackNumber > 0 && req.TrackNumber == 0 {
|
||||
req.TrackNumber = track.TrackNumber
|
||||
}
|
||||
if track.TotalTracks > 0 && req.TotalTracks == 0 {
|
||||
req.TotalTracks = track.TotalTracks
|
||||
}
|
||||
if track.DiscNumber > 0 && req.DiscNumber == 0 {
|
||||
req.DiscNumber = track.DiscNumber
|
||||
}
|
||||
if track.TotalDiscs > 0 && req.TotalDiscs == 0 {
|
||||
req.TotalDiscs = track.TotalDiscs
|
||||
}
|
||||
if track.Composer != "" && req.Composer == "" {
|
||||
req.Composer = track.Composer
|
||||
}
|
||||
if track.CoverURL != "" && req.CoverURL == "" {
|
||||
req.CoverURL = track.CoverURL
|
||||
}
|
||||
@@ -1091,7 +1325,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
||||
}
|
||||
|
||||
// Try Deezer extended metadata if we have ISRC
|
||||
if req.ISRC != "" &&
|
||||
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -1121,7 +1354,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
||||
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
trackID := req.SpotifyID
|
||||
|
||||
@@ -1164,14 +1397,17 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||
}
|
||||
|
||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) {
|
||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||
} else {
|
||||
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
||||
}
|
||||
} else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath)
|
||||
}
|
||||
|
||||
if ext.Manifest.SkipMetadataEnrichment {
|
||||
@@ -1205,8 +1441,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// Always pass enriched metadata from req so Flutter can
|
||||
// embed it — fills gaps from metadata provider search.
|
||||
if req.AlbumName != "" && resp.Album == "" {
|
||||
resp.Album = req.AlbumName
|
||||
}
|
||||
@@ -1271,14 +1505,19 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
continue
|
||||
}
|
||||
|
||||
if skipBuiltIn && isBuiltInProvider(providerIDNormalized) {
|
||||
if skipBuiltIn && isBuiltInDownloadProvider(providerIDNormalized) {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
|
||||
continue
|
||||
}
|
||||
|
||||
if !isBuiltInDownloadProvider(providerIDNormalized) && !isExtensionFallbackAllowed(providerID) {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
|
||||
continue
|
||||
}
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||
|
||||
if isBuiltInProvider(providerIDNormalized) {
|
||||
if isBuiltInDownloadProvider(providerIDNormalized) {
|
||||
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
|
||||
req.ISRC != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||
@@ -1344,7 +1583,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
continue
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID)
|
||||
if err != nil || !availability.Available {
|
||||
@@ -1392,14 +1631,17 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||
}
|
||||
|
||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) {
|
||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||
} else {
|
||||
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
||||
}
|
||||
} else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath)
|
||||
}
|
||||
|
||||
if ext.Manifest.SkipMetadataEnrichment {
|
||||
@@ -1433,6 +1675,28 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
if req.AlbumName != "" && resp.Album == "" {
|
||||
resp.Album = req.AlbumName
|
||||
}
|
||||
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
|
||||
resp.AlbumArtist = req.AlbumArtist
|
||||
}
|
||||
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
|
||||
resp.ReleaseDate = req.ReleaseDate
|
||||
}
|
||||
if req.ISRC != "" && resp.ISRC == "" {
|
||||
resp.ISRC = req.ISRC
|
||||
}
|
||||
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
|
||||
resp.TrackNumber = req.TrackNumber
|
||||
}
|
||||
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
|
||||
resp.DiscNumber = req.DiscNumber
|
||||
}
|
||||
if req.CoverURL != "" && resp.CoverURL == "" {
|
||||
resp.CoverURL = req.CoverURL
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -1510,24 +1774,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
}
|
||||
}
|
||||
err = qobuzErr
|
||||
case "deezer":
|
||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||
if deezerErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: deezerResult.FilePath,
|
||||
BitDepth: deezerResult.BitDepth,
|
||||
SampleRate: deezerResult.SampleRate,
|
||||
Title: deezerResult.Title,
|
||||
Artist: deezerResult.Artist,
|
||||
Album: deezerResult.Album,
|
||||
ReleaseDate: deezerResult.ReleaseDate,
|
||||
TrackNumber: deezerResult.TrackNumber,
|
||||
DiscNumber: deezerResult.DiscNumber,
|
||||
ISRC: deezerResult.ISRC,
|
||||
LyricsLRC: deezerResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = deezerErr
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown built-in provider: %s", providerID)
|
||||
}
|
||||
@@ -1555,6 +1801,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
Copyright: req.Copyright,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1570,12 +1817,15 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
"album_artist": req.AlbumArtist,
|
||||
"track": req.TrackNumber,
|
||||
"track_number": req.TrackNumber,
|
||||
"total_tracks": req.TotalTracks,
|
||||
"disc": req.DiscNumber,
|
||||
"disc_number": req.DiscNumber,
|
||||
"total_discs": req.TotalDiscs,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"release_date": req.ReleaseDate,
|
||||
"isrc": req.ISRC,
|
||||
"composer": req.Composer,
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
@@ -1593,23 +1843,27 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
outputDir := req.OutputDir
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
|
||||
os.MkdirAll(outputDir, 0755)
|
||||
AddAllowedDownloadDir(outputDir)
|
||||
}
|
||||
os.MkdirAll(outputDir, 0755)
|
||||
AddAllowedDownloadDir(outputDir)
|
||||
|
||||
return filepath.Join(outputDir, filename+ext)
|
||||
}
|
||||
|
||||
func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) string {
|
||||
func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string {
|
||||
if strings.TrimSpace(req.OutputPath) != "" {
|
||||
return strings.TrimSpace(req.OutputPath)
|
||||
outputPath := strings.TrimSpace(req.OutputPath)
|
||||
AddAllowedDownloadDir(filepath.Dir(outputPath))
|
||||
return outputPath
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.OutputDir) != "" {
|
||||
// SAF downloads hand extensions a detached output FD owned by the host.
|
||||
// Extensions still need a real local temp file so Android can copy it into
|
||||
// the target document after provider-specific post-processing completes.
|
||||
if !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||
return buildOutputPath(req)
|
||||
}
|
||||
|
||||
// SAF mode: use extension's data dir as writable temp location
|
||||
tempDir := filepath.Join(ext.DataDir, "downloads")
|
||||
os.MkdirAll(tempDir, 0755)
|
||||
AddAllowedDownloadDir(tempDir)
|
||||
@@ -1621,12 +1875,15 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
|
||||
"album_artist": req.AlbumArtist,
|
||||
"track": req.TrackNumber,
|
||||
"track_number": req.TrackNumber,
|
||||
"total_tracks": req.TotalTracks,
|
||||
"disc": req.DiscNumber,
|
||||
"disc_number": req.DiscNumber,
|
||||
"total_discs": req.TotalDiscs,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"release_date": req.ReleaseDate,
|
||||
"isrc": req.ISRC,
|
||||
"composer": req.Composer,
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
@@ -1644,7 +1901,19 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
|
||||
return filepath.Join(tempDir, filename+outputExt)
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||
func canEmbedGenreLabel(filePath string) bool {
|
||||
path := strings.TrimSpace(filePath)
|
||||
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
return false
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir() && info.Size() > 0
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.HasCustomSearch() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
|
||||
}
|
||||
@@ -1726,7 +1995,7 @@ type ExtURLHandleResult struct {
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||
if !p.extension.Manifest.HasURLHandler() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
|
||||
}
|
||||
@@ -1812,7 +2081,7 @@ type MatchTrackResult struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
|
||||
func (p *extensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
|
||||
if !p.extension.Manifest.HasCustomMatching() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
|
||||
}
|
||||
@@ -1883,7 +2152,7 @@ type PostProcessInput struct {
|
||||
|
||||
const PostProcessTimeout = 2 * time.Minute
|
||||
|
||||
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||
func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||
if !p.extension.Manifest.HasPostProcessing() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||
}
|
||||
@@ -1946,7 +2215,7 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
||||
return &postResult, nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||
func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||
if !p.extension.Manifest.HasPostProcessing() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||
}
|
||||
@@ -2016,39 +2285,39 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
|
||||
return &postResult, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetSearchProviders() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetURLHandlers() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper {
|
||||
func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" {
|
||||
return NewExtensionProviderWrapper(ext)
|
||||
return newExtensionProviderWrapper(ext)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -2059,7 +2328,7 @@ type ExtURLHandleResultWithExtID struct {
|
||||
ExtensionID string
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
|
||||
func (m *extensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
|
||||
handler := m.FindURLHandler(url)
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("no extension found to handle URL: %s", url)
|
||||
@@ -2079,20 +2348,20 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetPostProcessingProviders() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.HasPostProcessing() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||
func (m *extensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||
providers := m.GetPostProcessingProviders()
|
||||
if len(providers) == 0 {
|
||||
return &PostProcessResult{Success: true, NewFilePath: filePath}, nil
|
||||
@@ -2137,7 +2406,7 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
||||
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||
func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||
providers := m.GetPostProcessingProviders()
|
||||
if len(providers) == 0 {
|
||||
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
|
||||
@@ -2205,7 +2474,7 @@ type ExtLyricsLine struct {
|
||||
EndTimeMs int64 `json:"endTimeMs"`
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
||||
func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
||||
if !p.extension.Manifest.IsLyricsProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
||||
}
|
||||
@@ -2267,7 +2536,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
||||
return nil, fmt.Errorf("failed to parse lyrics result: %w", err)
|
||||
}
|
||||
|
||||
// Convert ExtLyricsResult to LyricsResponse
|
||||
response := &LyricsResponse{
|
||||
SyncType: extResult.SyncType,
|
||||
Instrumental: extResult.Instrumental,
|
||||
@@ -2288,7 +2556,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
||||
})
|
||||
}
|
||||
|
||||
// If the extension provided plainLyrics but no lines, parse them as unsynced
|
||||
if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental {
|
||||
response.SyncType = "UNSYNCED"
|
||||
for _, line := range strings.Split(response.PlainLyrics, "\n") {
|
||||
@@ -2305,18 +2572,17 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
|
||||
// Keep a deterministic order so provider selection is stable across runs.
|
||||
sort.Slice(providers, func(i, j int) bool {
|
||||
return providers[i].extension.ID < providers[j].extension.ID
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
original := GetMetadataProviderPriority()
|
||||
@@ -19,6 +23,183 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
|
||||
|
||||
got := GetExtensionFallbackProviderIDs()
|
||||
want := []string{"ext-a", "ext-b"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs(nil)
|
||||
|
||||
if !isExtensionFallbackAllowed("custom-ext") {
|
||||
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
|
||||
}
|
||||
if !isExtensionFallbackAllowed("qobuz") {
|
||||
t.Fatal("expected built-in provider to remain allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
|
||||
|
||||
if !isExtensionFallbackAllowed("allowed-ext") {
|
||||
t.Fatal("expected explicitly allowed extension to be permitted")
|
||||
}
|
||||
if isExtensionFallbackAllowed("blocked-ext") {
|
||||
t.Fatal("expected extension outside allowlist to be blocked")
|
||||
}
|
||||
if isExtensionFallbackAllowed("deezer") {
|
||||
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||
original := GetProviderPriority()
|
||||
defer SetProviderPriority(original)
|
||||
|
||||
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
||||
|
||||
got := GetProviderPriority()
|
||||
want := []string{"qobuz", "custom-ext", "tidal"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
||||
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
||||
if normalized == nil {
|
||||
t.Fatal("expected legacy decryption key to produce normalized descriptor")
|
||||
}
|
||||
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||
}
|
||||
if normalized.Key != "001122" {
|
||||
t.Fatalf("key = %q", normalized.Key)
|
||||
}
|
||||
if normalized.InputFormat != "mov" {
|
||||
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
||||
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
|
||||
Strategy: "mp4_decryption_key",
|
||||
Key: "abcd",
|
||||
InputFormat: "",
|
||||
}, "")
|
||||
if normalized == nil {
|
||||
t.Fatal("expected descriptor to remain available")
|
||||
}
|
||||
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||
}
|
||||
if normalized.InputFormat != "mov" {
|
||||
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := buildOutputPath(DownloadRequest{
|
||||
TrackName: "Song",
|
||||
ArtistName: "Artist",
|
||||
OutputDir: outputDir,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "",
|
||||
})
|
||||
|
||||
if !isPathInAllowedDirs(outputPath) {
|
||||
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := filepath.Join(outputDir, "custom.flac")
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
OutputPath: outputPath,
|
||||
}, ext)
|
||||
|
||||
if resolved != outputPath {
|
||||
t.Fatalf("resolved output path = %q", resolved)
|
||||
}
|
||||
if !isPathInAllowedDirs(outputPath) {
|
||||
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
TrackName: "Song",
|
||||
ArtistName: "Artist",
|
||||
OutputDir: filepath.Join("Artist", "Album"),
|
||||
OutputFD: 123,
|
||||
OutputExt: ".flac",
|
||||
}, ext)
|
||||
|
||||
expectedBase := filepath.Join(ext.DataDir, "downloads")
|
||||
if !isPathWithinBase(expectedBase, resolved) {
|
||||
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
|
||||
}
|
||||
if !isPathInAllowedDirs(resolved) {
|
||||
t.Fatalf("expected resolved output path %q to be allowed", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
||||
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
|
||||
if canEmbedGenreLabel("relative.flac") {
|
||||
t.Fatal("expected relative path to be rejected")
|
||||
}
|
||||
if canEmbedGenreLabel("content://example") {
|
||||
t.Fatal("expected content URI to be rejected")
|
||||
}
|
||||
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
|
||||
t.Fatal("expected missing file to be rejected")
|
||||
}
|
||||
if !canEmbedGenreLabel(tempFile) {
|
||||
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
originalPriority := GetMetadataProviderPriority()
|
||||
originalSearch := searchBuiltInMetadataTracksFunc
|
||||
@@ -51,7 +232,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||
|
||||
@@ -80,7 +80,7 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
state.IsAuthenticated = accessToken != ""
|
||||
}
|
||||
|
||||
type ExtensionRuntime struct {
|
||||
type extensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
@@ -123,10 +123,10 @@ var (
|
||||
privateIPCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
runtime := &ExtensionRuntime{
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
@@ -142,25 +142,25 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
return runtime
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
|
||||
func (r *extensionRuntime) clearActiveDownloadItemID() {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = ""
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
|
||||
func (r *extensionRuntime) getActiveDownloadItemID() string {
|
||||
r.activeDownloadMu.RLock()
|
||||
defer r.activeDownloadMu.RUnlock()
|
||||
return r.activeDownloadItemID
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
@@ -329,11 +329,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
r.vm = vm
|
||||
|
||||
httpObj := vm.NewObject()
|
||||
@@ -377,7 +377,9 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
fileObj.Set("delete", r.fileDelete)
|
||||
fileObj.Set("read", r.fileRead)
|
||||
fileObj.Set("readBytes", r.fileReadBytes)
|
||||
fileObj.Set("write", r.fileWrite)
|
||||
fileObj.Set("writeBytes", r.fileWriteBytes)
|
||||
fileObj.Set("copy", r.fileCopy)
|
||||
fileObj.Set("move", r.fileMove)
|
||||
fileObj.Set("getSize", r.fileGetSize)
|
||||
@@ -407,6 +409,8 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
||||
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
@@ -52,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
|
||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -99,7 +99,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -111,7 +111,7 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(state.AuthCode)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.Lock()
|
||||
delete(extensionAuthState, r.extensionID)
|
||||
extensionAuthStateMu.Unlock()
|
||||
@@ -162,7 +162,7 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -178,7 +178,7 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(state.IsAuthenticated)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -201,7 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// Length should be between 43-128 characters (RFC 7636)
|
||||
func generatePKCEVerifier(length int) (string, error) {
|
||||
if length < 43 {
|
||||
length = 43
|
||||
@@ -226,11 +225,10 @@ func generatePKCEVerifier(length int) (string, error) {
|
||||
|
||||
func generatePKCEChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
// Base64url encode without padding (RFC 7636)
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
length := 64
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||
@@ -267,7 +265,7 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -283,8 +281,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -388,8 +385,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
})
|
||||
}
|
||||
|
||||
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"golang.org/x/crypto/blowfish"
|
||||
)
|
||||
|
||||
type runtimeBlockCipherOptions struct {
|
||||
Algorithm string
|
||||
Mode string
|
||||
Key []byte
|
||||
IV []byte
|
||||
InputEncoding string
|
||||
OutputEncoding string
|
||||
Padding string
|
||||
}
|
||||
|
||||
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
|
||||
if len(call.Arguments) <= index {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := call.Arguments[index]
|
||||
if goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return nil
|
||||
}
|
||||
|
||||
exported := value.Export()
|
||||
if options, ok := exported.(map[string]interface{}); ok {
|
||||
return options
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
raw, ok := options[key]
|
||||
if !ok || raw == nil {
|
||||
return defaultValue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
case []byte:
|
||||
if len(value) > 0 {
|
||||
return string(value)
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
raw, ok := options[key]
|
||||
if !ok || raw == nil {
|
||||
return defaultValue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case int:
|
||||
return value != 0
|
||||
case int64:
|
||||
return value != 0
|
||||
case float64:
|
||||
return value != 0
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
raw, ok := options[key]
|
||||
if !ok || raw == nil {
|
||||
return defaultValue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case int:
|
||||
return int64(value)
|
||||
case int32:
|
||||
return int64(value)
|
||||
case int64:
|
||||
return value
|
||||
case float32:
|
||||
return int64(value)
|
||||
case float64:
|
||||
return int64(value)
|
||||
case string:
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
var parsed int64
|
||||
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
|
||||
if options == nil {
|
||||
return false
|
||||
}
|
||||
_, exists := options[key]
|
||||
return exists
|
||||
}
|
||||
|
||||
func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "", "utf8", "utf-8", "text":
|
||||
return []byte(input), nil
|
||||
case "base64":
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base64 data: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
case "hex":
|
||||
decoded, err := hex.DecodeString(strings.TrimSpace(input))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hex data: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
return decodeRuntimeBytesString(value, encoding)
|
||||
case []byte:
|
||||
cloned := make([]byte, len(value))
|
||||
copy(cloned, value)
|
||||
return cloned, nil
|
||||
case []interface{}:
|
||||
decoded := make([]byte, len(value))
|
||||
for i, item := range value {
|
||||
switch num := item.(type) {
|
||||
case int:
|
||||
decoded[i] = byte(num)
|
||||
case int64:
|
||||
decoded[i] = byte(num)
|
||||
case float64:
|
||||
decoded[i] = byte(int(num))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported byte array item at index %d", i)
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported byte payload type")
|
||||
}
|
||||
}
|
||||
|
||||
func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "", "base64":
|
||||
return base64.StdEncoding.EncodeToString(data), nil
|
||||
case "hex":
|
||||
return hex.EncodeToString(data), nil
|
||||
case "utf8", "utf-8", "text":
|
||||
return string(data), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
|
||||
parsed := &runtimeBlockCipherOptions{
|
||||
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
|
||||
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
|
||||
InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")),
|
||||
OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")),
|
||||
Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")),
|
||||
}
|
||||
if parsed.Algorithm == "" {
|
||||
return nil, fmt.Errorf("algorithm is required")
|
||||
}
|
||||
if parsed.Mode == "" {
|
||||
return nil, fmt.Errorf("mode is required")
|
||||
}
|
||||
|
||||
key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid key: %w", err)
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return nil, fmt.Errorf("key is required")
|
||||
}
|
||||
parsed.Key = key
|
||||
|
||||
iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid iv: %w", err)
|
||||
}
|
||||
parsed.IV = iv
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) {
|
||||
switch options.Algorithm {
|
||||
case "blowfish":
|
||||
return blowfish.NewCipher(options.Key)
|
||||
case "aes":
|
||||
return aes.NewCipher(options.Key)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
func applyPKCS7Padding(data []byte, blockSize int) []byte {
|
||||
padding := blockSize - (len(data) % blockSize)
|
||||
if padding == 0 {
|
||||
padding = blockSize
|
||||
}
|
||||
out := make([]byte, len(data)+padding)
|
||||
copy(out, data)
|
||||
for i := len(data); i < len(out); i++ {
|
||||
out[i] = byte(padding)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
|
||||
if len(data) == 0 || len(data)%blockSize != 0 {
|
||||
return nil, fmt.Errorf("invalid padded payload length")
|
||||
}
|
||||
padding := int(data[len(data)-1])
|
||||
if padding <= 0 || padding > blockSize || padding > len(data) {
|
||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||
}
|
||||
for i := len(data) - padding; i < len(data); i++ {
|
||||
if int(data[i]) != padding {
|
||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||
}
|
||||
}
|
||||
return data[:len(data)-padding], nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "data and options are required",
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 1)
|
||||
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
if parsedOptions.Mode != "cbc" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||
})
|
||||
}
|
||||
|
||||
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
block, err := newRuntimeBlockCipher(parsedOptions)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(parsedOptions.IV) != block.BlockSize() {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
|
||||
})
|
||||
}
|
||||
|
||||
data := inputData
|
||||
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
||||
data = applyPKCS7Padding(data, block.BlockSize())
|
||||
}
|
||||
if len(data)%block.BlockSize() != 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
||||
})
|
||||
}
|
||||
|
||||
output := make([]byte, len(data))
|
||||
if decrypt {
|
||||
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||
if parsedOptions.Padding == "pkcs7" {
|
||||
output, err = removePKCS7Padding(output, block.BlockSize())
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"block_size": block.BlockSize(),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||
return r.transformBlockCipher(call, false)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||
return r.transformBlockCipher(call, true)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime {
|
||||
t.Helper()
|
||||
|
||||
ext := &loadedExtension{
|
||||
ID: "binary-test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "binary-test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: withFilePermission,
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
return vm
|
||||
}
|
||||
|
||||
func decodeJSONResult[T any](t *testing.T, value goja.Value) T {
|
||||
t.Helper()
|
||||
|
||||
var decoded T
|
||||
if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil {
|
||||
t.Fatalf("failed to decode JSON result: %v", err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_FileByteAPIs(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, true)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true});
|
||||
if (!first.success) throw new Error(first.error);
|
||||
|
||||
var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true});
|
||||
if (!second.success) throw new Error(second.error);
|
||||
|
||||
var all = file.readBytes("bytes.bin", {encoding: "hex"});
|
||||
if (!all.success) throw new Error(all.error);
|
||||
|
||||
var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"});
|
||||
if (!slice.success) throw new Error(slice.error);
|
||||
|
||||
var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"});
|
||||
if (!tail.success) throw new Error(tail.error);
|
||||
|
||||
return JSON.stringify({
|
||||
all: all.data,
|
||||
slice: slice.data,
|
||||
size: all.size,
|
||||
sliceBytes: slice.bytes_read,
|
||||
sliceEof: slice.eof,
|
||||
tailBytes: tail.bytes_read,
|
||||
tailEof: tail.eof
|
||||
});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("file byte APIs failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
All string `json:"all"`
|
||||
Slice string `json:"slice"`
|
||||
Size int64 `json:"size"`
|
||||
SliceBytes int `json:"sliceBytes"`
|
||||
SliceEof bool `json:"sliceEof"`
|
||||
TailBytes int `json:"tailBytes"`
|
||||
TailEof bool `json:"tailEof"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.All != "0001020304ff" {
|
||||
t.Fatalf("all = %q", decoded.All)
|
||||
}
|
||||
if decoded.Slice != "0203" {
|
||||
t.Fatalf("slice = %q", decoded.Slice)
|
||||
}
|
||||
if decoded.Size != 6 {
|
||||
t.Fatalf("size = %d", decoded.Size)
|
||||
}
|
||||
if decoded.SliceBytes != 2 {
|
||||
t.Fatalf("slice bytes = %d", decoded.SliceBytes)
|
||||
}
|
||||
if decoded.SliceEof {
|
||||
t.Fatal("slice should not be EOF")
|
||||
}
|
||||
if decoded.TailBytes != 0 || !decoded.TailEof {
|
||||
t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var options = {
|
||||
algorithm: "blowfish",
|
||||
mode: "cbc",
|
||||
key: "0123456789ABCDEFF0E1D2C3B4A59687",
|
||||
keyEncoding: "hex",
|
||||
iv: "0001020304050607",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "hex",
|
||||
outputEncoding: "hex",
|
||||
padding: "none"
|
||||
};
|
||||
var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options);
|
||||
if (!enc.success) throw new Error(enc.error);
|
||||
var dec = utils.decryptBlockCipher(enc.data, options);
|
||||
if (!dec.success) throw new Error(dec.error);
|
||||
return JSON.stringify({enc: enc.data, dec: dec.data});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("blowfish block cipher failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Enc string `json:"enc"`
|
||||
Dec string `json:"dec"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Dec != "00112233445566778899aabbccddeeff" {
|
||||
t.Fatalf("dec = %q", decoded.Dec)
|
||||
}
|
||||
if decoded.Enc == decoded.Dec {
|
||||
t.Fatal("expected ciphertext to differ from plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var options = {
|
||||
algorithm: "aes",
|
||||
mode: "cbc",
|
||||
key: "000102030405060708090a0b0c0d0e0f",
|
||||
keyEncoding: "hex",
|
||||
iv: "0f0e0d0c0b0a09080706050403020100",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "utf8",
|
||||
outputEncoding: "base64",
|
||||
padding: "pkcs7"
|
||||
};
|
||||
var enc = utils.encryptBlockCipher("hello generic cbc", options);
|
||||
if (!enc.success) throw new Error(enc.error);
|
||||
var dec = utils.decryptBlockCipher(enc.data, {
|
||||
algorithm: "aes",
|
||||
mode: "cbc",
|
||||
key: options.key,
|
||||
keyEncoding: options.keyEncoding,
|
||||
iv: options.iv,
|
||||
ivEncoding: options.ivEncoding,
|
||||
inputEncoding: "base64",
|
||||
outputEncoding: "utf8",
|
||||
padding: "pkcs7"
|
||||
});
|
||||
if (!dec.success) throw new Error(dec.error);
|
||||
return dec.data;
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("aes block cipher failed: %v", err)
|
||||
}
|
||||
|
||||
if result.String() != "hello generic cbc" {
|
||||
t.Fatalf("unexpected decrypted value: %q", result.String())
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
|
||||
delete(ffmpegCommands, commandID)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -107,7 +107,7 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -134,7 +134,7 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -71,7 +71,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
func (r *extensionRuntime) validatePath(path string) (string, error) {
|
||||
if !r.manifest.Permissions.File {
|
||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -271,7 +271,7 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -286,7 +286,7 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(err == nil)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -315,7 +315,7 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -346,7 +346,105 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 1)
|
||||
offset := runtimeOptionInt64(options, "offset", 0)
|
||||
length := runtimeOptionInt64(options, "length", -1)
|
||||
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||
if offset < 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "offset must be >= 0",
|
||||
})
|
||||
}
|
||||
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
size := info.Size()
|
||||
if offset > size {
|
||||
offset = size
|
||||
}
|
||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
var data []byte
|
||||
switch {
|
||||
case length == 0:
|
||||
data = []byte{}
|
||||
case length > 0:
|
||||
buf := make([]byte, int(length))
|
||||
n, readErr := file.Read(buf)
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read file: %v", readErr),
|
||||
})
|
||||
}
|
||||
data = buf[:n]
|
||||
default:
|
||||
data, err = io.ReadAll(file)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read file: %v", err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(data, encoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"bytes_read": len(data),
|
||||
"offset": offset,
|
||||
"size": size,
|
||||
"eof": offset+int64(len(data)) >= size,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -386,7 +484,108 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path and data are required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 2)
|
||||
appendMode := runtimeOptionBool(options, "append", false)
|
||||
truncate := runtimeOptionBool(options, "truncate", false)
|
||||
hasOffset := runtimeOptionHasKey(options, "offset")
|
||||
offset := runtimeOptionInt64(options, "offset", 0)
|
||||
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||
|
||||
if appendMode && hasOffset {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "append and offset cannot be used together",
|
||||
})
|
||||
}
|
||||
if offset < 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "offset must be >= 0",
|
||||
})
|
||||
}
|
||||
|
||||
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
flags := os.O_CREATE | os.O_WRONLY
|
||||
if appendMode {
|
||||
flags |= os.O_APPEND
|
||||
}
|
||||
if truncate {
|
||||
flags |= os.O_TRUNC
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(fullPath, flags, 0644)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if hasOffset && !appendMode {
|
||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
written, err := file.Write(data)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
info, statErr := file.Stat()
|
||||
size := int64(0)
|
||||
if statErr == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
"bytes_written": written,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -459,7 +658,7 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -507,7 +706,7 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -17,7 +17,7 @@ type HTTPResponse struct {
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
@@ -49,7 +49,7 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -118,12 +118,13 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -214,12 +215,13 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -322,24 +324,25 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -446,12 +449,13 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.mu.Lock()
|
||||
jar.cookies = make(map[string][]*http.Cookie)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(0.0)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
||||
return r.vm.ToValue(similarity)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(diff <= tolerance)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||
@@ -12,11 +12,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// These polyfills make porting browser/Node.js libraries easier
|
||||
// without compromising sandbox security.
|
||||
|
||||
// Returns a Promise-like object with json(), text() methods.
|
||||
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.createFetchError("URL is required")
|
||||
}
|
||||
@@ -38,7 +34,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
// Body - support string, object (auto-stringify), or nil
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
@@ -110,7 +105,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
responseObj.Set("status", resp.StatusCode)
|
||||
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
||||
responseObj.Set("headers", respHeaders)
|
||||
responseObj.Set("url", urlStr)
|
||||
responseObj.Set("url", resp.Request.URL.String())
|
||||
|
||||
bodyString := string(body)
|
||||
|
||||
@@ -138,7 +133,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return responseObj
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
func (r *extensionRuntime) createFetchError(message string) goja.Value {
|
||||
errorObj := r.vm.NewObject()
|
||||
errorObj.Set("ok", false)
|
||||
errorObj.Set("status", 0)
|
||||
@@ -153,7 +148,7 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
return errorObj
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -169,7 +164,7 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -177,7 +172,7 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
encoder := call.This
|
||||
encoder.Set("encoding", "utf-8")
|
||||
@@ -197,7 +192,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
})
|
||||
|
||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||
// Simplified implementation
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
||||
}
|
||||
@@ -258,7 +252,7 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||
urlObj := call.This
|
||||
|
||||
@@ -422,8 +416,7 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
// JSON is already built-in to Goja; this ensures a fallback exists.
|
||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
jsonScript := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
var JSON = {
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
storageFlushRetryDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) getStoragePath() string {
|
||||
func (r *extensionRuntime) getStoragePath() string {
|
||||
return filepath.Join(r.dataDir, "storage.json")
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
|
||||
return dst
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
||||
func (r *extensionRuntime) ensureStorageLoaded() error {
|
||||
r.storageMu.RLock()
|
||||
if r.storageLoaded {
|
||||
r.storageMu.RUnlock()
|
||||
@@ -74,7 +74,7 @@ func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
return cloneInterfaceMap(r.storageCache), nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
if r.storageClosed {
|
||||
return
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
||||
func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
||||
data, err := json.Marshal(storage)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -106,13 +106,13 @@ func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}
|
||||
return os.WriteFile(r.getStoragePath(), data, 0600)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
|
||||
func (r *extensionRuntime) flushStorageDirtyAsync() {
|
||||
if err := r.flushStorageDirty(); err != nil {
|
||||
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageDirty() error {
|
||||
func (r *extensionRuntime) flushStorageDirty() error {
|
||||
r.storageMu.Lock()
|
||||
if r.storageClosed {
|
||||
r.storageTimer = nil
|
||||
@@ -140,7 +140,7 @@ func (r *ExtensionRuntime) flushStorageDirty() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageNow() error {
|
||||
func (r *extensionRuntime) flushStorageNow() error {
|
||||
r.storageMu.Lock()
|
||||
if r.storageTimer != nil {
|
||||
r.storageTimer.Stop()
|
||||
@@ -157,7 +157,7 @@ func (r *ExtensionRuntime) flushStorageNow() error {
|
||||
return r.persistStorageSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) closeStorageFlusher() {
|
||||
func (r *extensionRuntime) closeStorageFlusher() {
|
||||
r.storageMu.Lock()
|
||||
r.storageClosed = true
|
||||
r.storageDirty = false
|
||||
@@ -168,7 +168,7 @@ func (r *ExtensionRuntime) closeStorageFlusher() {
|
||||
r.storageMu.Unlock()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -254,15 +254,15 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||
func (r *extensionRuntime) getCredentialsPath() string {
|
||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getSaltPath() string {
|
||||
func (r *extensionRuntime) getSaltPath() string {
|
||||
return filepath.Join(r.dataDir, ".cred_salt")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
saltPath := r.getSaltPath()
|
||||
|
||||
salt, err := os.ReadFile(saltPath)
|
||||
@@ -282,7 +282,7 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
salt, err := r.getOrCreateSalt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -293,7 +293,7 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
||||
func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
||||
r.credentialsMu.RLock()
|
||||
if r.credentialsLoaded {
|
||||
r.credentialsMu.RUnlock()
|
||||
@@ -340,7 +340,7 @@ func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -350,7 +350,7 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
return cloneInterfaceMap(r.credentialsCache), nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -377,7 +377,7 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -414,7 +414,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
@@ -439,7 +439,7 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -464,7 +464,7 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) {
|
||||
func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value interface{}) {
|
||||
t.Helper()
|
||||
result := runtime.storageSet(goja.FunctionCall{
|
||||
Arguments: []goja.Value{
|
||||
@@ -39,7 +39,7 @@ func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
|
||||
}
|
||||
|
||||
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "storage-test",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "storage-test",
|
||||
@@ -47,7 +47,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = 25 * time.Millisecond
|
||||
runtime.RegisterAPIs(goja.New())
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "unload-storage-test",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "unload-storage-test",
|
||||
@@ -95,13 +95,13 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||
VM: goja.New(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = time.Hour
|
||||
runtime.RegisterAPIs(ext.VM)
|
||||
ext.runtime = runtime
|
||||
|
||||
manager := &ExtensionManager{
|
||||
extensions: map[string]*LoadedExtension{
|
||||
manager := &extensionManager{
|
||||
extensions: map[string]*loadedExtension{
|
||||
ext.ID: ext,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(jsArray)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(string(data))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -187,7 +187,7 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -222,7 +222,7 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||
length := 32
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||
@@ -245,35 +245,35 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(getRandomUserAgent())
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
parts := make([]string, len(args))
|
||||
for i, arg := range args {
|
||||
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||
@@ -281,7 +281,7 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -289,7 +289,7 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(sanitizeFilename(input))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
gobackendObj := vm.Get("gobackend")
|
||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||
gobackendObj = vm.NewObject()
|
||||
|
||||
@@ -145,7 +145,7 @@ func initExtensionStore(cacheDir string) *extensionStore {
|
||||
|
||||
if globalExtensionStore == nil {
|
||||
globalExtensionStore = &extensionStore{
|
||||
registryURL: "", // No default - user must provide a registry URL
|
||||
registryURL: "",
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
@@ -154,8 +154,6 @@ func initExtensionStore(cacheDir string) *extensionStore {
|
||||
return globalExtensionStore
|
||||
}
|
||||
|
||||
// SetRegistryURL updates the registry URL and clears the in-memory cache
|
||||
// so the next fetch will use the new URL. Disk cache is also cleared.
|
||||
func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
@@ -168,7 +166,6 @@ func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||
s.cache = nil
|
||||
s.cacheTime = time.Time{}
|
||||
|
||||
// Clear disk cache since it's from a different registry
|
||||
if s.cacheDir != "" {
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.Remove(cachePath)
|
||||
@@ -177,7 +174,6 @@ func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
||||
}
|
||||
|
||||
// GetRegistryURL returns the currently configured registry URL.
|
||||
func (s *extensionStore) getRegistryURL() string {
|
||||
s.cacheMu.RLock()
|
||||
defer s.cacheMu.RUnlock()
|
||||
@@ -299,7 +295,7 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
installed := make(map[string]string) // id -> version
|
||||
|
||||
if manager != nil {
|
||||
@@ -378,32 +374,22 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL.
|
||||
//
|
||||
// Accepted formats:
|
||||
// - https://raw.githubusercontent.com/owner/repo/<branch>/registry.json → returned as-is
|
||||
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
|
||||
// the GitHub API to discover the default branch, then converted to the raw URL
|
||||
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
|
||||
func resolveRegistryURL(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", fmt.Errorf("registry URL is empty")
|
||||
}
|
||||
|
||||
// Already a fully-qualified raw URL – keep it.
|
||||
if strings.Contains(input, "raw.githubusercontent.com") {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
const ghPrefix = "https://github.com/"
|
||||
if !strings.HasPrefix(input, ghPrefix) {
|
||||
// Also accept http:// and upgrade silently.
|
||||
const ghPrefixHTTP = "http://github.com/"
|
||||
if strings.HasPrefix(input, ghPrefixHTTP) {
|
||||
input = "https://github.com/" + input[len(ghPrefixHTTP):]
|
||||
} else {
|
||||
// Not a GitHub URL – return as-is.
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
@@ -423,8 +409,6 @@ func resolveRegistryURL(input string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's
|
||||
// default branch. Falls back to "main" on any error.
|
||||
func resolveGitHubDefaultBranch(owner, repo string) string {
|
||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
|
||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestIsDomainAllowed(t *testing.T) {
|
||||
|
||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
// Create a mock extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -110,7 +110,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
|
||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||
@@ -132,7 +132,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -143,7 +143,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
DataDir: tempDir,
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
|
||||
validPath, err := runtime.validatePath("test.txt")
|
||||
if err != nil {
|
||||
@@ -177,7 +177,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
t.Error("Expected absolute path to be blocked")
|
||||
}
|
||||
|
||||
extNoFile := &LoadedExtension{
|
||||
extNoFile := &loadedExtension{
|
||||
ID: "test-ext-no-file",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext-no-file",
|
||||
@@ -187,7 +187,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
},
|
||||
DataDir: tempDir,
|
||||
}
|
||||
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
||||
runtimeNoFile := newExtensionRuntime(extNoFile)
|
||||
_, err = runtimeNoFile.validatePath("test.txt")
|
||||
if err == nil {
|
||||
t.Error("Expected file access to be denied without file permission")
|
||||
@@ -195,7 +195,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -203,7 +203,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
|
||||
@@ -243,7 +243,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
|
||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
// Create extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -254,7 +254,7 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
|
||||
privateIPs := []string{
|
||||
"http://localhost/admin",
|
||||
|
||||
@@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string {
|
||||
}
|
||||
|
||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
if vm == nil {
|
||||
return nil, fmt.Errorf("extension runtime unavailable")
|
||||
}
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultJSTimeout
|
||||
}
|
||||
@@ -49,7 +53,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
IsTimeout: true,
|
||||
}}
|
||||
} else {
|
||||
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||
GoLog("[extensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +73,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
|
||||
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
|
||||
// is still executing JS (e.g. blocked on an HTTP call), the next
|
||||
// caller will access the VM concurrently and crash with a nil
|
||||
// pointer dereference.
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
if res.err != nil {
|
||||
@@ -78,7 +87,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
Message: "execution timeout exceeded",
|
||||
IsTimeout: true,
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
case <-time.After(60 * time.Second):
|
||||
// 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")
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
@@ -92,8 +104,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
result, err := RunWithTimeout(vm, script, timeout)
|
||||
|
||||
// Clear any interrupt state so VM can be reused
|
||||
vm.ClearInterrupt()
|
||||
if vm != nil {
|
||||
vm.ClearInterrupt()
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
+15
-15
@@ -2,28 +2,28 @@ module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.25.7
|
||||
toolchain go1.25.8
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/text v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
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/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
)
|
||||
|
||||
+30
-28
@@ -1,49 +1,51 @@
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
||||
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
|
||||
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
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/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=
|
||||
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=
|
||||
|
||||
@@ -66,9 +66,6 @@ var sharedTransport = &http.Transport{
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
|
||||
// Isolated from download traffic so that download failures cannot poison
|
||||
// the connection pool used by metadata enrichment.
|
||||
var metadataTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -104,8 +101,6 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
|
||||
// Use this for API calls that should not be affected by download traffic.
|
||||
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: newCompatibilityTransport(metadataTransport),
|
||||
@@ -229,7 +224,6 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
|
||||
return reqCopy, nil
|
||||
}
|
||||
|
||||
// Also checks for ISP blocking on errors
|
||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
resp, err := client.Do(req)
|
||||
@@ -239,7 +233,6 @@ func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Respo
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// RetryConfig holds configuration for retry logic
|
||||
type RetryConfig struct {
|
||||
MaxRetries int
|
||||
InitialDelay time.Duration
|
||||
|
||||
@@ -6,17 +6,10 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
|
||||
// Fall back to standard HTTP client
|
||||
|
||||
// GetCloudflareBypassClient returns the standard HTTP client on iOS
|
||||
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
|
||||
func GetCloudflareBypassClient() *http.Client {
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
// DoRequestWithCloudflareBypass on iOS just uses the standard client
|
||||
// uTLS Chrome fingerprint bypass is not available on iOS
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
resp, err := sharedClient.Do(req)
|
||||
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// uTLS transport that mimics Chrome's TLS fingerprint to bypass Cloudflare
|
||||
// Uses HTTP/2 for optimal performance as uTLS works best with HTTP/2
|
||||
type utlsTransport struct {
|
||||
dialer *net.Dialer
|
||||
mu sync.Mutex
|
||||
@@ -98,15 +96,10 @@ var cloudflareBypassClient = &http.Client{
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
|
||||
// GetCloudflareBypassClient returns an HTTP client that mimics Chrome's TLS fingerprint
|
||||
// Use this when requests are blocked by Cloudflare (common when using VPN)
|
||||
func GetCloudflareBypassClient() *http.Client {
|
||||
return cloudflareBypassClient
|
||||
}
|
||||
|
||||
// DoRequestWithCloudflareBypass attempts request with standard client first,
|
||||
// then retries with uTLS Chrome fingerprint if Cloudflare blocks it.
|
||||
// This is useful when using VPN as Cloudflare detects Go's default TLS fingerprint.
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
@@ -142,7 +135,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Not Cloudflare, return original response (recreate body)
|
||||
return &http.Response{
|
||||
Status: resp.Status,
|
||||
StatusCode: resp.StatusCode,
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// IDHSClient is a client for I Don't Have Spotify API
|
||||
// Used as fallback when SongLink fails or is rate limited
|
||||
type IDHSClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
@@ -55,7 +53,6 @@ func NewIDHSClient() *IDHSClient {
|
||||
return globalIDHSClient
|
||||
}
|
||||
|
||||
// Search converts a music link to links on other platforms
|
||||
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
|
||||
idhsRateLimiter.WaitForSlot()
|
||||
|
||||
@@ -109,7 +106,6 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
|
||||
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
|
||||
|
||||
@@ -24,13 +24,18 @@ type LibraryScanResult struct {
|
||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
TrackNumber int `json:"trackNumber,omitempty"`
|
||||
TotalTracks int `json:"totalTracks,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
TotalDiscs int `json:"totalDiscs,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
BitDepth int `json:"bitDepth,omitempty"`
|
||||
SampleRate int `json:"sampleRate,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
|
||||
}
|
||||
@@ -66,6 +71,9 @@ var supportedAudioFormats = map[string]bool{
|
||||
".mp3": true,
|
||||
".opus": true,
|
||||
".ogg": true,
|
||||
".ape": true,
|
||||
".wv": true,
|
||||
".mpc": true,
|
||||
".cue": true,
|
||||
}
|
||||
|
||||
@@ -170,11 +178,9 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
errorCount := 0
|
||||
|
||||
// Track audio files referenced by .cue sheets to avoid duplicates
|
||||
cueReferencedAudioFiles := make(map[string]bool)
|
||||
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||
|
||||
// First pass: scan .cue files to collect referenced audio paths
|
||||
for _, fileInfo := range audioFileInfos {
|
||||
filePath := fileInfo.path
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
@@ -209,7 +215,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
// Handle .cue files: produce multiple track results
|
||||
if ext == ".cue" {
|
||||
var cueResults []LibraryScanResult
|
||||
cueInfo, ok := parsedCueFiles[filePath]
|
||||
@@ -318,6 +323,8 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
||||
return scanMP3File(filePath, result, displayNameHint)
|
||||
case ".opus", ".ogg":
|
||||
return scanOggFile(filePath, result, displayNameHint)
|
||||
case ".ape", ".wv", ".mpc":
|
||||
return scanAPEFile(filePath, result, displayNameHint)
|
||||
default:
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
@@ -363,9 +370,14 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Genre = metadata.Genre
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err == nil {
|
||||
@@ -395,12 +407,17 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.ReleaseDate = metadata.Date
|
||||
if result.ReleaseDate == "" {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Genre = metadata.Genre
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
}
|
||||
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
@@ -425,7 +442,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
@@ -433,6 +452,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.ISRC = metadata.ISRC
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
quality, err := GetMP3Quality(filePath)
|
||||
if err == nil {
|
||||
@@ -462,9 +484,14 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
quality, err := GetOggQuality(filePath)
|
||||
if err == nil {
|
||||
@@ -481,6 +508,42 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
tag, err := ReadAPETags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] APE tag read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
metadata := APETagToAudioMetadata(tag)
|
||||
if metadata == nil {
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
} else {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
result.MetadataFromFilename = true
|
||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||
@@ -827,9 +890,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||
// Only files that are new or have changed modification time will be scanned
|
||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||
existingFiles := make(map[string]int64)
|
||||
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||
|
||||
@@ -51,7 +51,7 @@ func GetLogBuffer() *LogBuffer {
|
||||
globalLogBuffer = &LogBuffer{
|
||||
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||
maxSize: defaultLogBufferSize,
|
||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||
loggingEnabled: false,
|
||||
}
|
||||
})
|
||||
return globalLogBuffer
|
||||
@@ -145,13 +145,10 @@ func LogError(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
|
||||
// It parses the tag from the format string if it starts with [Tag]
|
||||
func GoLog(format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
message = strings.TrimSuffix(message, "\n")
|
||||
|
||||
// Extract tag from message if present (e.g., "[Tidal] message")
|
||||
tag := "Go"
|
||||
level := "INFO"
|
||||
|
||||
@@ -163,7 +160,6 @@ func GoLog(format string, args ...interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine level from message content
|
||||
msgLower := strings.ToLower(message)
|
||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
|
||||
level = "ERROR"
|
||||
|
||||
+14
-251
@@ -3,7 +3,6 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -21,9 +20,7 @@ const (
|
||||
durationToleranceSec = 10.0
|
||||
)
|
||||
|
||||
// Lyrics provider names (used in settings and cascade ordering)
|
||||
const (
|
||||
LyricsProviderSpotifyAPI = "spotify_api"
|
||||
LyricsProviderLRCLIB = "lrclib"
|
||||
LyricsProviderNetease = "netease"
|
||||
LyricsProviderMusixmatch = "musixmatch"
|
||||
@@ -31,11 +28,8 @@ const (
|
||||
LyricsProviderQQMusic = "qqmusic"
|
||||
)
|
||||
|
||||
// DefaultLyricsProviders is the default cascade order for lyrics fetching.
|
||||
// LRCLIB first (no proxy dependency), then the others.
|
||||
var DefaultLyricsProviders = []string{
|
||||
LyricsProviderLRCLIB,
|
||||
LyricsProviderSpotifyAPI,
|
||||
LyricsProviderMusixmatch,
|
||||
LyricsProviderNetease,
|
||||
LyricsProviderAppleMusic,
|
||||
@@ -47,12 +41,6 @@ var (
|
||||
lyricsProviders []string // ordered list of enabled providers
|
||||
)
|
||||
|
||||
var (
|
||||
spotifyLyricsRateLimitMu sync.RWMutex
|
||||
spotifyLyricsRateLimitedTil time.Time
|
||||
)
|
||||
|
||||
// LyricsFetchOptions controls optional provider-specific enhancements.
|
||||
type LyricsFetchOptions struct {
|
||||
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||
@@ -72,8 +60,6 @@ var (
|
||||
lyricsFetchOptions = defaultLyricsFetchOptions
|
||||
)
|
||||
|
||||
// SetLyricsProviderOrder sets the ordered list of lyrics providers to try.
|
||||
// Providers not in the list are disabled. An empty list resets to defaults.
|
||||
func SetLyricsProviderOrder(providers []string) {
|
||||
lyricsProvidersMu.Lock()
|
||||
defer lyricsProvidersMu.Unlock()
|
||||
@@ -84,7 +70,6 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
}
|
||||
|
||||
validNames := map[string]bool{
|
||||
LyricsProviderSpotifyAPI: true,
|
||||
LyricsProviderLRCLIB: true,
|
||||
LyricsProviderNetease: true,
|
||||
LyricsProviderMusixmatch: true,
|
||||
@@ -119,7 +104,6 @@ func GetLyricsProviderOrder() []string {
|
||||
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||
@@ -249,18 +233,6 @@ type LRCLibResponse struct {
|
||||
SyncedLyrics string `json:"syncedLyrics"`
|
||||
}
|
||||
|
||||
type SpotifyLyricsLine struct {
|
||||
TimeTag string `json:"timeTag"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
type SpotifyLyricsAPIResponse struct {
|
||||
Error bool `json:"error"`
|
||||
Message string `json:"message"`
|
||||
SyncType string `json:"syncType"`
|
||||
Lines []SpotifyLyricsLine `json:"lines"`
|
||||
}
|
||||
|
||||
type LyricsLine struct {
|
||||
StartTimeMs int64 `json:"startTimeMs"`
|
||||
Words string `json:"words"`
|
||||
@@ -368,214 +340,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
||||
return c.parseLRCLibResponse(&results[0]), nil
|
||||
}
|
||||
|
||||
func parseSpotifyLyricsTimeTagToMs(tag string) int64 {
|
||||
raw := strings.TrimSpace(tag)
|
||||
raw = strings.TrimPrefix(raw, "[")
|
||||
raw = strings.TrimSuffix(raw, "]")
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return ms
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^(\d{1,2}):(\d{2})\.(\d{1,3})$`)
|
||||
matches := re.FindStringSubmatch(raw)
|
||||
if len(matches) != 4 {
|
||||
return 0
|
||||
}
|
||||
|
||||
minutes, _ := strconv.ParseInt(matches[1], 10, 64)
|
||||
seconds, _ := strconv.ParseInt(matches[2], 10, 64)
|
||||
fraction := matches[3]
|
||||
fractionInt, _ := strconv.ParseInt(fraction, 10, 64)
|
||||
if len(fraction) == 2 {
|
||||
fractionInt *= 10
|
||||
} else if len(fraction) == 1 {
|
||||
fractionInt *= 100
|
||||
}
|
||||
return minutes*60*1000 + seconds*1000 + fractionInt
|
||||
}
|
||||
|
||||
func getSpotifyLyricsRateLimitUntil() time.Time {
|
||||
spotifyLyricsRateLimitMu.RLock()
|
||||
defer spotifyLyricsRateLimitMu.RUnlock()
|
||||
return spotifyLyricsRateLimitedTil
|
||||
}
|
||||
|
||||
func setSpotifyLyricsRateLimitUntil(until time.Time) {
|
||||
spotifyLyricsRateLimitMu.Lock()
|
||||
spotifyLyricsRateLimitedTil = until
|
||||
spotifyLyricsRateLimitMu.Unlock()
|
||||
}
|
||||
|
||||
func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
|
||||
raw := strings.TrimSpace(retryAfter)
|
||||
if raw == "" {
|
||||
return now.Add(10 * time.Minute)
|
||||
}
|
||||
|
||||
if sec, err := strconv.Atoi(raw); err == nil && sec > 0 {
|
||||
return now.Add(time.Duration(sec) * time.Second)
|
||||
}
|
||||
|
||||
if when, err := http.ParseTime(raw); err == nil && when.After(now) {
|
||||
return when
|
||||
}
|
||||
|
||||
return now.Add(10 * time.Minute)
|
||||
}
|
||||
|
||||
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
if syncType == "" {
|
||||
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
|
||||
syncType = "LINE_SYNCED"
|
||||
} else {
|
||||
syncType = "UNSYNCED"
|
||||
}
|
||||
}
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: syncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: plainLyrics,
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func plainLyricsFromTimedLines(lines []LyricsLine) string {
|
||||
parts := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, words)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
trimmed := strings.TrimSpace(lrcPayload)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(trimmed)
|
||||
if len(lines) > 0 {
|
||||
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(trimmed)
|
||||
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
lines := make([]LyricsLine, 0, len(apiResp.Lines))
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < len(lines)-1; i++ {
|
||||
nextStart := lines[i+1].StartTimeMs
|
||||
if nextStart > lines[i].StartTimeMs {
|
||||
lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
last := len(lines) - 1
|
||||
if lines[last].EndTimeMs == 0 {
|
||||
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
|
||||
now := time.Now()
|
||||
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
|
||||
waitFor := int(math.Ceil(limitedUntil.Sub(now).Seconds()))
|
||||
return nil, fmt.Errorf(
|
||||
"Spotify Lyrics API cooldown active (%ds remaining after previous 429)",
|
||||
waitFor,
|
||||
)
|
||||
}
|
||||
|
||||
spotifyID = strings.TrimSpace(spotifyID)
|
||||
if spotifyID == "" {
|
||||
return nil, fmt.Errorf("spotify ID is empty")
|
||||
}
|
||||
if parsed, err := parseSpotifyURI(spotifyID); err == nil && parsed.Type == "track" && parsed.ID != "" {
|
||||
spotifyID = parsed.ID
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
|
||||
setSpotifyLyricsRateLimitUntil(retryUntil)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &payload); err == nil {
|
||||
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||
}
|
||||
if msg, ok := payload["error"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return parseSpotifyLyricsResponseBody(bodyBytes)
|
||||
}
|
||||
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
var bestSynced *LRCLibResponse
|
||||
var bestPlain *LRCLibResponse
|
||||
@@ -600,6 +364,18 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
|
||||
return bestPlain
|
||||
}
|
||||
|
||||
func plainLyricsFromTimedLines(lines []LyricsLine) string {
|
||||
parts := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, words)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||
diff := math.Abs(lrcDuration - targetDuration)
|
||||
return diff <= durationToleranceSec
|
||||
@@ -609,8 +385,8 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
fetchOptions := GetLyricsFetchOptions()
|
||||
|
||||
extManager := GetExtensionManager()
|
||||
var extensionProviders []*ExtensionProviderWrapper
|
||||
extManager := getExtensionManager()
|
||||
var extensionProviders []*extensionProviderWrapper
|
||||
if extManager != nil {
|
||||
extensionProviders = extManager.GetLyricsProviders()
|
||||
}
|
||||
@@ -669,9 +445,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
var err error
|
||||
|
||||
switch providerName {
|
||||
case LyricsProviderSpotifyAPI:
|
||||
lyrics, err = c.FetchLyricsFromSpotifyAPI(spotifyID)
|
||||
|
||||
case LyricsProviderLRCLIB:
|
||||
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
|
||||
|
||||
@@ -753,19 +526,16 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
return nil, fmt.Errorf("lyrics not found from any source")
|
||||
}
|
||||
|
||||
// tryLRCLIB attempts all LRCLIB search strategies (exact match, simplified, search).
|
||||
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
// 1. Exact match with primary artist
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// 2. Exact match with full artist name
|
||||
if primaryArtist != artistName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
@@ -774,7 +544,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Simplified track name
|
||||
if simplifiedTrack != trackName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
@@ -783,7 +552,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Search by query
|
||||
query := primaryArtist + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
@@ -791,7 +559,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// 5. Search with simplified track name
|
||||
if simplifiedTrack != trackName {
|
||||
query = primaryArtist + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
@@ -909,8 +676,6 @@ func lyricsHasUsableText(lyrics *LyricsResponse) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// detectLyricsErrorPayload extracts human-readable error messages from
|
||||
// JSON payloads returned by lyrics proxies when no lyric is available.
|
||||
func detectLyricsErrorPayload(raw string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
|
||||
@@ -1035,8 +800,6 @@ func simplifyTrackName(name string) string {
|
||||
return result
|
||||
}
|
||||
|
||||
// Add a loose fallback form for provider queries where punctuation
|
||||
// and separators differ (e.g. "/" vs "_" vs spaces).
|
||||
if loose := normalizeLooseTitle(result); loose != "" {
|
||||
return loose
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppleMusicClient fetches lyrics from Apple Music.
|
||||
// Uses Paxsenix endpoints for search and lyrics.
|
||||
type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
@@ -25,7 +23,6 @@ type appleMusicSearchResult struct {
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
||||
type paxResponse struct {
|
||||
Type string `json:"type"` // "Syllable" or "Line"
|
||||
Content []paxLyrics `json:"content"` // List of lyric lines
|
||||
@@ -103,7 +100,6 @@ func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackNam
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
@@ -144,7 +140,6 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
|
||||
return strings.TrimSpace(best.ID), nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
|
||||
|
||||
@@ -252,7 +247,6 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// FetchLyrics searches Apple Music and returns parsed LyricsResponse.
|
||||
func (c *AppleMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
@@ -272,10 +266,8 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to parse as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
@@ -289,7 +281,6 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fall back to plain text if no timestamps found
|
||||
resultLines := plainTextLyricsLines(lrcText)
|
||||
|
||||
if len(resultLines) > 0 {
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// MusixmatchClient fetches lyrics from Musixmatch via a proxy server.
|
||||
// The proxy handles Musixmatch authentication internally.
|
||||
type MusixmatchClient struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
@@ -114,7 +112,6 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura
|
||||
return "", fmt.Errorf("failed to decode musixmatch response")
|
||||
}
|
||||
|
||||
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if lang == "" {
|
||||
@@ -151,7 +148,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, d
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||
}
|
||||
|
||||
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
|
||||
type NeteaseClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
@@ -51,7 +50,6 @@ func NewNeteaseClient() *NeteaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Netease and returns the song ID.
|
||||
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
@@ -96,7 +94,6 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
return searchResp.Result.Songs[0].ID, nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
|
||||
params := url.Values{}
|
||||
@@ -146,7 +143,6 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
|
||||
return lyric, nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches for a track and returns parsed LyricsResponse.
|
||||
func (c *NeteaseClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
@@ -166,7 +162,6 @@ func (c *NeteaseClient) FetchLyrics(
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) == 0 {
|
||||
// May be plain text lyrics without timestamps
|
||||
plainLines := strings.Split(lrcText, "\n")
|
||||
for _, line := range plainLines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// QQMusicClient fetches lyrics from QQ Music.
|
||||
// Uses Paxsenix metadata lookup for lyrics.
|
||||
type QQMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
@@ -34,7 +32,6 @@ func NewQQMusicClient() *QQMusicClient {
|
||||
}
|
||||
}
|
||||
|
||||
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
|
||||
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
|
||||
payload := qqLyricsMetadataRequest{
|
||||
Artist: []string{artistName},
|
||||
@@ -93,7 +90,6 @@ func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (st
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
|
||||
+375
-116
@@ -110,6 +110,7 @@ type Metadata struct {
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
ISRC string
|
||||
Description string
|
||||
Lyrics string
|
||||
@@ -118,6 +119,12 @@ type Metadata struct {
|
||||
Copyright string
|
||||
Composer string
|
||||
Comment string
|
||||
|
||||
// ReplayGain fields (stored as Vorbis Comments in FLAC)
|
||||
ReplayGainTrackGain string // e.g. "-6.50 dB"
|
||||
ReplayGainTrackPeak string // e.g. "0.988831"
|
||||
ReplayGainAlbumGain string // e.g. "-7.20 dB"
|
||||
ReplayGainAlbumPeak string // e.g. "1.000000"
|
||||
}
|
||||
|
||||
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
@@ -144,61 +151,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
setComment(cmt, "TITLE", metadata.Title)
|
||||
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
setArtistComments(
|
||||
cmt,
|
||||
"ALBUMARTIST",
|
||||
metadata.AlbumArtist,
|
||||
metadata.ArtistTagMode,
|
||||
)
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
if metadata.TotalTracks > 0 {
|
||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||
} else {
|
||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||
}
|
||||
}
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
}
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
setComment(cmt, "ISRC", metadata.ISRC)
|
||||
}
|
||||
|
||||
if metadata.Description != "" {
|
||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||
}
|
||||
|
||||
if metadata.Lyrics != "" {
|
||||
setComment(cmt, "LYRICS", metadata.Lyrics)
|
||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||
}
|
||||
|
||||
if metadata.Genre != "" {
|
||||
setComment(cmt, "GENRE", metadata.Genre)
|
||||
}
|
||||
|
||||
if metadata.Label != "" {
|
||||
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||
}
|
||||
|
||||
if metadata.Copyright != "" {
|
||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||
}
|
||||
|
||||
if metadata.Composer != "" {
|
||||
setComment(cmt, "COMPOSER", metadata.Composer)
|
||||
}
|
||||
|
||||
if metadata.Comment != "" {
|
||||
setComment(cmt, "COMMENT", metadata.Comment)
|
||||
}
|
||||
writeVorbisMetadata(cmt, metadata)
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
@@ -258,61 +211,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
setComment(cmt, "TITLE", metadata.Title)
|
||||
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
setArtistComments(
|
||||
cmt,
|
||||
"ALBUMARTIST",
|
||||
metadata.AlbumArtist,
|
||||
metadata.ArtistTagMode,
|
||||
)
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
if metadata.TotalTracks > 0 {
|
||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||
} else {
|
||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||
}
|
||||
}
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
}
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
setComment(cmt, "ISRC", metadata.ISRC)
|
||||
}
|
||||
|
||||
if metadata.Description != "" {
|
||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||
}
|
||||
|
||||
if metadata.Lyrics != "" {
|
||||
setComment(cmt, "LYRICS", metadata.Lyrics)
|
||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||
}
|
||||
|
||||
if metadata.Genre != "" {
|
||||
setComment(cmt, "GENRE", metadata.Genre)
|
||||
}
|
||||
|
||||
if metadata.Label != "" {
|
||||
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||
}
|
||||
|
||||
if metadata.Copyright != "" {
|
||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||
}
|
||||
|
||||
if metadata.Composer != "" {
|
||||
setComment(cmt, "COMPOSER", metadata.Composer)
|
||||
}
|
||||
|
||||
if metadata.Comment != "" {
|
||||
setComment(cmt, "COMMENT", metadata.Comment)
|
||||
}
|
||||
writeVorbisMetadata(cmt, metadata)
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
@@ -358,6 +257,12 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
metadata.Artist = getJoinedComment(cmt, "ARTIST")
|
||||
metadata.Album = getComment(cmt, "ALBUM")
|
||||
metadata.AlbumArtist = getJoinedComment(cmt, "ALBUMARTIST")
|
||||
if metadata.AlbumArtist == "" {
|
||||
metadata.AlbumArtist = getJoinedComment(cmt, "ALBUM ARTIST")
|
||||
}
|
||||
if metadata.AlbumArtist == "" {
|
||||
metadata.AlbumArtist = getJoinedComment(cmt, "ALBUM_ARTIST")
|
||||
}
|
||||
metadata.Date = getComment(cmt, "DATE")
|
||||
metadata.ISRC = getComment(cmt, "ISRC")
|
||||
metadata.Description = getComment(cmt, "DESCRIPTION")
|
||||
@@ -369,23 +274,23 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
|
||||
trackNum := getComment(cmt, "TRACKNUMBER")
|
||||
if trackNum != "" {
|
||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
|
||||
}
|
||||
if metadata.TrackNumber == 0 {
|
||||
trackNum = getComment(cmt, "TRACK")
|
||||
if trackNum != "" {
|
||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
|
||||
}
|
||||
}
|
||||
|
||||
discNum := getComment(cmt, "DISCNUMBER")
|
||||
if discNum != "" {
|
||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
|
||||
}
|
||||
if metadata.DiscNumber == 0 {
|
||||
discNum = getComment(cmt, "DISC")
|
||||
if discNum != "" {
|
||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,10 +300,21 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
|
||||
metadata.Genre = getComment(cmt, "GENRE")
|
||||
metadata.Label = getComment(cmt, "ORGANIZATION")
|
||||
if metadata.Label == "" {
|
||||
metadata.Label = getComment(cmt, "LABEL")
|
||||
}
|
||||
if metadata.Label == "" {
|
||||
metadata.Label = getComment(cmt, "PUBLISHER")
|
||||
}
|
||||
metadata.Copyright = getComment(cmt, "COPYRIGHT")
|
||||
metadata.Composer = getComment(cmt, "COMPOSER")
|
||||
metadata.Comment = getComment(cmt, "COMMENT")
|
||||
|
||||
metadata.ReplayGainTrackGain = getComment(cmt, "REPLAYGAIN_TRACK_GAIN")
|
||||
metadata.ReplayGainTrackPeak = getComment(cmt, "REPLAYGAIN_TRACK_PEAK")
|
||||
metadata.ReplayGainAlbumGain = getComment(cmt, "REPLAYGAIN_ALBUM_GAIN")
|
||||
metadata.ReplayGainAlbumPeak = getComment(cmt, "REPLAYGAIN_ALBUM_PEAK")
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -406,6 +322,222 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// EditFlacFields opens a FLAC file and updates only the Vorbis Comment keys
|
||||
// that are explicitly present in the fields map. Keys present with a non-empty
|
||||
// value are set; keys present with an empty value are removed (cleared). Keys
|
||||
// absent from the map are left untouched. This is the correct function for
|
||||
// partial edits (e.g. writing only ReplayGain tags) and full editor saves alike.
|
||||
func EditFlacFields(filePath string, fields map[string]string) error {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
var cmtIdx int = -1
|
||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||
|
||||
for idx, meta := range f.Meta {
|
||||
if meta.Type == flac.VorbisComment {
|
||||
cmtIdx = idx
|
||||
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if cmt == nil {
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
artistMode := fields["artist_tag_mode"]
|
||||
|
||||
// Mapping from fields-map key → one or more Vorbis Comment keys.
|
||||
// Each entry is handled with set-or-clear semantics.
|
||||
simpleKeys := map[string]string{
|
||||
"title": "TITLE",
|
||||
"album": "ALBUM",
|
||||
"date": "DATE",
|
||||
"isrc": "ISRC",
|
||||
"genre": "GENRE",
|
||||
"label": "ORGANIZATION",
|
||||
"copyright": "COPYRIGHT",
|
||||
"composer": "COMPOSER",
|
||||
"comment": "COMMENT",
|
||||
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
|
||||
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
|
||||
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
|
||||
"replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK",
|
||||
}
|
||||
|
||||
for fieldKey, vorbisKey := range simpleKeys {
|
||||
if v, ok := fields[fieldKey]; ok {
|
||||
setOrClearComment(cmt, vorbisKey, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove known aliases for fields that were just written/cleared, so that
|
||||
// tags from other taggers (e.g. LABEL, PUBLISHER, ALBUM ARTIST) don't
|
||||
// conflict with the canonical keys we use.
|
||||
aliasCleanup := map[string][]string{
|
||||
"label": {"LABEL", "PUBLISHER"}, // canonical: ORGANIZATION
|
||||
"date": {"YEAR"}, // canonical: DATE
|
||||
"genre": {}, // no common aliases
|
||||
"copyright": {},
|
||||
}
|
||||
for fieldKey, aliases := range aliasCleanup {
|
||||
if _, ok := fields[fieldKey]; ok {
|
||||
for _, alias := range aliases {
|
||||
removeCommentKey(cmt, alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Artist fields: use split-artist logic when mode is set.
|
||||
if v, ok := fields["artist"]; ok {
|
||||
setOrClearArtistComments(cmt, "ARTIST", v, artistMode)
|
||||
}
|
||||
if v, ok := fields["album_artist"]; ok {
|
||||
setOrClearArtistComments(cmt, "ALBUMARTIST", v, artistMode)
|
||||
// Remove aliases from other taggers.
|
||||
removeCommentKey(cmt, "ALBUM ARTIST")
|
||||
removeCommentKey(cmt, "ALBUM_ARTIST")
|
||||
}
|
||||
|
||||
// Track/disc numbers: present + empty → clear; when only totals are edited,
|
||||
// preserve the current index number and rewrite the combined value.
|
||||
if _, ok := fields["track_number"]; ok || fields["track_total"] != "" || hasMapKey(fields, "track_total") {
|
||||
currentTrackNum, currentTotalTracks := parseIndexPair(getComment(cmt, "TRACKNUMBER"))
|
||||
if currentTrackNum == 0 && currentTotalTracks == 0 {
|
||||
currentTrackNum, currentTotalTracks = parseIndexPair(getComment(cmt, "TRACK"))
|
||||
}
|
||||
if v, ok := fields["track_number"]; ok {
|
||||
currentTrackNum = parsePositiveInt(v)
|
||||
}
|
||||
if v, ok := fields["track_total"]; ok {
|
||||
currentTotalTracks = parsePositiveInt(v)
|
||||
}
|
||||
if currentTrackNum > 0 {
|
||||
setOrClearComment(cmt, "TRACKNUMBER", formatIndexValue(currentTrackNum, currentTotalTracks))
|
||||
} else {
|
||||
removeCommentKey(cmt, "TRACKNUMBER")
|
||||
}
|
||||
removeCommentKey(cmt, "TRACK") // alias
|
||||
}
|
||||
if _, ok := fields["disc_number"]; ok || fields["disc_total"] != "" || hasMapKey(fields, "disc_total") {
|
||||
currentDiscNum, currentTotalDiscs := parseIndexPair(getComment(cmt, "DISCNUMBER"))
|
||||
if currentDiscNum == 0 && currentTotalDiscs == 0 {
|
||||
currentDiscNum, currentTotalDiscs = parseIndexPair(getComment(cmt, "DISC"))
|
||||
}
|
||||
if v, ok := fields["disc_number"]; ok {
|
||||
currentDiscNum = parsePositiveInt(v)
|
||||
}
|
||||
if v, ok := fields["disc_total"]; ok {
|
||||
currentTotalDiscs = parsePositiveInt(v)
|
||||
}
|
||||
if currentDiscNum > 0 {
|
||||
setOrClearComment(cmt, "DISCNUMBER", formatIndexValue(currentDiscNum, currentTotalDiscs))
|
||||
} else {
|
||||
removeCommentKey(cmt, "DISCNUMBER")
|
||||
}
|
||||
removeCommentKey(cmt, "DISC") // alias
|
||||
}
|
||||
|
||||
// Lyrics: set both LYRICS + UNSYNCEDLYRICS, or clear both.
|
||||
if v, ok := fields["lyrics"]; ok {
|
||||
if v != "" {
|
||||
setOrClearComment(cmt, "LYRICS", v)
|
||||
setOrClearComment(cmt, "UNSYNCEDLYRICS", v)
|
||||
} else {
|
||||
removeCommentKey(cmt, "LYRICS")
|
||||
removeCommentKey(cmt, "UNSYNCEDLYRICS")
|
||||
}
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
} else {
|
||||
f.Meta = append(f.Meta, &cmtBlock)
|
||||
}
|
||||
|
||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||
if coverPath != "" && fileExists(coverPath) {
|
||||
coverData, err := os.ReadFile(coverPath)
|
||||
if err == nil && len(coverData) > 0 {
|
||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||
if f.Meta[i].Type == flac.Picture {
|
||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||
}
|
||||
}
|
||||
picBlock, err := buildPictureBlock("", coverData)
|
||||
if err == nil {
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// writeVorbisMetadata writes all metadata fields to a Vorbis Comment block.
|
||||
// Empty/zero values are simply skipped (not written, not cleared). This is
|
||||
// used by the download embedding path where absent fields should preserve any
|
||||
// existing values. The editor path uses EditFlacFields() instead.
|
||||
func writeVorbisMetadata(cmt *flacvorbis.MetaDataBlockVorbisComment, metadata Metadata) {
|
||||
setComment(cmt, "TITLE", metadata.Title)
|
||||
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
setArtistComments(cmt, "ALBUMARTIST", metadata.AlbumArtist, metadata.ArtistTagMode)
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
setComment(cmt, "TRACKNUMBER", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||
}
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
setComment(cmt, "DISCNUMBER", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||
}
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
setComment(cmt, "ISRC", metadata.ISRC)
|
||||
}
|
||||
|
||||
if metadata.Description != "" {
|
||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||
}
|
||||
|
||||
if metadata.Lyrics != "" {
|
||||
setComment(cmt, "LYRICS", metadata.Lyrics)
|
||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||
}
|
||||
|
||||
if metadata.Genre != "" {
|
||||
setComment(cmt, "GENRE", metadata.Genre)
|
||||
}
|
||||
|
||||
if metadata.Label != "" {
|
||||
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||
}
|
||||
|
||||
if metadata.Copyright != "" {
|
||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||
}
|
||||
|
||||
if metadata.Composer != "" {
|
||||
setComment(cmt, "COMPOSER", metadata.Composer)
|
||||
}
|
||||
|
||||
if metadata.Comment != "" {
|
||||
setComment(cmt, "COMMENT", metadata.Comment)
|
||||
}
|
||||
|
||||
setComment(cmt, "REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
|
||||
setComment(cmt, "REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
|
||||
setComment(cmt, "REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
|
||||
setComment(cmt, "REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak)
|
||||
}
|
||||
|
||||
func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
@@ -414,7 +546,21 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
||||
cmt.Comments = append(cmt.Comments, key+"="+value)
|
||||
}
|
||||
|
||||
// setOrClearComment writes a Vorbis Comment, or removes the key if value is
|
||||
// empty. Used by the metadata editor path where empty means "delete this tag".
|
||||
func setOrClearComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
||||
if value == "" {
|
||||
removeCommentKey(cmt, key)
|
||||
return
|
||||
}
|
||||
removeCommentKey(cmt, key)
|
||||
cmt.Comments = append(cmt.Comments, key+"="+value)
|
||||
}
|
||||
|
||||
func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, mode string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
values := []string{value}
|
||||
if shouldSplitVorbisArtistTags(mode) {
|
||||
values = splitArtistTagValues(value)
|
||||
@@ -431,6 +577,76 @@ func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, m
|
||||
}
|
||||
}
|
||||
|
||||
// setOrClearArtistComments writes artist Vorbis Comments, or removes the key
|
||||
// if value is empty. Used by the metadata editor path.
|
||||
func setOrClearArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, mode string) {
|
||||
if value == "" {
|
||||
removeCommentKey(cmt, key)
|
||||
return
|
||||
}
|
||||
values := []string{value}
|
||||
if shouldSplitVorbisArtistTags(mode) {
|
||||
values = splitArtistTagValues(value)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
removeCommentKey(cmt, key)
|
||||
return
|
||||
}
|
||||
removeCommentKey(cmt, key)
|
||||
for _, artist := range values {
|
||||
if strings.TrimSpace(artist) == "" {
|
||||
continue
|
||||
}
|
||||
cmt.Comments = append(cmt.Comments, key+"="+artist)
|
||||
}
|
||||
}
|
||||
|
||||
// RewriteSplitArtistTags opens a FLAC file and rewrites the ARTIST and
|
||||
// ALBUMARTIST Vorbis comments as multiple separate entries (one per artist).
|
||||
// This is needed because FFmpeg's -metadata flag deduplicates keys, so only
|
||||
// the last value survives when multiple -metadata ARTIST=X flags are used.
|
||||
// The native go-flac writer correctly handles multiple Vorbis comments.
|
||||
func RewriteSplitArtistTags(filePath, artist, albumArtist string) error {
|
||||
if !shouldSplitVorbisArtistTags(artistTagModeSplitVorbis) {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
var cmtIdx int = -1
|
||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||
|
||||
for idx, meta := range f.Meta {
|
||||
if meta.Type == flac.VorbisComment {
|
||||
cmtIdx = idx
|
||||
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cmt == nil {
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
setArtistComments(cmt, "ARTIST", artist, artistTagModeSplitVorbis)
|
||||
setArtistComments(cmt, "ALBUMARTIST", albumArtist, artistTagModeSplitVorbis)
|
||||
|
||||
cmtMeta := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtMeta
|
||||
} else {
|
||||
f.Meta = append(f.Meta, &cmtMeta)
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
func removeCommentKey(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) {
|
||||
keyUpper := strings.ToUpper(key)
|
||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||
@@ -747,9 +963,9 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) {
|
||||
case "\xa9lyr":
|
||||
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "trkn":
|
||||
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fi.Size())
|
||||
case "disk":
|
||||
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fi.Size())
|
||||
case "----":
|
||||
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
|
||||
if freeformErr == nil {
|
||||
@@ -774,6 +990,14 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) {
|
||||
if metadata.Lyrics == "" {
|
||||
metadata.Lyrics = value
|
||||
}
|
||||
case "REPLAYGAIN_TRACK_GAIN":
|
||||
metadata.ReplayGainTrackGain = value
|
||||
case "REPLAYGAIN_TRACK_PEAK":
|
||||
metadata.ReplayGainTrackPeak = value
|
||||
case "REPLAYGAIN_ALBUM_GAIN":
|
||||
metadata.ReplayGainAlbumGain = value
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,6 +1160,41 @@ func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, erro
|
||||
return int(binary.BigEndian.Uint16(payload[2:4])), nil
|
||||
}
|
||||
|
||||
func readM4AIndexPair(f *os.File, parent atomHeader, fileSize int64) (int, int, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(payload) < 6 {
|
||||
return 0, 0, fmt.Errorf("index payload too short in %s", parent.typ)
|
||||
}
|
||||
return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6])), nil
|
||||
}
|
||||
|
||||
func parsePositiveInt(value string) int {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(value)
|
||||
return n
|
||||
}
|
||||
|
||||
func formatIndexValue(number, total int) string {
|
||||
if number <= 0 {
|
||||
return ""
|
||||
}
|
||||
if total > 0 {
|
||||
return fmt.Sprintf("%d/%d", number, total)
|
||||
}
|
||||
return strconv.Itoa(number)
|
||||
}
|
||||
|
||||
func hasMapKey(fields map[string]string, key string) bool {
|
||||
_, ok := fields[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
|
||||
start := parent.offset + parent.headerSize
|
||||
end := parent.offset + parent.size
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package gobackend
|
||||
|
||||
import "time"
|
||||
|
||||
type cacheEntry struct {
|
||||
data interface{}
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func (e *cacheEntry) isExpired() bool {
|
||||
return time.Now().After(e.expiresAt)
|
||||
}
|
||||
|
||||
type TrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
Artists string `json:"artists"`
|
||||
Name string `json:"name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Images string `json:"images"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumTrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
Artists string `json:"artists"`
|
||||
Name string `json:"name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Images string `json:"images"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumInfoMetadata struct {
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artists string `json:"artists"`
|
||||
ArtistId string `json:"artist_id,omitempty"`
|
||||
Images string `json:"images"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumResponsePayload struct {
|
||||
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||
}
|
||||
|
||||
type PlaylistInfoMetadata struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Images string `json:"images,omitempty"`
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
Owner struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Name string `json:"name"`
|
||||
Images string `json:"images"`
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
type PlaylistResponsePayload struct {
|
||||
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||
}
|
||||
|
||||
type ArtistInfoMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images string `json:"images"`
|
||||
Followers int `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
type ArtistAlbumMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Images string `json:"images"`
|
||||
AlbumType string `json:"album_type"`
|
||||
Artists string `json:"artists"`
|
||||
}
|
||||
|
||||
type ArtistResponsePayload struct {
|
||||
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||
}
|
||||
|
||||
type TrackResponse struct {
|
||||
Track TrackMetadata `json:"track"`
|
||||
}
|
||||
|
||||
type SearchArtistResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images string `json:"images"`
|
||||
Followers int `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
type SearchAlbumResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
Images string `json:"images"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
AlbumType string `json:"album_type"`
|
||||
}
|
||||
|
||||
type SearchPlaylistResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Images string `json:"images"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
}
|
||||
|
||||
type SearchAllResult struct {
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Artists []SearchArtistResult `json:"artists"`
|
||||
Albums []SearchAlbumResult `json:"albums"`
|
||||
Playlists []SearchPlaylistResult `json:"playlists"`
|
||||
}
|
||||
+249
-32
@@ -44,35 +44,35 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id="
|
||||
qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query="
|
||||
qobuzAlbumGetBaseURL = "https://www.qobuz.com/api.json/0.2/album/get?album_id="
|
||||
qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id="
|
||||
qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id="
|
||||
qobuzAPIBaseURL = "https://api.zarz.moe/v1/qbz/"
|
||||
qobuzTrackGetBaseURL = qobuzAPIBaseURL + "track/get?track_id="
|
||||
qobuzTrackSearchBaseURL = qobuzAPIBaseURL + "track/search?query="
|
||||
qobuzAlbumGetBaseURL = qobuzAPIBaseURL + "album/get?album_id="
|
||||
qobuzArtistGetBaseURL = qobuzAPIBaseURL + "artist/get?artist_id="
|
||||
qobuzPlaylistGetBaseURL = qobuzAPIBaseURL + "playlist/get?playlist_id="
|
||||
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
|
||||
qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/"
|
||||
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
|
||||
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
|
||||
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
|
||||
qobuzZarzDownloadAPIURL = "https://api.zarz.moe/dl/qbz"
|
||||
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
||||
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
||||
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
|
||||
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
|
||||
qobuzDebugKeyXORMask = byte(0x5A)
|
||||
|
||||
qobuzFallbackAPIBaseURL = "https://api.zarz.moe/v1/qbz2/"
|
||||
qobuzFallbackTrackGetBaseURL = qobuzFallbackAPIBaseURL + "track/get?track_id="
|
||||
qobuzFallbackTrackSearchBaseURL = qobuzFallbackAPIBaseURL + "track/search?query="
|
||||
qobuzFallbackAlbumGetBaseURL = qobuzFallbackAPIBaseURL + "album/get?album_id="
|
||||
qobuzFallbackArtistGetBaseURL = qobuzFallbackAPIBaseURL + "artist/get?artist_id="
|
||||
qobuzFallbackPlaylistGetBaseURL = qobuzFallbackAPIBaseURL + "playlist/get?playlist_id="
|
||||
)
|
||||
|
||||
var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`)
|
||||
var qobuzArtistAlbumIDRegex = regexp.MustCompile(`data-itemtype="album"\s+data-itemId="([A-Za-z0-9]+)"`)
|
||||
var qobuzLocaleSegmentRegex = regexp.MustCompile(`^[a-z]{2}-[a-z]{2}$`)
|
||||
|
||||
var qobuzDebugKeyObfuscated = []byte{
|
||||
0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b,
|
||||
0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b,
|
||||
0x34, 0x3e, 0x34, 0x35, 0x35, 0x34, 0x3f, 0x39, 0x35, 0x37,
|
||||
0x3f, 0x29, 0x3f, 0x2c, 0x3f, 0x34, 0x39, 0x36, 0x35, 0x29,
|
||||
0x3f,
|
||||
}
|
||||
|
||||
type QobuzTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -106,6 +106,10 @@ type QobuzTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"performer"`
|
||||
Composer struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"composer"`
|
||||
}
|
||||
|
||||
type qobuzImageSet struct {
|
||||
@@ -350,6 +354,7 @@ func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata {
|
||||
AlbumID: qobuzPrefixedID(track.Album.ID),
|
||||
ArtistID: qobuzTrackArtistID(track),
|
||||
AlbumType: qobuzTrackAlbumType(track),
|
||||
Composer: strings.TrimSpace(track.Composer.Name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +379,7 @@ func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata {
|
||||
AlbumID: qobuzPrefixedID(track.Album.ID),
|
||||
AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)),
|
||||
AlbumType: qobuzTrackAlbumType(track),
|
||||
Composer: strings.TrimSpace(track.Composer.Name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,12 +792,21 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||
|
||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||
if err != nil {
|
||||
if isQobuzPrimaryUnavailable(err) {
|
||||
GoLog("[Qobuz] Primary API unavailable for track %d, trying qbz2 fallback: %v\n", trackID, err)
|
||||
return q.getTrackByIDViaMusicDL(trackID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("get track failed: HTTP %d", resp.StatusCode)
|
||||
primaryErr := fmt.Errorf("get track failed: HTTP %d", resp.StatusCode)
|
||||
if isQobuzPrimaryUnavailable(primaryErr) {
|
||||
GoLog("[Qobuz] Primary API unavailable for track %d, trying qbz2 fallback: %v\n", trackID, primaryErr)
|
||||
return q.getTrackByIDViaMusicDL(trackID)
|
||||
}
|
||||
return nil, primaryErr
|
||||
}
|
||||
|
||||
var track QobuzTrack
|
||||
@@ -802,6 +817,16 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getTrackByIDViaMusicDL(trackID int64) (*QobuzTrack, error) {
|
||||
requestURL := fmt.Sprintf("%s%d", qobuzFallbackTrackGetBaseURL, trackID)
|
||||
var track QobuzTrack
|
||||
if err := q.getQobuzJSON(requestURL, &track); err != nil {
|
||||
return nil, fmt.Errorf("qbz2 fallback also failed for track %d: %w", trackID, err)
|
||||
}
|
||||
GoLog("[Qobuz] qbz2 fallback succeeded for track %d\n", trackID)
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getQobuzJSON(requestURL string, target interface{}) error {
|
||||
req, err := http.NewRequest("GET", requestURL, nil)
|
||||
if err != nil {
|
||||
@@ -842,6 +867,25 @@ func (q *QobuzDownloader) getQobuzBody(requestURL string) ([]byte, error) {
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func isQobuzPrimaryUnavailable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errStr := err.Error()
|
||||
return strings.Contains(errStr, "HTTP 429") ||
|
||||
strings.Contains(errStr, "HTTP 5") ||
|
||||
strings.Contains(errStr, "rate limit") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "no such host") ||
|
||||
strings.Contains(errStr, "i/o timeout") ||
|
||||
strings.Contains(errStr, "deadline exceeded") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "TLS handshake") ||
|
||||
strings.Contains(errStr, "server misbehaving") ||
|
||||
strings.Contains(errStr, "network is unreachable")
|
||||
}
|
||||
|
||||
func extractQobuzAlbumIDsFromArtistHTML(body []byte) []string {
|
||||
matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
|
||||
if len(matches) == 0 {
|
||||
@@ -871,20 +915,48 @@ func (q *QobuzDownloader) getAlbumDetails(albumID string) (*qobuzAlbumDetails, e
|
||||
requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID)), q.appID)
|
||||
var album qobuzAlbumDetails
|
||||
if err := q.getQobuzJSON(requestURL, &album); err != nil {
|
||||
if isQobuzPrimaryUnavailable(err) {
|
||||
GoLog("[Qobuz] Primary API unavailable for album %s, trying qbz2 fallback: %v\n", albumID, err)
|
||||
return q.getAlbumDetailsViaMusicDL(albumID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getAlbumDetailsViaMusicDL(albumID string) (*qobuzAlbumDetails, error) {
|
||||
requestURL := fmt.Sprintf("%s%s", qobuzFallbackAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID)))
|
||||
var album qobuzAlbumDetails
|
||||
if err := q.getQobuzJSON(requestURL, &album); err != nil {
|
||||
return nil, fmt.Errorf("qbz2 fallback also failed for album %s: %w", albumID, err)
|
||||
}
|
||||
GoLog("[Qobuz] qbz2 fallback succeeded for album %s\n", albumID)
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getArtistDetails(artistID string) (*qobuzArtistDetails, error) {
|
||||
requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID)), q.appID)
|
||||
var artist qobuzArtistDetails
|
||||
if err := q.getQobuzJSON(requestURL, &artist); err != nil {
|
||||
if isQobuzPrimaryUnavailable(err) {
|
||||
GoLog("[Qobuz] Primary API unavailable for artist %s, trying qbz2 fallback: %v\n", artistID, err)
|
||||
return q.getArtistDetailsViaMusicDL(artistID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getArtistDetailsViaMusicDL(artistID string) (*qobuzArtistDetails, error) {
|
||||
requestURL := fmt.Sprintf("%s%s", qobuzFallbackArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID)))
|
||||
var artist qobuzArtistDetails
|
||||
if err := q.getQobuzJSON(requestURL, &artist); err != nil {
|
||||
return nil, fmt.Errorf("qbz2 fallback also failed for artist %s: %w", artistID, err)
|
||||
}
|
||||
GoLog("[Qobuz] qbz2 fallback succeeded for artist %s\n", artistID)
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getPlaylistDetailsPage(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) {
|
||||
requestURL := fmt.Sprintf(
|
||||
"%s%s&extra=tracks&limit=%d&offset=%d&app_id=%s",
|
||||
@@ -896,11 +968,31 @@ func (q *QobuzDownloader) getPlaylistDetailsPage(playlistID string, limit, offse
|
||||
)
|
||||
var playlist qobuzPlaylistDetails
|
||||
if err := q.getQobuzJSON(requestURL, &playlist); err != nil {
|
||||
if isQobuzPrimaryUnavailable(err) {
|
||||
GoLog("[Qobuz] Primary API unavailable for playlist %s, trying qbz2 fallback: %v\n", playlistID, err)
|
||||
return q.getPlaylistDetailsPageViaMusicDL(playlistID, limit, offset)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &playlist, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getPlaylistDetailsPageViaMusicDL(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) {
|
||||
requestURL := fmt.Sprintf(
|
||||
"%s%s&limit=%d&offset=%d",
|
||||
qobuzFallbackPlaylistGetBaseURL,
|
||||
url.QueryEscape(strings.TrimSpace(playlistID)),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
var playlist qobuzPlaylistDetails
|
||||
if err := q.getQobuzJSON(requestURL, &playlist); err != nil {
|
||||
return nil, fmt.Errorf("qbz2 fallback also failed for playlist %s: %w", playlistID, err)
|
||||
}
|
||||
GoLog("[Qobuz] qbz2 fallback succeeded for playlist %s (offset=%d)\n", playlistID, offset)
|
||||
return &playlist, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getArtistAlbumIDs(artistID string) ([]string, error) {
|
||||
artist, err := q.getArtistDetails(artistID)
|
||||
if err != nil {
|
||||
@@ -945,6 +1037,7 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
|
||||
totalDiscs := 0
|
||||
for i := range album.Tracks.Items {
|
||||
track := &album.Tracks.Items[i]
|
||||
track.Album.ID = album.ID
|
||||
@@ -956,8 +1049,14 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
Large: album.Image.Large,
|
||||
}
|
||||
track.Album.TracksCount = album.TracksCount
|
||||
if track.MediaNumber > totalDiscs {
|
||||
totalDiscs = track.MediaNumber
|
||||
}
|
||||
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
|
||||
}
|
||||
for i := range tracks {
|
||||
tracks[i].TotalDiscs = totalDiscs
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
AlbumInfo: qobuzAlbumToAlbumInfo(album),
|
||||
@@ -1041,6 +1140,7 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
|
||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
return []string{
|
||||
qobuzDownloadAPIURL,
|
||||
qobuzZarzDownloadAPIURL,
|
||||
qobuzDabMusicAPIURL,
|
||||
qobuzDeebAPIURL,
|
||||
qobuzAfkarAPIURL,
|
||||
@@ -1062,10 +1162,9 @@ const (
|
||||
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
|
||||
return []qobuzAPIProvider{
|
||||
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
|
||||
{Name: "zarz", URL: qobuzZarzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
|
||||
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
|
||||
// "deeb" is mapped from the legacy reference fallback endpoint.
|
||||
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
|
||||
// "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=...
|
||||
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
|
||||
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
|
||||
}
|
||||
@@ -1216,14 +1315,6 @@ func mapQobuzQualityCodeToAPI(qualityCode string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func getQobuzDebugKey() string {
|
||||
decoded := make([]byte, len(qobuzDebugKeyObfuscated))
|
||||
for i, b := range qobuzDebugKeyObfuscated {
|
||||
decoded[i] = b ^ qobuzDebugKeyXORMask
|
||||
}
|
||||
return string(decoded)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
candidates, err := q.searchQobuzTracksWithFallback(isrc, 50)
|
||||
if err != nil {
|
||||
@@ -1376,9 +1467,10 @@ func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, f
|
||||
}
|
||||
|
||||
if artistLimit > 0 {
|
||||
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/artist/search?query=%s&limit=%d&app_id=%s",
|
||||
url.QueryEscape(cleanQuery), artistLimit, q.appID)
|
||||
searchURL := fmt.Sprintf("%sartist/search?query=%s&limit=%d&app_id=%s",
|
||||
qobuzAPIBaseURL, url.QueryEscape(cleanQuery), artistLimit, q.appID)
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
artistSearchDone := false
|
||||
if err == nil {
|
||||
resp, reqErr := DoRequestWithUserAgent(q.client, req)
|
||||
if reqErr == nil {
|
||||
@@ -1403,20 +1495,30 @@ func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, f
|
||||
Images: imageURL,
|
||||
})
|
||||
}
|
||||
artistSearchDone = true
|
||||
} else {
|
||||
GoLog("[Qobuz] Artist search decode failed: %v\n", decErr)
|
||||
}
|
||||
} else if isQobuzPrimaryUnavailable(fmt.Errorf("HTTP %d", resp.StatusCode)) {
|
||||
GoLog("[Qobuz] Artist search primary API returned HTTP %d, will try qbz2 fallback\n", resp.StatusCode)
|
||||
}
|
||||
} else {
|
||||
GoLog("[Qobuz] Artist search request failed: %v\n", reqErr)
|
||||
if isQobuzPrimaryUnavailable(reqErr) {
|
||||
GoLog("[Qobuz] Primary API unavailable for artist search, will try qbz2 fallback\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !artistSearchDone {
|
||||
q.searchAllArtistsViaMusicDL(cleanQuery, artistLimit, result)
|
||||
}
|
||||
}
|
||||
|
||||
if albumLimit > 0 {
|
||||
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
|
||||
url.QueryEscape(cleanQuery), albumLimit, q.appID)
|
||||
searchURL := fmt.Sprintf("%salbum/search?query=%s&limit=%d&app_id=%s",
|
||||
qobuzAPIBaseURL, url.QueryEscape(cleanQuery), albumLimit, q.appID)
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
albumSearchDone := false
|
||||
if err == nil {
|
||||
resp, reqErr := DoRequestWithUserAgent(q.client, req)
|
||||
if reqErr == nil {
|
||||
@@ -1441,20 +1543,81 @@ func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, f
|
||||
AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
|
||||
})
|
||||
}
|
||||
albumSearchDone = true
|
||||
} else {
|
||||
GoLog("[Qobuz] Album search decode failed: %v\n", decErr)
|
||||
}
|
||||
} else if isQobuzPrimaryUnavailable(fmt.Errorf("HTTP %d", resp.StatusCode)) {
|
||||
GoLog("[Qobuz] Album search primary API returned HTTP %d, will try qbz2 fallback\n", resp.StatusCode)
|
||||
}
|
||||
} else {
|
||||
GoLog("[Qobuz] Album search request failed: %v\n", reqErr)
|
||||
if isQobuzPrimaryUnavailable(reqErr) {
|
||||
GoLog("[Qobuz] Primary API unavailable for album search, will try qbz2 fallback\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !albumSearchDone {
|
||||
q.searchAllAlbumsViaMusicDL(cleanQuery, albumLimit, result)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) searchAllArtistsViaMusicDL(query string, limit int, result *SearchAllResult) {
|
||||
requestURL := fmt.Sprintf("%sartist/search?query=%s&limit=%d", qobuzFallbackAPIBaseURL, url.QueryEscape(query), limit)
|
||||
var searchResp struct {
|
||||
Artists struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image qobuzImageSet `json:"image"`
|
||||
} `json:"items"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
if err := q.getQobuzJSON(requestURL, &searchResp); err != nil {
|
||||
GoLog("[Qobuz] qbz2 fallback artist search also failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
GoLog("[Qobuz] qbz2 fallback artist search succeeded: %d artists\n", len(searchResp.Artists.Items))
|
||||
for _, artist := range searchResp.Artists.Items {
|
||||
imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail)
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: qobuzPrefixedNumericID(artist.ID),
|
||||
Name: strings.TrimSpace(artist.Name),
|
||||
Images: imageURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) searchAllAlbumsViaMusicDL(query string, limit int, result *SearchAllResult) {
|
||||
requestURL := fmt.Sprintf("%salbum/search?query=%s&limit=%d", qobuzFallbackAPIBaseURL, url.QueryEscape(query), limit)
|
||||
var searchResp struct {
|
||||
Albums struct {
|
||||
Items []qobuzAlbumDetails `json:"items"`
|
||||
} `json:"albums"`
|
||||
}
|
||||
if err := q.getQobuzJSON(requestURL, &searchResp); err != nil {
|
||||
GoLog("[Qobuz] qbz2 fallback album search also failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
GoLog("[Qobuz] qbz2 fallback album search succeeded: %d albums\n", len(searchResp.Albums.Items))
|
||||
for i := range searchResp.Albums.Items {
|
||||
album := &searchResp.Albums.Items[i]
|
||||
result.Albums = append(result.Albums, SearchAlbumResult{
|
||||
ID: qobuzPrefixedID(album.ID),
|
||||
Name: strings.TrimSpace(album.Title),
|
||||
Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
|
||||
Images: qobuzAlbumImage(album),
|
||||
ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
|
||||
TotalTracks: album.TracksCount,
|
||||
AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
queries := []string{}
|
||||
|
||||
@@ -1646,13 +1809,22 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
|
||||
|
||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||
if err != nil {
|
||||
if isQobuzPrimaryUnavailable(err) {
|
||||
GoLog("[Qobuz] Primary API unavailable for track search, trying qbz2 fallback: %v\n", err)
|
||||
return q.searchQobuzTracksViaMusicDL(query, limit)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
primaryErr := fmt.Errorf("search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
if isQobuzPrimaryUnavailable(primaryErr) {
|
||||
GoLog("[Qobuz] Primary API unavailable for track search, trying qbz2 fallback: %v\n", primaryErr)
|
||||
return q.searchQobuzTracksViaMusicDL(query, limit)
|
||||
}
|
||||
return nil, primaryErr
|
||||
}
|
||||
|
||||
var result struct {
|
||||
@@ -1666,6 +1838,20 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
|
||||
return result.Tracks.Items, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) searchQobuzTracksViaMusicDL(query string, limit int) ([]QobuzTrack, error) {
|
||||
requestURL := fmt.Sprintf("%s%s&limit=%d", qobuzFallbackTrackSearchBaseURL, url.QueryEscape(query), limit)
|
||||
var result struct {
|
||||
Tracks struct {
|
||||
Items []QobuzTrack `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
if err := q.getQobuzJSON(requestURL, &result); err != nil {
|
||||
return nil, fmt.Errorf("qbz2 fallback search also failed: %w", err)
|
||||
}
|
||||
GoLog("[Qobuz] qbz2 fallback search succeeded: %d tracks for '%s'\n", len(result.Tracks.Items), query)
|
||||
return result.Tracks.Items, nil
|
||||
}
|
||||
|
||||
type qobuzTrackSearchCandidate struct {
|
||||
score int
|
||||
track QobuzTrack
|
||||
@@ -1855,7 +2041,8 @@ func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearch(query string, limit in
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
|
||||
"%salbum/search?query=%s&limit=%d&app_id=%s",
|
||||
qobuzAPIBaseURL,
|
||||
url.QueryEscape(strings.TrimSpace(query)),
|
||||
albumLimit,
|
||||
q.appID,
|
||||
@@ -1868,13 +2055,22 @@ func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearch(query string, limit in
|
||||
|
||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||
if err != nil {
|
||||
if isQobuzPrimaryUnavailable(err) {
|
||||
GoLog("[Qobuz] Primary API unavailable for album search fallback, trying qbz2: %v\n", err)
|
||||
return q.searchQobuzTracksViaAlbumSearchMusicDL(query, limit, albumLimit)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("album search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
primaryErr := fmt.Errorf("album search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
if isQobuzPrimaryUnavailable(primaryErr) {
|
||||
GoLog("[Qobuz] Primary API unavailable for album search fallback, trying qbz2: %v\n", primaryErr)
|
||||
return q.searchQobuzTracksViaAlbumSearchMusicDL(query, limit, albumLimit)
|
||||
}
|
||||
return nil, primaryErr
|
||||
}
|
||||
|
||||
var albumResp struct {
|
||||
@@ -1894,6 +2090,25 @@ func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearch(query string, limit in
|
||||
)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearchMusicDL(query string, limit, albumLimit int) ([]QobuzTrack, error) {
|
||||
requestURL := fmt.Sprintf("%salbum/search?query=%s&limit=%d", qobuzFallbackAPIBaseURL, url.QueryEscape(strings.TrimSpace(query)), albumLimit)
|
||||
var searchResp struct {
|
||||
Albums struct {
|
||||
Items []qobuzAlbumDetails `json:"items"`
|
||||
} `json:"albums"`
|
||||
}
|
||||
if err := q.getQobuzJSON(requestURL, &searchResp); err != nil {
|
||||
return nil, fmt.Errorf("qbz2 fallback album search also failed: %w", err)
|
||||
}
|
||||
GoLog("[Qobuz] qbz2 fallback album search returned %d albums\n", len(searchResp.Albums.Items))
|
||||
return selectQobuzTracksFromAlbumSearchResults(
|
||||
query,
|
||||
limit,
|
||||
searchResp.Albums.Items,
|
||||
q.getAlbumDetails,
|
||||
)
|
||||
}
|
||||
|
||||
func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 {
|
||||
matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1)
|
||||
if len(matches) == 0 {
|
||||
@@ -2594,10 +2809,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
|
||||
+37
-14
@@ -201,18 +201,6 @@ func TestNormalizeQobuzQualityCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetQobuzDebugKey(t *testing.T) {
|
||||
got := getQobuzDebugKey()
|
||||
if len(got) != len(qobuzDebugKeyObfuscated) {
|
||||
t.Fatalf("unexpected debug key length: %d", len(got))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] {
|
||||
t.Fatalf("unexpected debug key reconstruction at index %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
|
||||
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
|
||||
if err != nil {
|
||||
@@ -253,12 +241,13 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||
|
||||
func TestQobuzAvailableProviders(t *testing.T) {
|
||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||
if len(providers) != 5 {
|
||||
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
||||
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,
|
||||
@@ -530,3 +519,37 @@ func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzTrackMetadataIncludesComposer(t *testing.T) {
|
||||
track := &QobuzTrack{
|
||||
ID: 40681594,
|
||||
Title: "Sign of the Times",
|
||||
ISRC: "USSM11703595",
|
||||
Duration: 340,
|
||||
TrackNumber: 1,
|
||||
MediaNumber: 1,
|
||||
}
|
||||
track.Performer.ID = 729886
|
||||
track.Performer.Name = "Harry Styles"
|
||||
track.Composer.ID = 729886
|
||||
track.Composer.Name = "Harry Styles"
|
||||
track.Album.ID = "0886446451985"
|
||||
track.Album.Title = "Harry Styles"
|
||||
track.Album.ReleaseDate = "2017-05-12"
|
||||
track.Album.TracksCount = 10
|
||||
track.Album.ReleaseType = "album"
|
||||
track.Album.ProductType = "album"
|
||||
track.Album.Artist.ID = 729886
|
||||
track.Album.Artist.Name = "Harry Styles"
|
||||
track.Album.Artists = []qobuzArtistRef{{ID: 729886, Name: "Harry Styles"}}
|
||||
|
||||
trackMeta := qobuzTrackToTrackMetadata(track)
|
||||
if trackMeta.Composer != "Harry Styles" {
|
||||
t.Fatalf("track composer = %q", trackMeta.Composer)
|
||||
}
|
||||
|
||||
albumTrackMeta := qobuzTrackToAlbumTrackMetadata(track)
|
||||
if albumTrackMeta.Composer != "Harry Styles" {
|
||||
t.Fatalf("album track composer = %q", albumTrackMeta.Composer)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-17
@@ -16,16 +16,13 @@ var hiraganaToRomaji = map[rune]string{
|
||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||
// Dakuten (voiced)
|
||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||
// Handakuten (semi-voiced)
|
||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||
// Small characters
|
||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||
'っ': "", // Double consonant marker
|
||||
'っ': "",
|
||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||
}
|
||||
|
||||
@@ -40,19 +37,15 @@ var katakanaToRomaji = map[rune]string{
|
||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||
// Dakuten (voiced)
|
||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||
// Handakuten (semi-voiced)
|
||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||
// Small characters
|
||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||
'ッ': "", // Double consonant marker
|
||||
'ッ': "",
|
||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||
// Extended katakana
|
||||
'ー': "", // Long vowel mark
|
||||
'ー': "",
|
||||
'ヴ': "vu",
|
||||
}
|
||||
|
||||
@@ -82,7 +75,6 @@ var combinationKatakana = map[string]string{
|
||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||
// Extended combinations
|
||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||
@@ -120,7 +112,6 @@ func JapaneseToRomaji(text string) string {
|
||||
i := 0
|
||||
|
||||
for i < len(runes) {
|
||||
// Check for っ/ッ (double consonant)
|
||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||
nextRomaji := ""
|
||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||
@@ -129,13 +120,12 @@ func JapaneseToRomaji(text string) string {
|
||||
nextRomaji = romaji
|
||||
}
|
||||
if len(nextRomaji) > 0 {
|
||||
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||
result.WriteByte(nextRomaji[0])
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for two-character combinations
|
||||
if i < len(runes)-1 {
|
||||
combo := string(runes[i : i+2])
|
||||
if romaji, ok := combinationHiragana[combo]; ok {
|
||||
@@ -150,17 +140,14 @@ func JapaneseToRomaji(text string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Single character conversion
|
||||
r := runes[i]
|
||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if isKanji(r) {
|
||||
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
// Keep other characters (punctuation, spaces, etc.)
|
||||
result.WriteRune(r)
|
||||
}
|
||||
i++
|
||||
|
||||
+212
-444
@@ -87,38 +87,210 @@ func GetSongLinkRegion() string {
|
||||
return region
|
||||
}
|
||||
|
||||
const resolveAPIURL = "https://api.zarz.moe/v1/resolve"
|
||||
|
||||
func songLinkBaseURL() string {
|
||||
opts := GetNetworkCompatibilityOptions()
|
||||
if opts.AllowHTTP {
|
||||
return "http://api.song.link/v1-alpha.1/links"
|
||||
}
|
||||
return "https://api.song.link/v1-alpha.1/links"
|
||||
}
|
||||
|
||||
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
|
||||
if userCountry == "" {
|
||||
userCountry = GetSongLinkRegion()
|
||||
// resolveTrackPlatforms resolves a music URL to all platforms.
|
||||
// Spotify URLs use the resolve API; if that fails, falls back to SongLink.
|
||||
// All other URLs go directly to SongLink.
|
||||
func (s *SongLinkClient) resolveTrackPlatforms(inputURL string) (map[string]songLinkPlatformLink, error) {
|
||||
if isSpotifyURL(inputURL) {
|
||||
payload, err := json.Marshal(map[string]string{"url": inputURL})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
|
||||
}
|
||||
links, err := s.doResolveRequest(payload)
|
||||
if err == nil {
|
||||
return links, nil
|
||||
}
|
||||
GoLog("[SongLink] Resolve proxy failed for %s: %v, falling back to SongLink", inputURL, err)
|
||||
return s.songLinkByTargetURL(inputURL)
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL))
|
||||
if userCountry != "" {
|
||||
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
||||
}
|
||||
return apiURL
|
||||
return s.songLinkByTargetURL(inputURL)
|
||||
}
|
||||
|
||||
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string {
|
||||
if userCountry == "" {
|
||||
userCountry = GetSongLinkRegion()
|
||||
// resolveTrackPlatformsByPlatform resolves using platform + type + id.
|
||||
// Spotify uses the resolve API with SongLink fallback; all other platforms use SongLink directly.
|
||||
func (s *SongLinkClient) resolveTrackPlatformsByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
|
||||
if strings.EqualFold(platform, "spotify") {
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"platform": platform,
|
||||
"type": entityType,
|
||||
"id": entityID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
|
||||
}
|
||||
links, err := s.doResolveRequest(payload)
|
||||
if err == nil {
|
||||
return links, nil
|
||||
}
|
||||
GoLog("[SongLink] Resolve proxy failed for %s/%s/%s: %v, falling back to SongLink", platform, entityType, entityID, err)
|
||||
return s.songLinkByPlatform(platform, entityType, entityID)
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s",
|
||||
return s.songLinkByPlatform(platform, entityType, entityID)
|
||||
}
|
||||
|
||||
func isSpotifyURL(u string) bool {
|
||||
lower := strings.ToLower(u)
|
||||
return strings.Contains(lower, "spotify.com/") || strings.Contains(lower, "spotify:")
|
||||
}
|
||||
|
||||
// doResolveRequest sends a JSON payload to the resolve API (api.zarz.moe)
|
||||
// and parses the response into a platform link map.
|
||||
func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPlatformLink, error) {
|
||||
req, err := http.NewRequest("POST", resolveAPIURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create resolve request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("resolve API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read resolve response: %w", err)
|
||||
}
|
||||
|
||||
var resolveResp struct {
|
||||
Success bool `json:"success"`
|
||||
ISRC string `json:"isrc"`
|
||||
SongUrls map[string]json.RawMessage `json:"songUrls"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resolveResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
|
||||
}
|
||||
if !resolveResp.Success {
|
||||
return nil, fmt.Errorf("resolve API returned success=false")
|
||||
}
|
||||
|
||||
keyMap := map[string]string{
|
||||
"Spotify": "spotify",
|
||||
"Deezer": "deezer",
|
||||
"Tidal": "tidal",
|
||||
"YouTubeMusic": "youtubeMusic",
|
||||
"YouTube": "youtube",
|
||||
"AmazonMusic": "amazonMusic",
|
||||
"Qobuz": "qobuz",
|
||||
"AppleMusic": "appleMusic",
|
||||
}
|
||||
|
||||
links := make(map[string]songLinkPlatformLink)
|
||||
for resolveKey, platformKey := range keyMap {
|
||||
rawValue, ok := resolveResp.SongUrls[resolveKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if u := extractResolveURLValue(rawValue); u != "" {
|
||||
links[platformKey] = songLinkPlatformLink{URL: u}
|
||||
}
|
||||
}
|
||||
|
||||
if len(links) == 0 {
|
||||
return nil, fmt.Errorf("resolve API returned no platform links")
|
||||
}
|
||||
|
||||
return links, nil
|
||||
}
|
||||
|
||||
func extractResolveURLValue(raw json.RawMessage) string {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return ""
|
||||
}
|
||||
|
||||
var direct string
|
||||
if err := json.Unmarshal(trimmed, &direct); err == nil {
|
||||
return strings.TrimSpace(direct)
|
||||
}
|
||||
|
||||
var list []string
|
||||
if err := json.Unmarshal(trimmed, &list); err == nil {
|
||||
for _, candidate := range list {
|
||||
if cleaned := strings.TrimSpace(candidate); cleaned != "" {
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// songLinkByTargetURL calls the SongLink API with a target URL (for non-Spotify URLs).
|
||||
func (s *SongLinkClient) songLinkByTargetURL(targetURL string) (map[string]songLinkPlatformLink, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiURL := fmt.Sprintf("%s?url=%s&userCountry=%s",
|
||||
songLinkBaseURL(),
|
||||
url.QueryEscape(targetURL),
|
||||
url.QueryEscape(GetSongLinkRegion()))
|
||||
|
||||
return s.doSongLinkRequest(apiURL)
|
||||
}
|
||||
|
||||
// songLinkByPlatform calls the SongLink API with platform + type + id (for non-Spotify platforms).
|
||||
func (s *SongLinkClient) songLinkByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s&userCountry=%s",
|
||||
songLinkBaseURL(),
|
||||
url.QueryEscape(platform),
|
||||
url.QueryEscape(entityType),
|
||||
url.QueryEscape(entityID))
|
||||
if userCountry != "" {
|
||||
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
||||
url.QueryEscape(entityID),
|
||||
url.QueryEscape(GetSongLinkRegion()))
|
||||
|
||||
return s.doSongLinkRequest(apiURL)
|
||||
}
|
||||
|
||||
// doSongLinkRequest calls the SongLink API and parses the response.
|
||||
func (s *SongLinkClient) doSongLinkRequest(apiURL string) (map[string]songLinkPlatformLink, error) {
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SongLink request: %w", err)
|
||||
}
|
||||
return apiURL
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SongLink request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read SongLink response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode SongLink response: %w", err)
|
||||
}
|
||||
|
||||
if len(songLinkResp.LinksByPlatform) == 0 {
|
||||
return nil, fmt.Errorf("SongLink returned no platform links")
|
||||
}
|
||||
|
||||
return songLinkResp.LinksByPlatform, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
@@ -136,145 +308,12 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
|
||||
if pageErr == nil {
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
if !songLinkRateLimiter.TryAcquire() {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
|
||||
}
|
||||
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
links, err := s.resolveTrackPlatforms(spotifyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("resolve proxy failed for Spotify %s: %w", spotifyTrackID, err)
|
||||
}
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
|
||||
req, err := http.NewRequest("GET", pageURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on song.link page")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read song.link page: %w", err)
|
||||
}
|
||||
|
||||
nextDataJSON, err := extractSongLinkNextDataJSON(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pageData struct {
|
||||
Props struct {
|
||||
PageProps struct {
|
||||
PageData struct {
|
||||
Sections []struct {
|
||||
Links []struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
Show bool `json:"show"`
|
||||
} `json:"links"`
|
||||
} `json:"sections"`
|
||||
} `json:"pageData"`
|
||||
} `json:"pageProps"`
|
||||
} `json:"props"`
|
||||
}
|
||||
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
|
||||
}
|
||||
|
||||
linksByPlatform := make(map[string]songLinkPlatformLink)
|
||||
for _, section := range pageData.Props.PageProps.PageData.Sections {
|
||||
for _, link := range section.Links {
|
||||
if !link.Show || strings.TrimSpace(link.URL) == "" {
|
||||
continue
|
||||
}
|
||||
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
|
||||
}
|
||||
}
|
||||
|
||||
if len(linksByPlatform) == 0 {
|
||||
return nil, fmt.Errorf("song.link page contained no usable platform links")
|
||||
}
|
||||
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
|
||||
}
|
||||
|
||||
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
|
||||
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
|
||||
const endMarker = `</script>`
|
||||
|
||||
start := bytes.Index(body, []byte(startMarker))
|
||||
if start < 0 {
|
||||
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
|
||||
}
|
||||
start += len(startMarker)
|
||||
|
||||
end := bytes.Index(body[start:], []byte(endMarker))
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
|
||||
}
|
||||
|
||||
return body[start : start+end], nil
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, links), nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
||||
@@ -469,8 +508,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// isNumeric is defined in library_scan.go
|
||||
|
||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
@@ -505,47 +542,17 @@ type AlbumAvailability struct {
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
links, err := s.resolveTrackPlatforms(spotifyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, fmt.Errorf("resolve proxy failed for album %s: %w", spotifyAlbumID, err)
|
||||
}
|
||||
|
||||
availability := &AlbumAvailability{
|
||||
SpotifyID: spotifyAlbumID,
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
@@ -588,101 +595,19 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||
apiURL := buildSongLinkURLFromTarget(deezerURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
links, err := s.resolveTrackPlatforms(deezerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("resolve failed for Deezer %s: %w", deezerTrackID, err)
|
||||
}
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
availability := buildTrackAvailabilityFromSongLinkLinks("", links)
|
||||
// Ensure Deezer is always marked available since we started from a Deezer URL
|
||||
availability.Deezer = true
|
||||
availability.DeezerID = deezerTrackID
|
||||
if availability.DeezerURL == "" {
|
||||
availability.DeezerURL = deezerURL
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
EntitiesByUniqueId map[string]struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
ArtistName string `json:"artistName"`
|
||||
} `json:"entitiesByUniqueId"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
Deezer: true,
|
||||
DeezerID: deezerTrackID,
|
||||
}
|
||||
|
||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
@@ -694,94 +619,12 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||
}
|
||||
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
links, err := s.resolveTrackPlatformsByPlatform(platform, entityType, entityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("resolve failed for %s %s: %w", platform, entityID, err)
|
||||
}
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{}
|
||||
|
||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
|
||||
}
|
||||
|
||||
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
||||
@@ -894,85 +737,10 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string,
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiURL := buildSongLinkURLFromTarget(inputURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
links, err := s.resolveTrackPlatforms(inputURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("resolve failed for URL %s: %w", inputURL, err)
|
||||
}
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 || resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on SongLink")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
EntityID string `json:"entityUniqueId"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{}
|
||||
|
||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
|
||||
}
|
||||
|
||||
+108
-36
@@ -23,26 +23,24 @@ func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
|
||||
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(t *testing.T) {
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "api.song.link":
|
||||
t.Fatalf("api.song.link should not be called when song.link page succeeds")
|
||||
return nil, nil
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
body := `<!DOCTYPE html><html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"pageData":{"sections":[{"displayName":"Listen","links":[{"platform":"spotify","url":"https://open.spotify.com/track/testspotifyid","show":true},{"platform":"deezer","url":"https://www.deezer.com/track/908604612","show":true},{"platform":"amazonMusic","url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","show":true},{"platform":"tidal","url":"https://listen.tidal.com/track/134858527","show":true},{"platform":"qobuz","url":"https://open.qobuz.com/track/195125822","show":true},{"platform":"youtubeMusic","url":"https://music.youtube.com/watch?v=testvideoid1","show":true}]}]}}}}</script></body></html>`
|
||||
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
|
||||
body := `{"success":true,"isrc":"USRC12345678","songUrls":{"Spotify":"https://open.spotify.com/track/testspotifyid","Deezer":"https://www.deezer.com/track/908604612","AmazonMusic":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","Tidal":"https://listen.tidal.com/track/134858527","Qobuz":"https://open.qobuz.com/track/195125822","YouTubeMusic":"https://music.youtube.com/watch?v=testvideoid1"}}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||
return nil, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -66,62 +64,136 @@ func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
|
||||
func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) {
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 0,
|
||||
InitialDelay: 0,
|
||||
MaxDelay: 0,
|
||||
BackoffFactor: 1,
|
||||
}
|
||||
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
||||
}
|
||||
defer func() {
|
||||
songLinkRetryConfig = origRetryConfig
|
||||
}()
|
||||
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||
|
||||
var hitSongLink bool
|
||||
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
// Resolve proxy returns 500
|
||||
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" {
|
||||
return &http.Response{
|
||||
StatusCode: 500,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("page failure")),
|
||||
Body: io.NopCloser(strings.NewReader("internal error")),
|
||||
Request: req,
|
||||
}, nil
|
||||
case req.URL.Host == "api.song.link":
|
||||
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}`
|
||||
}
|
||||
// SongLink fallback should be called
|
||||
if req.URL.Host == "api.song.link" {
|
||||
hitSongLink = true
|
||||
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"}}}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||
return nil, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected SongLink fallback to succeed, got error: %v", err)
|
||||
}
|
||||
if !hitSongLink {
|
||||
t.Fatal("expected fallback request to SongLink API, but it was never called")
|
||||
}
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer availability via fallback = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPIMixedSongURLShapes(t *testing.T) {
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
|
||||
body := `{"success":true,"isrc":"TCAHA2367688","songUrls":{"Spotify":"https://open.spotify.com/track/5glgyj6zH0irbNGfukHacv","Deezer":"https://www.deezer.com/track/2248583177","Tidal":"https://tidal.com/browse/track/290565315","AppleMusic":"https://geo.music.apple.com/us/album/example?i=1","YouTubeMusic":null,"YouTube":"https://www.youtube.com/watch?v=wD_e59XUNdQ","AmazonMusic":"https://music.amazon.com/tracks/B0C35TG38Y/?ref=dm_ff_amazonmusic_3p","Beatport":null,"BeatSource":null,"SoundCloud":null,"Qobuz":null,"Other":[]}}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||
return nil, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.CheckTrackAvailability("5glgyj6zH0irbNGfukHacv", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||
}
|
||||
|
||||
if availability.SpotifyID != "testspotifyid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||
if availability.SpotifyID != "5glgyj6zH0irbNGfukHacv" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "5glgyj6zH0irbNGfukHacv")
|
||||
}
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||
if !availability.Deezer || availability.DeezerID != "2248583177" {
|
||||
t.Fatalf("Deezer availability = %+v, want DeezerID 2248583177", availability)
|
||||
}
|
||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||
if !availability.Tidal || availability.TidalID != "290565315" {
|
||||
t.Fatalf("Tidal availability = %+v, want TidalID 290565315", availability)
|
||||
}
|
||||
if availability.YouTubeID != "testvideoid1" {
|
||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||
if availability.Qobuz {
|
||||
t.Fatalf("Qobuz should remain false when resolve response contains null, got %+v", availability)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAvailabilityFromDeezerUsesSongLink(t *testing.T) {
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
||||
}
|
||||
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
// Non-Spotify should go to SongLink, not resolve API
|
||||
if req.URL.Host == "api.zarz.moe" {
|
||||
t.Fatalf("non-Spotify URL should not hit resolve API, got: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
if req.URL.Host == "api.song.link" {
|
||||
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvid"}}}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||
return nil, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.checkAvailabilityFromDeezerSongLink("908604612")
|
||||
if err != nil {
|
||||
t.Fatalf("checkAvailabilityFromDeezerSongLink() error = %v", err)
|
||||
}
|
||||
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
if availability.SpotifyID != "testid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testid")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+24
-19
@@ -875,8 +875,6 @@ func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SearchAll searches Tidal for tracks, artists, and albums matching the query.
|
||||
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
|
||||
func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||
GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||
|
||||
@@ -1014,6 +1012,7 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
||||
totalDiscs := 0
|
||||
for _, item := range itemsModule.PagedList.Items {
|
||||
track := item.Item
|
||||
track.Album.ID = headerModule.Album.ID
|
||||
@@ -1021,8 +1020,14 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
track.Album.Cover = headerModule.Album.Cover
|
||||
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
||||
track.Album.URL = headerModule.Album.URL
|
||||
if track.VolumeNumber > totalDiscs {
|
||||
totalDiscs = track.VolumeNumber
|
||||
}
|
||||
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
||||
}
|
||||
for i := range tracks {
|
||||
tracks[i].TotalDiscs = totalDiscs
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
|
||||
@@ -1165,7 +1170,6 @@ type tidalAPIResult struct {
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// Mobile networks are more unstable, so we use longer timeouts
|
||||
const (
|
||||
tidalAPITimeoutMobile = 25 * time.Second
|
||||
tidalMaxRetries = 2
|
||||
@@ -1211,7 +1215,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
|
||||
continue
|
||||
}
|
||||
|
||||
// 429 rate limit - wait and retry
|
||||
if resp.StatusCode == 429 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
@@ -1233,7 +1236,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
|
||||
continue
|
||||
}
|
||||
|
||||
// Try V2 response format (with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
@@ -1247,7 +1249,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Try V1 response format
|
||||
var v1Responses []struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
@@ -1602,10 +1603,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
return nil
|
||||
}
|
||||
|
||||
// For DASH format, determine correct M4A path
|
||||
// If outputPath already ends with .m4a, use it directly.
|
||||
// If outputPath ends with .flac, convert .flac to .m4a.
|
||||
// Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is.
|
||||
var m4aPath string
|
||||
if strings.HasSuffix(outputPath, ".m4a") {
|
||||
m4aPath = outputPath
|
||||
@@ -1879,8 +1876,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji/symbol-only titles must be matched strictly to avoid false positives
|
||||
// like mapping "🪐" to "Higher Power".
|
||||
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
|
||||
strings.TrimSpace(expectedTitle) != "" &&
|
||||
strings.TrimSpace(foundTitle) != "" {
|
||||
@@ -2111,7 +2106,6 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer Deezer-based SongLink lookup when DeezerID is available.
|
||||
if req.DeezerID != "" {
|
||||
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
@@ -2150,11 +2144,9 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
||||
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
|
||||
}
|
||||
|
||||
// Verify the resolved track matches the request.
|
||||
actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10))
|
||||
if fetchErr != nil {
|
||||
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
|
||||
// Continue without verification — better than failing entirely.
|
||||
} else {
|
||||
providerArtist := actualTrack.Artist.Name
|
||||
if providerArtist == "" && len(actualTrack.Artists) > 0 {
|
||||
@@ -2168,7 +2160,6 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
||||
SkipNameVerification: resolvedViaSongLink,
|
||||
}
|
||||
if !trackMatchesRequest(req, resolved, logPrefix) {
|
||||
// Invalidate the cached ID so future requests don't reuse it.
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetTidal(req.ISRC, 0)
|
||||
}
|
||||
@@ -2178,13 +2169,26 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
||||
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
|
||||
// Use track_number / disc_number from the actual Tidal API data when the
|
||||
// request doesn't carry them (e.g. downloads from search results / popular).
|
||||
resolvedTrackNumber := req.TrackNumber
|
||||
resolvedDiscNumber := req.DiscNumber
|
||||
if actualTrack != nil {
|
||||
if resolvedTrackNumber == 0 && actualTrack.TrackNumber > 0 {
|
||||
resolvedTrackNumber = actualTrack.TrackNumber
|
||||
}
|
||||
if resolvedDiscNumber == 0 && actualTrack.VolumeNumber > 0 {
|
||||
resolvedDiscNumber = actualTrack.VolumeNumber
|
||||
}
|
||||
}
|
||||
|
||||
track := &TidalTrack{
|
||||
ID: trackID,
|
||||
Title: strings.TrimSpace(req.TrackName),
|
||||
ISRC: strings.TrimSpace(req.ISRC),
|
||||
Duration: expectedDurationSec,
|
||||
TrackNumber: req.TrackNumber,
|
||||
VolumeNumber: req.DiscNumber,
|
||||
TrackNumber: resolvedTrackNumber,
|
||||
VolumeNumber: resolvedDiscNumber,
|
||||
}
|
||||
track.Artist.Name = strings.TrimSpace(req.ArtistName)
|
||||
track.Album.Title = strings.TrimSpace(req.AlbumName)
|
||||
@@ -2363,10 +2367,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: actualDiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
@@ -2497,7 +2503,6 @@ func parseTidalURL(input string) (string, string, error) {
|
||||
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
|
||||
// Handle /browse/track/123 format
|
||||
if len(parts) > 0 && parts[0] == "browse" {
|
||||
parts = parts[1:]
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ func writeNormalizedArtistRune(b *strings.Builder, r rune) {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
||||
func normalizeLooseTitle(title string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(title))
|
||||
if trimmed == "" {
|
||||
@@ -48,8 +46,6 @@ func normalizeLooseTitle(title string) string {
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
// normalizeLooseArtistName folds diacritics and common separators so artist
|
||||
// verification is resilient to variants like "Özkent" vs "Ozkent".
|
||||
func normalizeLooseArtistName(name string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(name))
|
||||
if trimmed == "" {
|
||||
@@ -87,9 +83,6 @@ func hasAlphaNumericRunes(value string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeSymbolOnlyTitle keeps symbol/emoji runes while dropping letters,
|
||||
// digits, spaces and punctuation. This is useful for emoji-only titles such as
|
||||
// "🪐", "🌎" etc, so we can compare them strictly and avoid false matches.
|
||||
func normalizeSymbolOnlyTitle(title string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(title))
|
||||
if trimmed == "" {
|
||||
@@ -114,7 +107,6 @@ func normalizeSymbolOnlyTitle(title string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||
type resolvedTrackInfo struct {
|
||||
Title string
|
||||
ArtistName string
|
||||
@@ -123,8 +115,6 @@ type resolvedTrackInfo struct {
|
||||
SkipNameVerification bool
|
||||
}
|
||||
|
||||
// trackMatchesRequest checks whether a resolved track from a provider matches
|
||||
// the original download request. Returns true if the track is a plausible match.
|
||||
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
||||
exactISRCMatch := req.ISRC != "" &&
|
||||
resolved.ISRC != "" &&
|
||||
|
||||
+33
@@ -27,6 +27,37 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
def patch_device_info_plus_vision_selector
|
||||
plugin_file = File.join(
|
||||
__dir__,
|
||||
'.symlinks',
|
||||
'plugins',
|
||||
'device_info_plus',
|
||||
'ios',
|
||||
'device_info_plus',
|
||||
'Sources',
|
||||
'device_info_plus',
|
||||
'FPPDeviceInfoPlusPlugin.m'
|
||||
)
|
||||
return unless File.exist?(plugin_file)
|
||||
|
||||
source = File.read(plugin_file)
|
||||
return if source.include?('FPPDeviceInfoPlusVisionCompat')
|
||||
|
||||
marker = "#import <sys/utsname.h>\n"
|
||||
declaration = <<~OBJC
|
||||
|
||||
// Older Xcode SDKs do not declare this selector yet, but device_info_plus
|
||||
// only calls it behind an availability check.
|
||||
@interface NSProcessInfo (FPPDeviceInfoPlusVisionCompat)
|
||||
- (BOOL)isiOSAppOnVision;
|
||||
@end
|
||||
OBJC
|
||||
|
||||
patched = source.sub(marker, "#{marker}#{declaration}\n")
|
||||
File.write(plugin_file, patched) if patched != source
|
||||
end
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
use_modular_headers!
|
||||
@@ -42,6 +73,8 @@ target 'RunnerTests' do
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
patch_device_info_plus_vision_selector
|
||||
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
|
||||
@@ -89,7 +89,7 @@ import Gobackend // Import Go framework
|
||||
}
|
||||
self.lastDownloadProgressPayload = payload
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.downloadProgressEventSink?(payload)
|
||||
self?.downloadProgressEventSink?(self?.parseJsonPayload(payload))
|
||||
}
|
||||
}
|
||||
downloadProgressTimer = timer
|
||||
@@ -119,7 +119,7 @@ import Gobackend // Import Go framework
|
||||
}
|
||||
self.lastLibraryScanProgressPayload = payload
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.libraryScanProgressEventSink?(payload)
|
||||
self?.libraryScanProgressEventSink?(self?.parseJsonPayload(payload))
|
||||
}
|
||||
}
|
||||
libraryScanProgressTimer = timer
|
||||
@@ -133,6 +133,17 @@ import Gobackend // Import Go framework
|
||||
libraryScanProgressEventSink = nil
|
||||
lastLibraryScanProgressPayload = nil
|
||||
}
|
||||
|
||||
private func parseJsonPayload(_ payload: String) -> Any {
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
return payload
|
||||
}
|
||||
do {
|
||||
return try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
||||
} catch {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
@@ -169,11 +180,11 @@ import Gobackend // Import Go framework
|
||||
|
||||
case "getDownloadProgress":
|
||||
let response = GobackendGetDownloadProgress()
|
||||
return response
|
||||
return parseJsonPayload(response as String? ?? "{}")
|
||||
|
||||
case "getAllDownloadProgress":
|
||||
let response = GobackendGetAllDownloadProgress()
|
||||
return response
|
||||
return parseJsonPayload(response as String? ?? "{}")
|
||||
|
||||
case "initItemProgress":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -296,6 +307,15 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "rewriteSplitArtistTags":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let artist = args["artist"] as! String
|
||||
let albumArtist = args["album_artist"] as! String
|
||||
let response = GobackendRewriteSplitArtistTagsExport(filePath, artist, albumArtist, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "cleanupConnections":
|
||||
GobackendCleanupConnections()
|
||||
return nil
|
||||
@@ -324,7 +344,8 @@ import Gobackend // Import Go framework
|
||||
let spotifyId = args["spotify_id"] as! String
|
||||
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||
let outputPath = args["output_path"] as! String
|
||||
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, &error)
|
||||
let audioFilePath = args["audio_file_path"] as? String ?? ""
|
||||
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, audioFilePath, &error)
|
||||
if let error = error { throw error }
|
||||
return "{\"success\":true}"
|
||||
|
||||
@@ -586,6 +607,13 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendGetProviderPriorityJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setDownloadFallbackExtensionIds":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionIdsJson = args["extension_ids"] as? String ?? ""
|
||||
GobackendSetExtensionFallbackProviderIDsJSON(extensionIdsJson, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "setMetadataProviderPriority":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -923,7 +951,7 @@ import Gobackend // Import Go framework
|
||||
|
||||
case "getLibraryScanProgress":
|
||||
let response = GobackendGetLibraryScanProgressJSON()
|
||||
return response
|
||||
return parseJsonPayload(response as String? ?? "{}")
|
||||
|
||||
case "cancelLibraryScan":
|
||||
GobackendCancelLibraryScanJSON()
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '4.1.3';
|
||||
static const String buildNumber = '120';
|
||||
static const String version = '4.2.2';
|
||||
static const String buildNumber = '123';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
/// Shows "Internal" in debug builds, actual version in release.
|
||||
|
||||
@@ -256,6 +256,18 @@ abstract class AppLocalizations {
|
||||
/// **'Filename Format'**
|
||||
String get downloadFilenameFormat;
|
||||
|
||||
/// Setting for output filename pattern for singles/EPs
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Single Filename Format'**
|
||||
String get downloadSingleFilenameFormat;
|
||||
|
||||
/// Subtitle description for single filename format setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filename pattern for singles and EPs. Uses the same tags as the album format.'**
|
||||
String get downloadSingleFilenameFormatDescription;
|
||||
|
||||
/// Title of the folder organization picker bottom sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -400,6 +412,24 @@ abstract class AppLocalizations {
|
||||
/// **'Download highest resolution cover art'**
|
||||
String get optionsMaxQualityCoverSubtitle;
|
||||
|
||||
/// Title for ReplayGain setting toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ReplayGain'**
|
||||
String get optionsReplayGain;
|
||||
|
||||
/// Subtitle when ReplayGain is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan loudness and embed ReplayGain tags (EBU R128)'**
|
||||
String get optionsReplayGainSubtitleOn;
|
||||
|
||||
/// Subtitle when ReplayGain is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disabled: no loudness normalization tags'**
|
||||
String get optionsReplayGainSubtitleOff;
|
||||
|
||||
/// Setting title for how artist metadata is written into files
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1708,6 +1738,24 @@ abstract class AppLocalizations {
|
||||
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
|
||||
String get providerPriorityInfo;
|
||||
|
||||
/// Section title for choosing which download extensions can be used as fallback providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Extension Fallback'**
|
||||
String get providerPriorityFallbackExtensionsTitle;
|
||||
|
||||
/// Section description for extension fallback selection
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.'**
|
||||
String get providerPriorityFallbackExtensionsDescription;
|
||||
|
||||
/// Hint below the extension fallback selection list
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Only enabled extensions with download-provider capability are listed here.'**
|
||||
String get providerPriorityFallbackExtensionsHint;
|
||||
|
||||
/// Label for built-in providers (Tidal/Qobuz)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2218,6 +2266,18 @@ abstract class AppLocalizations {
|
||||
/// **'Lyrics not available for this track'**
|
||||
String get trackLyricsNotAvailable;
|
||||
|
||||
/// Message when no embedded lyrics in audio file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No lyrics found in this file'**
|
||||
String get trackLyricsNotInFile;
|
||||
|
||||
/// Action - fetch lyrics from online providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetch from Online'**
|
||||
String get trackFetchOnlineLyrics;
|
||||
|
||||
/// Message when lyrics request times out
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2602,6 +2662,18 @@ abstract class AppLocalizations {
|
||||
/// **'Set download service order'**
|
||||
String get extensionsDownloadPrioritySubtitle;
|
||||
|
||||
/// Setting and page title for choosing which download extensions can be used during fallback
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fallback Extensions'**
|
||||
String get extensionsFallbackTitle;
|
||||
|
||||
/// Subtitle for download fallback extensions menu
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which installed download extensions can be used as fallback'**
|
||||
String get extensionsFallbackSubtitle;
|
||||
|
||||
/// Empty state - no download providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4048,6 +4120,54 @@ abstract class AppLocalizations {
|
||||
/// **'Search metadata online and embed into file'**
|
||||
String get trackReEnrichOnlineSubtitle;
|
||||
|
||||
/// Section title for field selection in re-enrich dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fields to update'**
|
||||
String get trackReEnrichFieldsTitle;
|
||||
|
||||
/// Checkbox label for cover art field in re-enrich
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cover Art'**
|
||||
String get trackReEnrichFieldCover;
|
||||
|
||||
/// Checkbox label for lyrics field in re-enrich
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lyrics'**
|
||||
String get trackReEnrichFieldLyrics;
|
||||
|
||||
/// Checkbox label for basic tags in re-enrich (title/artist are never overwritten)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album, Album Artist'**
|
||||
String get trackReEnrichFieldBasicTags;
|
||||
|
||||
/// Checkbox label for track info in re-enrich
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Track & Disc Number'**
|
||||
String get trackReEnrichFieldTrackInfo;
|
||||
|
||||
/// Checkbox label for release info in re-enrich
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Date & ISRC'**
|
||||
String get trackReEnrichFieldReleaseInfo;
|
||||
|
||||
/// Checkbox label for extra metadata in re-enrich
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Genre, Label, Copyright'**
|
||||
String get trackReEnrichFieldExtra;
|
||||
|
||||
/// Select all fields checkbox in re-enrich
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select All'**
|
||||
String get trackReEnrichSelectAll;
|
||||
|
||||
/// Menu action - edit embedded metadata
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4647,12 +4767,6 @@ abstract class AppLocalizations {
|
||||
/// **'You have unsaved changes that will be lost.'**
|
||||
String get lyricsProvidersDiscardContent;
|
||||
|
||||
/// Description for Spotify Lyrics API provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spotify-sourced synced lyrics via community API'**
|
||||
String get lyricsProviderSpotifyApiDesc;
|
||||
|
||||
/// Description for LRCLIB provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -76,6 +76,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Dateinamenformat';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Ordnerstruktur';
|
||||
|
||||
@@ -158,6 +165,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Cover in höchster Auflösung herunterladen';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -922,6 +940,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Integriert';
|
||||
|
||||
@@ -1201,6 +1230,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get trackLyricsNotAvailable =>
|
||||
'Lyrics sind für diesen Titel nicht verfügbar';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout =>
|
||||
'Anfrage Timeout. Versuche es später erneut.';
|
||||
@@ -1414,6 +1449,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get extensionsDownloadPrioritySubtitle =>
|
||||
'Download-Service-Reihenfolge festlegen';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'Keine Erweiterungen mit Download-Provider';
|
||||
@@ -2290,6 +2332,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Metadaten online suchen und in Datei einbinden';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Metadaten bearbeiten';
|
||||
|
||||
@@ -2706,10 +2772,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Folder Organization';
|
||||
|
||||
@@ -154,6 +161,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -908,6 +926,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1182,6 +1211,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||
|
||||
@@ -1391,6 +1426,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2260,6 +2302,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@@ -2674,10 +2740,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Folder Organization';
|
||||
|
||||
@@ -154,6 +161,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -908,6 +926,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1182,6 +1211,12 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||
|
||||
@@ -1391,6 +1426,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2260,6 +2302,30 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@@ -2674,10 +2740,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Nom du fichier';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Organisation du dossier';
|
||||
|
||||
@@ -156,6 +163,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -910,6 +928,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1184,6 +1213,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||
|
||||
@@ -1393,6 +1428,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2262,6 +2304,30 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@@ -2675,10 +2741,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Folder Organization';
|
||||
|
||||
@@ -154,6 +161,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -908,6 +926,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1182,6 +1211,12 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||
|
||||
@@ -1391,6 +1426,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2260,6 +2302,30 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@@ -2673,10 +2739,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -76,6 +76,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Format Nama File';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Organisasi Folder';
|
||||
|
||||
@@ -158,6 +165,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Unduh cover art resolusi tertinggi';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -912,6 +930,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Fallback Ekstensi';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Bawaan';
|
||||
|
||||
@@ -1189,6 +1218,12 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lirik tidak tersedia untuk lagu ini';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'Permintaan timeout. Coba lagi nanti.';
|
||||
|
||||
@@ -1399,6 +1434,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get extensionsDownloadPrioritySubtitle =>
|
||||
'Atur urutan layanan unduhan';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'Tidak ada ekstensi dengan provider unduhan';
|
||||
@@ -2270,6 +2312,30 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@@ -2684,10 +2750,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'ファイル名の形式';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'フォルダ構成';
|
||||
|
||||
@@ -152,6 +159,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -902,6 +920,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => '内蔵';
|
||||
|
||||
@@ -1176,6 +1205,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'このトラックの歌詞は利用できません';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'リクエストがタイムアウトしました。後ほどお試しください。';
|
||||
|
||||
@@ -1385,6 +1420,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません';
|
||||
|
||||
@@ -2247,6 +2289,30 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'メタデータを編集';
|
||||
|
||||
@@ -2660,10 +2726,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -74,6 +74,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => '파일 이름 형식';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => '폴더 분류 형식';
|
||||
|
||||
@@ -148,6 +155,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -890,6 +908,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1162,6 +1191,12 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||
|
||||
@@ -1371,6 +1406,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2240,6 +2282,30 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@@ -2653,10 +2719,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Folder Organization';
|
||||
|
||||
@@ -154,6 +161,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -908,6 +926,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1182,6 +1211,12 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||
|
||||
@@ -1391,6 +1426,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2260,6 +2302,30 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@@ -2673,10 +2739,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Folder Organization';
|
||||
|
||||
@@ -154,6 +161,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -908,6 +926,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1182,6 +1211,12 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||
|
||||
@@ -1391,6 +1426,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2260,6 +2302,30 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@@ -2674,10 +2740,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -76,6 +76,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Формат имени файла';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Организация папок';
|
||||
|
||||
@@ -159,6 +166,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Скачивать обложку в макс. разрешении';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -922,6 +940,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Встроенные';
|
||||
|
||||
@@ -1202,6 +1231,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackLyricsNotAvailable =>
|
||||
'Текст песни недоступен для этого трека';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout =>
|
||||
'Время ожидания запроса истекло. Повторите попытку позже.';
|
||||
@@ -1415,6 +1450,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get extensionsDownloadPrioritySubtitle =>
|
||||
'Установка порядок сервисов скачивания';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'Нет расширений с провайдером загрузки';
|
||||
@@ -2312,6 +2354,30 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Поиск в сети метаданных и встраивание в файл';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Редактировать метаданные';
|
||||
|
||||
@@ -2733,10 +2799,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -76,6 +76,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Dosya adı formatı';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Dosya Organizasyonu';
|
||||
|
||||
@@ -157,6 +164,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'En yüksek kalitedeki albüm kapaklarını indir';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -913,6 +931,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'Eğer bir şarkı ilk hizmette mevcut değilse uygulama otomatik olarak bir sonrakini deneyecektir.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Dahili';
|
||||
|
||||
@@ -1188,6 +1217,12 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||
|
||||
@@ -1397,6 +1432,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2266,6 +2308,30 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@@ -2679,10 +2745,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Folder Organization';
|
||||
|
||||
@@ -154,6 +161,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -908,6 +926,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1182,6 +1211,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||
|
||||
@override
|
||||
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||
|
||||
@@ -1391,6 +1426,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2260,6 +2302,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSelectAll => 'Select All';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@@ -2674,10 +2740,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
|
||||
+80
-4
@@ -89,6 +89,14 @@
|
||||
"@downloadFilenameFormat": {
|
||||
"description": "Setting for output filename pattern"
|
||||
},
|
||||
"downloadSingleFilenameFormat": "Single Filename Format",
|
||||
"@downloadSingleFilenameFormat": {
|
||||
"description": "Setting for output filename pattern for singles/EPs"
|
||||
},
|
||||
"downloadSingleFilenameFormatDescription": "Filename pattern for singles and EPs. Uses the same tags as the album format.",
|
||||
"@downloadSingleFilenameFormatDescription": {
|
||||
"description": "Subtitle description for single filename format setting"
|
||||
},
|
||||
"downloadFolderOrganization": "Folder Organization",
|
||||
"@downloadFolderOrganization": {
|
||||
"description": "Setting for folder structure"
|
||||
@@ -190,6 +198,18 @@
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"description": "Subtitle for max quality cover"
|
||||
},
|
||||
"optionsReplayGain": "ReplayGain",
|
||||
"@optionsReplayGain": {
|
||||
"description": "Title for ReplayGain setting toggle"
|
||||
},
|
||||
"optionsReplayGainSubtitleOn": "Scan loudness and embed ReplayGain tags (EBU R128)",
|
||||
"@optionsReplayGainSubtitleOn": {
|
||||
"description": "Subtitle when ReplayGain is enabled"
|
||||
},
|
||||
"optionsReplayGainSubtitleOff": "Disabled: no loudness normalization tags",
|
||||
"@optionsReplayGainSubtitleOff": {
|
||||
"description": "Subtitle when ReplayGain is disabled"
|
||||
},
|
||||
"optionsArtistTagMode": "Artist Tag Mode",
|
||||
"@optionsArtistTagMode": {
|
||||
"description": "Setting title for how artist metadata is written into files"
|
||||
@@ -1183,6 +1203,18 @@
|
||||
"@providerPriorityInfo": {
|
||||
"description": "Info tip about fallback behavior"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsTitle": "Extension Fallback",
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsHint": "Only enabled extensions with download-provider capability are listed here.",
|
||||
"@providerPriorityFallbackExtensionsHint": {
|
||||
"description": "Hint below the extension fallback selection list"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
@@ -1543,6 +1575,14 @@
|
||||
"@trackLyricsNotAvailable": {
|
||||
"description": "Message when lyrics not found"
|
||||
},
|
||||
"trackLyricsNotInFile": "No lyrics found in this file",
|
||||
"@trackLyricsNotInFile": {
|
||||
"description": "Message when no embedded lyrics in audio file"
|
||||
},
|
||||
"trackFetchOnlineLyrics": "Fetch from Online",
|
||||
"@trackFetchOnlineLyrics": {
|
||||
"description": "Action - fetch lyrics from online providers"
|
||||
},
|
||||
"trackLyricsTimeout": "Request timed out. Try again later.",
|
||||
"@trackLyricsTimeout": {
|
||||
"description": "Message when lyrics request times out"
|
||||
@@ -1829,6 +1869,14 @@
|
||||
"@extensionsDownloadPrioritySubtitle": {
|
||||
"description": "Subtitle for download priority"
|
||||
},
|
||||
"extensionsFallbackTitle": "Fallback Extensions",
|
||||
"@extensionsFallbackTitle": {
|
||||
"description": "Setting and page title for choosing which download extensions can be used during fallback"
|
||||
},
|
||||
"extensionsFallbackSubtitle": "Choose which installed download extensions can be used as fallback",
|
||||
"@extensionsFallbackSubtitle": {
|
||||
"description": "Subtitle for download fallback extensions menu"
|
||||
},
|
||||
"extensionsNoDownloadProvider": "No extensions with download provider",
|
||||
"@extensionsNoDownloadProvider": {
|
||||
"description": "Empty state - no download providers"
|
||||
@@ -2954,6 +3002,38 @@
|
||||
"@trackReEnrichOnlineSubtitle": {
|
||||
"description": "Subtitle for re-enrich metadata action for local items"
|
||||
},
|
||||
"trackReEnrichFieldsTitle": "Fields to update",
|
||||
"@trackReEnrichFieldsTitle": {
|
||||
"description": "Section title for field selection in re-enrich dialog"
|
||||
},
|
||||
"trackReEnrichFieldCover": "Cover Art",
|
||||
"@trackReEnrichFieldCover": {
|
||||
"description": "Checkbox label for cover art field in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldLyrics": "Lyrics",
|
||||
"@trackReEnrichFieldLyrics": {
|
||||
"description": "Checkbox label for lyrics field in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldBasicTags": "Album, Album Artist",
|
||||
"@trackReEnrichFieldBasicTags": {
|
||||
"description": "Checkbox label for basic tags in re-enrich (title/artist are never overwritten)"
|
||||
},
|
||||
"trackReEnrichFieldTrackInfo": "Track & Disc Number",
|
||||
"@trackReEnrichFieldTrackInfo": {
|
||||
"description": "Checkbox label for track info in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldReleaseInfo": "Date & ISRC",
|
||||
"@trackReEnrichFieldReleaseInfo": {
|
||||
"description": "Checkbox label for release info in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldExtra": "Genre, Label, Copyright",
|
||||
"@trackReEnrichFieldExtra": {
|
||||
"description": "Checkbox label for extra metadata in re-enrich"
|
||||
},
|
||||
"trackReEnrichSelectAll": "Select All",
|
||||
"@trackReEnrichSelectAll": {
|
||||
"description": "Select all fields checkbox in re-enrich"
|
||||
},
|
||||
"trackEditMetadata": "Edit Metadata",
|
||||
"@trackEditMetadata": {
|
||||
"description": "Menu action - edit embedded metadata"
|
||||
@@ -3551,10 +3631,6 @@
|
||||
"@lyricsProvidersDiscardContent": {
|
||||
"description": "Body text of the discard-changes dialog on lyrics provider page"
|
||||
},
|
||||
"lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API",
|
||||
"@lyricsProviderSpotifyApiDesc": {
|
||||
"description": "Description for Spotify Lyrics API provider"
|
||||
},
|
||||
"lyricsProviderLrclibDesc": "Open-source synced lyrics database",
|
||||
"@lyricsProviderLrclibDesc": {
|
||||
"description": "Description for LRCLIB provider"
|
||||
|
||||
@@ -1119,6 +1119,18 @@
|
||||
"@providerPriorityInfo": {
|
||||
"description": "Info tip about fallback behavior"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsTitle": "Fallback Ekstensi",
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsHint": "Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.",
|
||||
"@providerPriorityFallbackExtensionsHint": {
|
||||
"description": "Hint below the extension fallback selection list"
|
||||
},
|
||||
"providerBuiltIn": "Bawaan",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
@@ -1713,6 +1725,14 @@
|
||||
"@extensionsDownloadPrioritySubtitle": {
|
||||
"description": "Subtitle for download priority"
|
||||
},
|
||||
"extensionsFallbackTitle": "Fallback Extensions",
|
||||
"@extensionsFallbackTitle": {
|
||||
"description": "Setting and page title for choosing which download extensions can be used during fallback"
|
||||
},
|
||||
"extensionsFallbackSubtitle": "Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback",
|
||||
"@extensionsFallbackSubtitle": {
|
||||
"description": "Subtitle for download fallback extensions menu"
|
||||
},
|
||||
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
|
||||
"@extensionsNoDownloadProvider": {
|
||||
"description": "Empty state - no download providers"
|
||||
|
||||
@@ -82,7 +82,6 @@ class _RuntimeProfile {
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget to eagerly initialize providers that need to load data on startup
|
||||
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
final Widget child;
|
||||
@@ -170,10 +169,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
const Duration(milliseconds: 1600),
|
||||
() {
|
||||
ref.read(localLibraryProvider);
|
||||
// Trigger auto-scan after initial warmup on first app launch.
|
||||
if (!_autoScanTriggeredOnLaunch) {
|
||||
_autoScanTriggeredOnLaunch = true;
|
||||
// Give the provider a moment to load existing data before scanning.
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) _maybeAutoScanLocalLibrary();
|
||||
});
|
||||
@@ -182,8 +179,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks whether an automatic incremental scan should be triggered based on
|
||||
/// the user's auto-scan preference and the time since the last scan.
|
||||
Future<void> _maybeAutoScanLocalLibrary() async {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -204,7 +199,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
|
||||
switch (settings.localLibraryAutoScan) {
|
||||
case 'on_open':
|
||||
// Cooldown of 10 minutes to prevent rapid re-scans.
|
||||
if (elapsed.inMinutes < 10) return;
|
||||
break;
|
||||
case 'daily':
|
||||
|
||||
+15
-18
@@ -16,6 +16,7 @@ class AppSettings {
|
||||
final String
|
||||
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
|
||||
final bool embedLyrics;
|
||||
final bool embedReplayGain; // Calculate and embed ReplayGain tags
|
||||
final bool maxQualityCover;
|
||||
final bool isFirstLaunch;
|
||||
final int concurrentDownloads;
|
||||
@@ -30,15 +31,13 @@ class AppSettings {
|
||||
final String historyViewMode;
|
||||
final String historyFilterMode;
|
||||
final bool askQualityBeforeDownload;
|
||||
final String spotifyClientId;
|
||||
final String spotifyClientSecret;
|
||||
final bool useCustomSpotifyCredentials;
|
||||
final String metadataSource;
|
||||
final bool enableLogging;
|
||||
final bool useExtensionProviders;
|
||||
final List<String>? downloadFallbackExtensionIds;
|
||||
final String? searchProvider;
|
||||
final String? homeFeedProvider;
|
||||
final bool separateSingles;
|
||||
final String singleFilenameFormat;
|
||||
final String albumFolderStructure;
|
||||
final bool showExtensionStore;
|
||||
final String locale;
|
||||
@@ -93,6 +92,7 @@ class AppSettings {
|
||||
this.embedMetadata = true,
|
||||
this.artistTagMode = artistTagModeJoined,
|
||||
this.embedLyrics = true,
|
||||
this.embedReplayGain = false,
|
||||
this.maxQualityCover = true,
|
||||
this.isFirstLaunch = true,
|
||||
this.concurrentDownloads = 1,
|
||||
@@ -107,15 +107,13 @@ class AppSettings {
|
||||
this.historyViewMode = 'grid',
|
||||
this.historyFilterMode = 'all',
|
||||
this.askQualityBeforeDownload = true,
|
||||
this.spotifyClientId = '',
|
||||
this.spotifyClientSecret = '',
|
||||
this.useCustomSpotifyCredentials = false,
|
||||
this.metadataSource = 'deezer',
|
||||
this.enableLogging = false,
|
||||
this.useExtensionProviders = true,
|
||||
this.downloadFallbackExtensionIds,
|
||||
this.searchProvider,
|
||||
this.homeFeedProvider,
|
||||
this.separateSingles = false,
|
||||
this.singleFilenameFormat = '{title} - {artist}',
|
||||
this.albumFolderStructure = 'artist_album',
|
||||
this.showExtensionStore = true,
|
||||
this.locale = 'system',
|
||||
@@ -134,7 +132,6 @@ class AppSettings {
|
||||
this.hasCompletedTutorial = false,
|
||||
this.lyricsProviders = const [
|
||||
'lrclib',
|
||||
'spotify_api',
|
||||
'musixmatch',
|
||||
'netease',
|
||||
'apple_music',
|
||||
@@ -158,6 +155,7 @@ class AppSettings {
|
||||
bool? embedMetadata,
|
||||
String? artistTagMode,
|
||||
bool? embedLyrics,
|
||||
bool? embedReplayGain,
|
||||
bool? maxQualityCover,
|
||||
bool? isFirstLaunch,
|
||||
int? concurrentDownloads,
|
||||
@@ -172,17 +170,16 @@ class AppSettings {
|
||||
String? historyViewMode,
|
||||
String? historyFilterMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
String? spotifyClientId,
|
||||
String? spotifyClientSecret,
|
||||
bool? useCustomSpotifyCredentials,
|
||||
String? metadataSource,
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
List<String>? downloadFallbackExtensionIds,
|
||||
bool clearDownloadFallbackExtensionIds = false,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false,
|
||||
String? homeFeedProvider,
|
||||
bool clearHomeFeedProvider = false,
|
||||
bool? separateSingles,
|
||||
String? singleFilenameFormat,
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
String? locale,
|
||||
@@ -217,6 +214,7 @@ class AppSettings {
|
||||
embedMetadata: embedMetadata ?? this.embedMetadata,
|
||||
artistTagMode: artistTagMode ?? this.artistTagMode,
|
||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||
embedReplayGain: embedReplayGain ?? this.embedReplayGain,
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||
@@ -235,14 +233,12 @@ class AppSettings {
|
||||
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||
askQualityBeforeDownload:
|
||||
askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||
useCustomSpotifyCredentials:
|
||||
useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||
metadataSource: metadataSource ?? this.metadataSource,
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
useExtensionProviders:
|
||||
useExtensionProviders ?? this.useExtensionProviders,
|
||||
downloadFallbackExtensionIds: clearDownloadFallbackExtensionIds
|
||||
? null
|
||||
: (downloadFallbackExtensionIds ?? this.downloadFallbackExtensionIds),
|
||||
searchProvider: clearSearchProvider
|
||||
? null
|
||||
: (searchProvider ?? this.searchProvider),
|
||||
@@ -250,6 +246,7 @@ class AppSettings {
|
||||
? null
|
||||
: (homeFeedProvider ?? this.homeFeedProvider),
|
||||
separateSingles: separateSingles ?? this.separateSingles,
|
||||
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
locale: locale ?? this.locale,
|
||||
|
||||
+12
-18
@@ -15,8 +15,9 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
|
||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||
embedMetadata: json['embedMetadata'] as bool? ?? true,
|
||||
artistTagMode: json['artistTagMode'] as String? ?? 'joined',
|
||||
artistTagMode: json['artistTagMode'] as String? ?? artistTagModeJoined,
|
||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
|
||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||
@@ -32,16 +33,17 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||
useCustomSpotifyCredentials:
|
||||
json['useCustomSpotifyCredentials'] as bool? ?? false,
|
||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
downloadFallbackExtensionIds:
|
||||
(json['downloadFallbackExtensionIds'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList(),
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
homeFeedProvider: json['homeFeedProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
singleFilenameFormat:
|
||||
json['singleFilenameFormat'] as String? ?? '{title} - {artist}',
|
||||
albumFolderStructure:
|
||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
@@ -65,14 +67,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
(json['lyricsProviders'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
const [
|
||||
'lrclib',
|
||||
'spotify_api',
|
||||
'musixmatch',
|
||||
'netease',
|
||||
'apple_music',
|
||||
'qqmusic',
|
||||
],
|
||||
const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'],
|
||||
lyricsIncludeTranslationNetease:
|
||||
json['lyricsIncludeTranslationNetease'] as bool? ?? false,
|
||||
lyricsIncludeRomanizationNetease:
|
||||
@@ -96,6 +91,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'embedMetadata': instance.embedMetadata,
|
||||
'artistTagMode': instance.artistTagMode,
|
||||
'embedLyrics': instance.embedLyrics,
|
||||
'embedReplayGain': instance.embedReplayGain,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'concurrentDownloads': instance.concurrentDownloads,
|
||||
@@ -111,15 +107,13 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'historyFilterMode': instance.historyFilterMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
'spotifyClientId': instance.spotifyClientId,
|
||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||
'metadataSource': instance.metadataSource,
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'homeFeedProvider': instance.homeFeedProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'singleFilenameFormat': instance.singleFilenameFormat,
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
'locale': instance.locale,
|
||||
|
||||
+10
-8
@@ -16,12 +16,14 @@ class Track {
|
||||
final int duration;
|
||||
final int? trackNumber;
|
||||
final int? discNumber;
|
||||
final int? totalDiscs;
|
||||
final String? releaseDate;
|
||||
final String? deezerId;
|
||||
final ServiceAvailability? availability;
|
||||
final String? source;
|
||||
final String? albumType;
|
||||
final int? totalTracks;
|
||||
final String? composer;
|
||||
final String? itemType;
|
||||
|
||||
const Track({
|
||||
@@ -37,38 +39,38 @@ class Track {
|
||||
required this.duration,
|
||||
this.trackNumber,
|
||||
this.discNumber,
|
||||
this.totalDiscs,
|
||||
this.releaseDate,
|
||||
this.deezerId,
|
||||
this.availability,
|
||||
this.source,
|
||||
this.albumType,
|
||||
this.totalTracks,
|
||||
this.composer,
|
||||
this.itemType,
|
||||
});
|
||||
|
||||
bool get isSingle {
|
||||
switch (albumType?.toLowerCase()) {
|
||||
case 'single':
|
||||
return true;
|
||||
case 'ep':
|
||||
final count = totalTracks;
|
||||
return count == null || count <= 1;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool get isAlbumItem => itemType == 'album';
|
||||
|
||||
|
||||
bool get isPlaylistItem => itemType == 'playlist';
|
||||
|
||||
|
||||
bool get isArtistItem => itemType == 'artist';
|
||||
|
||||
|
||||
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||
|
||||
|
||||
bool get isFromExtension => source != null && source!.isNotEmpty;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
duration: (json['duration'] as num).toInt(),
|
||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||
totalDiscs: (json['totalDiscs'] as num?)?.toInt(),
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
deezerId: json['deezerId'] as String?,
|
||||
availability: json['availability'] == null
|
||||
@@ -29,6 +30,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
source: json['source'] as String?,
|
||||
albumType: json['albumType'] as String?,
|
||||
totalTracks: (json['totalTracks'] as num?)?.toInt(),
|
||||
composer: json['composer'] as String?,
|
||||
itemType: json['itemType'] as String?,
|
||||
);
|
||||
|
||||
@@ -45,12 +47,14 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'duration': instance.duration,
|
||||
'trackNumber': instance.trackNumber,
|
||||
'discNumber': instance.discNumber,
|
||||
'totalDiscs': instance.totalDiscs,
|
||||
'releaseDate': instance.releaseDate,
|
||||
'deezerId': instance.deezerId,
|
||||
'availability': instance.availability,
|
||||
'source': instance.source,
|
||||
'albumType': instance.albumType,
|
||||
'totalTracks': instance.totalTracks,
|
||||
'composer': instance.composer,
|
||||
'itemType': instance.itemType,
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -19,6 +20,7 @@ class ExploreItem {
|
||||
final String? providerId;
|
||||
final String? albumId;
|
||||
final String? albumName;
|
||||
final String? releaseDate;
|
||||
final int durationMs;
|
||||
|
||||
const ExploreItem({
|
||||
@@ -32,6 +34,7 @@ class ExploreItem {
|
||||
this.providerId,
|
||||
this.albumId,
|
||||
this.albumName,
|
||||
this.releaseDate,
|
||||
this.durationMs = 0,
|
||||
});
|
||||
|
||||
@@ -47,6 +50,7 @@ class ExploreItem {
|
||||
providerId: json['provider_id'] as String?,
|
||||
albumId: json['album_id'] as String?,
|
||||
albumName: json['album_name'] as String?,
|
||||
releaseDate: json['release_date']?.toString(),
|
||||
durationMs: json['duration_ms'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
@@ -62,6 +66,7 @@ class ExploreItem {
|
||||
'provider_id': providerId,
|
||||
'album_id': albumId,
|
||||
'album_name': albumName,
|
||||
'release_date': releaseDate,
|
||||
'duration_ms': durationMs,
|
||||
};
|
||||
}
|
||||
@@ -158,6 +163,52 @@ bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
|
||||
return true;
|
||||
}
|
||||
|
||||
List<Map<String, Object?>> _normalizeExploreSectionsPayload(
|
||||
dynamic rawSections,
|
||||
) {
|
||||
if (rawSections is! List) return const [];
|
||||
final sections = <Map<String, Object?>>[];
|
||||
for (final rawSection in rawSections) {
|
||||
if (rawSection is! Map) continue;
|
||||
final section = Map<Object?, Object?>.from(rawSection);
|
||||
final rawItems = section['items'];
|
||||
final items = <Map<String, Object?>>[];
|
||||
if (rawItems is List) {
|
||||
for (final rawItem in rawItems) {
|
||||
if (rawItem is! Map) continue;
|
||||
items.add(Map<String, Object?>.from(rawItem));
|
||||
}
|
||||
}
|
||||
sections.add({
|
||||
'uri': section['uri']?.toString() ?? '',
|
||||
'title': section['title']?.toString() ?? '',
|
||||
'items': items,
|
||||
});
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
List<Map<String, Object?>> _decodeExploreCacheSections(String rawCache) {
|
||||
final decoded = jsonDecode(rawCache);
|
||||
if (decoded is! Map) return const [];
|
||||
return _normalizeExploreSectionsPayload(decoded['sections']);
|
||||
}
|
||||
|
||||
String _encodeExploreCacheSections(List<Map<String, Object?>> sections) {
|
||||
return jsonEncode({'sections': sections});
|
||||
}
|
||||
|
||||
List<ExploreSection> _buildExploreSectionsFromNormalizedPayload(
|
||||
List<Map<String, Object?>> normalizedSections,
|
||||
) {
|
||||
return normalizedSections
|
||||
.map(
|
||||
(section) =>
|
||||
ExploreSection.fromJson(Map<String, dynamic>.from(section)),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
class ExploreNotifier extends Notifier<ExploreState> {
|
||||
static const _cacheKey = 'explore_home_feed_cache';
|
||||
static const _cacheTsKey = 'explore_home_feed_ts';
|
||||
@@ -175,11 +226,13 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
final cachedTs = prefs.getInt(_cacheTsKey);
|
||||
if (cached == null || cached.isEmpty) return;
|
||||
|
||||
final data = jsonDecode(cached) as Map<String, dynamic>;
|
||||
final sectionsData = data['sections'] as List<dynamic>? ?? [];
|
||||
final sections = sectionsData
|
||||
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
final normalizedSections = await compute(
|
||||
_decodeExploreCacheSections,
|
||||
cached,
|
||||
);
|
||||
final sections = _buildExploreSectionsFromNormalizedPayload(
|
||||
normalizedSections,
|
||||
);
|
||||
|
||||
if (sections.isEmpty) return;
|
||||
|
||||
@@ -198,13 +251,18 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||
Future<void> _saveToCache(
|
||||
List<Map<String, Object?>> normalizedSections,
|
||||
) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final data = {'sections': sections.map((s) => s.toJson()).toList()};
|
||||
await prefs.setString(_cacheKey, jsonEncode(data));
|
||||
final encoded = await compute(
|
||||
_encodeExploreCacheSections,
|
||||
normalizedSections,
|
||||
);
|
||||
await prefs.setString(_cacheKey, encoded);
|
||||
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
|
||||
_log.d('Saved ${sections.length} explore sections to cache');
|
||||
_log.d('Saved ${normalizedSections.length} explore sections to cache');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save explore cache: $e');
|
||||
}
|
||||
@@ -286,10 +344,13 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
|
||||
final greeting = result['greeting'] as String?;
|
||||
final sectionsData = result['sections'] as List<dynamic>? ?? [];
|
||||
|
||||
final sections = sectionsData
|
||||
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
final normalizedSections = await compute(
|
||||
_normalizeExploreSectionsPayload,
|
||||
sectionsData,
|
||||
);
|
||||
final sections = _buildExploreSectionsFromNormalizedPayload(
|
||||
normalizedSections,
|
||||
);
|
||||
|
||||
_log.i('Fetched ${sections.length} sections');
|
||||
|
||||
@@ -310,7 +371,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
lastFetched: DateTime.now(),
|
||||
);
|
||||
|
||||
_saveToCache(sections);
|
||||
_saveToCache(normalizedSections);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
|
||||
@@ -33,6 +33,7 @@ class Extension {
|
||||
final bool hasDownloadProvider;
|
||||
final bool hasLyricsProvider;
|
||||
final bool skipMetadataEnrichment;
|
||||
final bool skipLyrics;
|
||||
final SearchBehavior? searchBehavior;
|
||||
final URLHandler? urlHandler;
|
||||
final TrackMatching? trackMatching;
|
||||
@@ -57,6 +58,7 @@ class Extension {
|
||||
this.hasDownloadProvider = false,
|
||||
this.hasLyricsProvider = false,
|
||||
this.skipMetadataEnrichment = false,
|
||||
this.skipLyrics = false,
|
||||
this.searchBehavior,
|
||||
this.urlHandler,
|
||||
this.trackMatching,
|
||||
@@ -94,6 +96,7 @@ class Extension {
|
||||
hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false,
|
||||
skipMetadataEnrichment:
|
||||
json['skip_metadata_enrichment'] as bool? ?? false,
|
||||
skipLyrics: json['skip_lyrics'] as bool? ?? false,
|
||||
searchBehavior: json['search_behavior'] != null
|
||||
? SearchBehavior.fromJson(
|
||||
json['search_behavior'] as Map<String, dynamic>,
|
||||
@@ -134,6 +137,7 @@ class Extension {
|
||||
bool? hasDownloadProvider,
|
||||
bool? hasLyricsProvider,
|
||||
bool? skipMetadataEnrichment,
|
||||
bool? skipLyrics,
|
||||
SearchBehavior? searchBehavior,
|
||||
URLHandler? urlHandler,
|
||||
TrackMatching? trackMatching,
|
||||
@@ -159,6 +163,7 @@ class Extension {
|
||||
hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider,
|
||||
skipMetadataEnrichment:
|
||||
skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||
skipLyrics: skipLyrics ?? this.skipLyrics,
|
||||
searchBehavior: searchBehavior ?? this.searchBehavior,
|
||||
urlHandler: urlHandler ?? this.urlHandler,
|
||||
trackMatching: trackMatching ?? this.trackMatching,
|
||||
@@ -662,9 +667,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
if (settings.searchProvider == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
_log.d(
|
||||
'Cleared search provider and reset to Deezer because extension $extensionId was disabled',
|
||||
'Cleared search provider because extension $extensionId was disabled',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -816,7 +820,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
for (final provider in const ['tidal', 'qobuz', 'deezer']) {
|
||||
for (final provider in const ['tidal', 'qobuz']) {
|
||||
if (!result.contains(provider)) {
|
||||
result.add(provider);
|
||||
}
|
||||
@@ -892,7 +896,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> getAllDownloadProviders() {
|
||||
final providers = ['tidal', 'qobuz', 'deezer'];
|
||||
final providers = ['tidal', 'qobuz'];
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasDownloadProvider) {
|
||||
providers.add(ext.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -127,6 +128,54 @@ class UserPlaylistCollection {
|
||||
}
|
||||
}
|
||||
|
||||
class PlaylistPickerSummary {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? coverImagePath;
|
||||
final String? previewCover;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final int trackCount;
|
||||
final bool containsAllRequestedTracks;
|
||||
|
||||
const PlaylistPickerSummary({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.coverImagePath,
|
||||
this.previewCover,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.trackCount,
|
||||
required this.containsAllRequestedTracks,
|
||||
});
|
||||
}
|
||||
|
||||
class PlaylistPickerSummaryRequest {
|
||||
final List<String> trackKeys;
|
||||
|
||||
PlaylistPickerSummaryRequest._(this.trackKeys);
|
||||
|
||||
factory PlaylistPickerSummaryRequest.fromTracks(Iterable<Track> tracks) {
|
||||
final keys =
|
||||
tracks
|
||||
.map(trackCollectionKey)
|
||||
.where((key) => key.trim().isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false)
|
||||
..sort();
|
||||
return PlaylistPickerSummaryRequest._(keys);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PlaylistPickerSummaryRequest &&
|
||||
listEquals(trackKeys, other.trackKeys);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hashAll(trackKeys);
|
||||
}
|
||||
|
||||
class LibraryCollectionsState {
|
||||
final List<CollectionTrackEntry> wishlist;
|
||||
final List<CollectionTrackEntry> loved;
|
||||
@@ -280,6 +329,10 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance;
|
||||
Future<void>? _loadFuture;
|
||||
|
||||
void _invalidatePlaylistPickerSummaries() {
|
||||
ref.invalidate(libraryPlaylistPickerSummariesProvider);
|
||||
}
|
||||
|
||||
@override
|
||||
LibraryCollectionsState build() {
|
||||
_loadFuture = _load();
|
||||
@@ -494,6 +547,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
updatedAt: now.toIso8601String(),
|
||||
);
|
||||
state = state.copyWith(playlists: [playlist, ...state.playlists]);
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -513,6 +567,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
_replacePlaylistById(playlistId, (playlist) {
|
||||
return playlist.copyWith(name: trimmed, updatedAt: now);
|
||||
});
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
}
|
||||
|
||||
Future<void> deletePlaylist(String playlistId) async {
|
||||
@@ -523,6 +578,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
await _db.deletePlaylist(playlistId);
|
||||
final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex);
|
||||
state = state.copyWith(playlists: updatedPlaylists);
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
}
|
||||
|
||||
Future<bool> addTrackToPlaylist(String playlistId, Track track) async {
|
||||
@@ -550,6 +606,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
);
|
||||
});
|
||||
if (!changed) return false;
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -615,6 +672,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
alreadyInPlaylistCount: alreadyInPlaylistCount,
|
||||
);
|
||||
}
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
return PlaylistAddBatchResult(
|
||||
addedCount: entriesToAdd.length,
|
||||
alreadyInPlaylistCount: alreadyInPlaylistCount,
|
||||
@@ -642,6 +700,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
if (nextTracks.length == playlist.tracks.length) return playlist;
|
||||
return playlist.copyWith(tracks: nextTracks, updatedAt: now);
|
||||
});
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
}
|
||||
|
||||
Future<Directory> _playlistCoversDir() async {
|
||||
@@ -678,6 +737,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
if (playlist.coverImagePath == destPath) return playlist;
|
||||
return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now);
|
||||
});
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
}
|
||||
|
||||
Future<void> removePlaylistCover(String playlistId) async {
|
||||
@@ -703,6 +763,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
if (playlist.coverImagePath == null) return playlist;
|
||||
return playlist.copyWith(coverImagePath: () => null, updatedAt: now);
|
||||
});
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,3 +771,27 @@ final libraryCollectionsProvider =
|
||||
NotifierProvider<LibraryCollectionsNotifier, LibraryCollectionsState>(
|
||||
LibraryCollectionsNotifier.new,
|
||||
);
|
||||
|
||||
final libraryPlaylistPickerSummariesProvider =
|
||||
FutureProvider.family<
|
||||
List<PlaylistPickerSummary>,
|
||||
PlaylistPickerSummaryRequest
|
||||
>((ref, request) async {
|
||||
final db = LibraryCollectionsDatabase.instance;
|
||||
await db.migrateFromSharedPreferences();
|
||||
final rows = await db.loadPlaylistPickerSummaries(request.trackKeys);
|
||||
return rows
|
||||
.map(
|
||||
(row) => PlaylistPickerSummary(
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
coverImagePath: row.coverImagePath,
|
||||
previewCover: row.previewCover,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
trackCount: row.trackCount,
|
||||
containsAllRequestedTracks: row.containsAllRequestedTracks,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 7;
|
||||
const _currentMigrationVersion = 10;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
@@ -35,21 +35,32 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final prefs = await _prefs;
|
||||
final json = prefs.getString(_settingsKey);
|
||||
if (json != null) {
|
||||
state = AppSettings.fromJson(
|
||||
final loaded = AppSettings.fromJson(
|
||||
Map<String, dynamic>.from(jsonDecode(json) as Map),
|
||||
);
|
||||
final sanitizedDownloadFallbackExtensionIds =
|
||||
_sanitizeDownloadFallbackExtensionIds(
|
||||
loaded.downloadFallbackExtensionIds,
|
||||
);
|
||||
state = loaded.copyWith(
|
||||
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
|
||||
clearDownloadFallbackExtensionIds:
|
||||
loaded.downloadFallbackExtensionIds != null &&
|
||||
sanitizedDownloadFallbackExtensionIds == null,
|
||||
);
|
||||
|
||||
await _runMigrations(prefs);
|
||||
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||
await _normalizeSongLinkRegionIfNeeded();
|
||||
}
|
||||
|
||||
await _retireBuiltInSpotifyProvider();
|
||||
await _cleanupRetiredSpotifySettings();
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
|
||||
_syncLyricsSettingsToBackend();
|
||||
_syncNetworkCompatibilitySettingsToBackend();
|
||||
_syncExtensionFallbackSettingsToBackend();
|
||||
}
|
||||
|
||||
void _syncLyricsSettingsToBackend() {
|
||||
@@ -83,16 +94,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
});
|
||||
}
|
||||
|
||||
void _syncExtensionFallbackSettingsToBackend() {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
|
||||
PlatformBridge.setDownloadFallbackExtensionIds(
|
||||
state.downloadFallbackExtensionIds,
|
||||
).catchError((Object e) {
|
||||
_log.w('Failed to sync extension fallback settings to backend: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||
|
||||
if (lastMigration < 1) {
|
||||
if (!state.useCustomSpotifyCredentials) {
|
||||
state = state.copyWith(metadataSource: 'deezer');
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
if (lastMigration < _currentMigrationVersion) {
|
||||
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
|
||||
state = state.copyWith(storageMode: 'saf');
|
||||
@@ -101,31 +115,26 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
if (!state.isFirstLaunch && !state.hasCompletedTutorial) {
|
||||
state = state.copyWith(hasCompletedTutorial: true);
|
||||
}
|
||||
// Migration 4: include Spotify Lyrics API in provider order for existing users
|
||||
if (!state.lyricsProviders.contains('spotify_api')) {
|
||||
final updatedProviders = List<String>.from(state.lyricsProviders);
|
||||
final lrclibIndex = updatedProviders.indexOf('lrclib');
|
||||
if (lrclibIndex >= 0) {
|
||||
updatedProviders.insert(lrclibIndex + 1, 'spotify_api');
|
||||
} else {
|
||||
updatedProviders.add('spotify_api');
|
||||
}
|
||||
state = state.copyWith(lyricsProviders: updatedProviders);
|
||||
}
|
||||
if (state.metadataSource != 'deezer' ||
|
||||
state.spotifyClientId.isNotEmpty ||
|
||||
state.spotifyClientSecret.isNotEmpty ||
|
||||
state.useCustomSpotifyCredentials) {
|
||||
if (state.lyricsProviders.contains('spotify_api')) {
|
||||
final updatedProviders = state.lyricsProviders
|
||||
.where((provider) => provider != 'spotify_api')
|
||||
.toList();
|
||||
state = state.copyWith(
|
||||
metadataSource: 'deezer',
|
||||
spotifyClientId: '',
|
||||
spotifyClientSecret: '',
|
||||
useCustomSpotifyCredentials: false,
|
||||
lyricsProviders: updatedProviders.isEmpty
|
||||
? const [
|
||||
'lrclib',
|
||||
'musixmatch',
|
||||
'netease',
|
||||
'apple_music',
|
||||
'qqmusic',
|
||||
]
|
||||
: updatedProviders,
|
||||
);
|
||||
}
|
||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||
// Migration 7: YouTube is no longer a built-in service — reset to Tidal
|
||||
if (state.defaultService == 'youtube') {
|
||||
// Migration 7/10: retired built-in services reset back to Tidal
|
||||
if (state.defaultService == 'youtube' ||
|
||||
state.defaultService == 'deezer') {
|
||||
state = state.copyWith(defaultService: 'tidal');
|
||||
}
|
||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||
@@ -134,8 +143,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final settingsToSave = state.copyWith(spotifyClientSecret: '');
|
||||
_pendingSettingsJson = jsonEncode(settingsToSave.toJson());
|
||||
_pendingSettingsJson = jsonEncode(state.toJson());
|
||||
|
||||
if (_isSavingSettings) {
|
||||
_saveQueued = true;
|
||||
@@ -186,28 +194,29 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
Future<void> _retireBuiltInSpotifyProvider() async {
|
||||
List<String>? _sanitizeDownloadFallbackExtensionIds(List<String>? ids) {
|
||||
if (ids == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final result = <String>[];
|
||||
for (final id in ids) {
|
||||
final normalized = id.trim();
|
||||
if (normalized.isEmpty || result.contains(normalized)) {
|
||||
continue;
|
||||
}
|
||||
result.add(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _cleanupRetiredSpotifySettings() async {
|
||||
final storedSecret = await _secureStorage.read(
|
||||
key: _spotifyClientSecretKey,
|
||||
);
|
||||
if (storedSecret != null && storedSecret.isNotEmpty) {
|
||||
await _secureStorage.delete(key: _spotifyClientSecretKey);
|
||||
}
|
||||
|
||||
if (state.metadataSource == 'deezer' &&
|
||||
state.spotifyClientId.isEmpty &&
|
||||
state.spotifyClientSecret.isEmpty &&
|
||||
!state.useCustomSpotifyCredentials) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
metadataSource: 'deezer',
|
||||
spotifyClientId: '',
|
||||
spotifyClientSecret: '',
|
||||
useCustomSpotifyCredentials: false,
|
||||
);
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
void setDefaultService(String service) {
|
||||
@@ -225,6 +234,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSingleFilenameFormat(String format) {
|
||||
state = state.copyWith(singleFilenameFormat: format);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setDownloadDirectory(String directory) {
|
||||
state = state.copyWith(downloadDirectory: directory);
|
||||
_saveSettings();
|
||||
@@ -256,6 +270,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEmbedReplayGain(bool enabled) {
|
||||
state = state.copyWith(embedReplayGain: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEmbedMetadata(bool enabled) {
|
||||
state = state.copyWith(embedMetadata: enabled);
|
||||
_saveSettings();
|
||||
@@ -380,11 +399,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setMetadataSource(String source) {
|
||||
state = state.copyWith(metadataSource: source);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSearchProvider(String? provider) {
|
||||
if (provider == null || provider.isEmpty) {
|
||||
state = state.copyWith(clearSearchProvider: true);
|
||||
@@ -414,6 +428,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setDownloadFallbackExtensionIds(List<String>? extensionIds) {
|
||||
final sanitized = _sanitizeDownloadFallbackExtensionIds(extensionIds);
|
||||
state = state.copyWith(
|
||||
downloadFallbackExtensionIds: sanitized,
|
||||
clearDownloadFallbackExtensionIds:
|
||||
extensionIds == null && state.downloadFallbackExtensionIds != null,
|
||||
);
|
||||
_saveSettings();
|
||||
_syncExtensionFallbackSettingsToBackend();
|
||||
}
|
||||
|
||||
void setSeparateSingles(bool enabled) {
|
||||
state = state.copyWith(separateSingles: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -146,7 +146,6 @@ class StoreState {
|
||||
this.registryUrl = '',
|
||||
});
|
||||
|
||||
/// Whether a registry URL has been configured by the user.
|
||||
bool get hasRegistryUrl => registryUrl.isNotEmpty;
|
||||
|
||||
StoreState copyWith({
|
||||
@@ -218,7 +217,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
Future<void> initialize(String cacheDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
// Load saved registry URL early to avoid UI flash (empty → setup screen)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
|
||||
|
||||
@@ -246,8 +244,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the registry URL, saves it, and refreshes the store.
|
||||
/// The Go backend handles URL normalisation (GitHub repo → raw URL, branch detection).
|
||||
Future<void> setRegistryUrl(String url) async {
|
||||
final trimmed = url.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
@@ -258,10 +254,8 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
// Go backend resolves GitHub URLs (detects default branch) and validates HTTPS.
|
||||
await PlatformBridge.setStoreRegistryUrl(trimmed);
|
||||
|
||||
// Read back the resolved URL (may differ from input after normalisation).
|
||||
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -280,13 +274,11 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the saved registry URL and fully detaches the repo from backend.
|
||||
Future<void> removeRegistryUrl() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_registryUrlPrefKey);
|
||||
|
||||
// Reset the URL in Go backend memory AND clear its cache
|
||||
await PlatformBridge.clearStoreRegistryUrl();
|
||||
|
||||
state = state.copyWith(
|
||||
|
||||
@@ -906,9 +906,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
albumType: data['album_type'] as String?,
|
||||
albumType: normalizeOptionalString(data['album_type']?.toString()),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -939,10 +941,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
source: effectiveSource,
|
||||
albumType: data['album_type']?.toString(),
|
||||
albumType: normalizeOptionalString(data['album_type']?.toString()),
|
||||
composer: data['composer']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
@@ -74,6 +75,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
String? _error;
|
||||
bool _showTitleInAppBar = false;
|
||||
String? _artistId;
|
||||
String? _albumType;
|
||||
int? _albumTotalTracks;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
@@ -111,6 +114,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
_tracks = _AlbumCache.get(widget.albumId);
|
||||
}
|
||||
_artistId = widget.artistId;
|
||||
_albumType = _tracks?.firstOrNull?.albumType;
|
||||
_albumTotalTracks = _tracks?.firstOrNull?.totalTracks;
|
||||
|
||||
if (_tracks == null || _tracks!.isEmpty) {
|
||||
_fetchTracks();
|
||||
@@ -138,14 +143,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
/// Upgrade cover URL to a higher resolution for full-screen display.
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
|
||||
if (url.contains('ab67616d00001e02')) {
|
||||
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
|
||||
}
|
||||
// Deezer CDN: upgrade to 1000x1000
|
||||
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
|
||||
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
|
||||
return url.replaceAllMapped(
|
||||
@@ -181,13 +183,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
deezerAlbumId,
|
||||
);
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||
?.toString();
|
||||
final albumType = normalizeOptionalString(
|
||||
albumInfo?['album_type']?.toString(),
|
||||
);
|
||||
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseTrack(
|
||||
t as Map<String, dynamic>,
|
||||
albumTypeFallback: albumType,
|
||||
totalTracksFallback: totalTracks,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
@@ -195,6 +206,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -206,13 +219,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
qobuzAlbumId,
|
||||
);
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||
?.toString();
|
||||
final albumType = normalizeOptionalString(
|
||||
albumInfo?['album_type']?.toString(),
|
||||
);
|
||||
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseTrack(
|
||||
t as Map<String, dynamic>,
|
||||
albumTypeFallback: albumType,
|
||||
totalTracksFallback: totalTracks,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
@@ -220,6 +242,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -231,13 +255,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
tidalAlbumId,
|
||||
);
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||
?.toString();
|
||||
final albumType = normalizeOptionalString(
|
||||
albumInfo?['album_type']?.toString(),
|
||||
);
|
||||
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseTrack(
|
||||
t as Map<String, dynamic>,
|
||||
albumTypeFallback: albumType,
|
||||
totalTracksFallback: totalTracks,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
@@ -245,6 +278,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -257,13 +292,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final albumInfo = result['album'] as Map<String, dynamic>?;
|
||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||
?.toString();
|
||||
final albumType = normalizeOptionalString(
|
||||
albumInfo?['album_type']?.toString(),
|
||||
);
|
||||
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseTrack(
|
||||
t as Map<String, dynamic>,
|
||||
albumTypeFallback: albumType,
|
||||
totalTracksFallback: totalTracks,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
@@ -271,6 +315,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -286,7 +332,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
Track _parseTrack(
|
||||
Map<String, dynamic> data, {
|
||||
String? albumTypeFallback,
|
||||
int? totalTracksFallback,
|
||||
}) {
|
||||
return Track(
|
||||
id: data['spotify_id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
@@ -301,9 +351,17 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
albumType: data['album_type'] as String?,
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
albumType:
|
||||
normalizeOptionalString(data['album_type']?.toString()) ??
|
||||
albumTypeFallback ??
|
||||
_albumType,
|
||||
totalTracks:
|
||||
data['total_tracks'] as int? ??
|
||||
totalTracksFallback ??
|
||||
_albumTotalTracks,
|
||||
composer: data['composer']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -313,7 +371,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
||||
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.albumId.startsWith('deezer:')) return 'deezer';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -393,6 +450,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
final cacheWidth = coverCacheWidthForViewport(context);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
@@ -404,6 +462,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
imageUrl:
|
||||
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
|
||||
@@ -159,7 +159,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -228,7 +227,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
// Show title when scrolled past the header (280px trigger)
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
@@ -411,9 +409,13 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
albumType: data['album_type']?.toString() ?? album?.albumType,
|
||||
albumType:
|
||||
normalizeOptionalString(data['album_type']?.toString()) ??
|
||||
album?.albumType,
|
||||
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
|
||||
composer: data['composer']?.toString(),
|
||||
source: data['provider_id']?.toString() ?? widget.extensionId,
|
||||
);
|
||||
}
|
||||
@@ -1056,9 +1058,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
);
|
||||
if (result != null && result['tracks'] != null) {
|
||||
final tracksList = result['tracks'] as List<dynamic>;
|
||||
return tracksList
|
||||
final parsedTracks = tracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||
.toList();
|
||||
return parsedTracks;
|
||||
}
|
||||
} else if (album.id.startsWith('deezer:')) {
|
||||
final deezerId = album.id.replaceFirst('deezer:', '');
|
||||
@@ -1130,9 +1133,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
trackNumber:
|
||||
data['track_position'] as int? ?? data['track_number'] as int?,
|
||||
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: album.releaseDate,
|
||||
albumType: album.albumType,
|
||||
totalTracks: album.totalTracks,
|
||||
composer: data['composer']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1931,6 +1936,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
coverUrl: album.coverUrl,
|
||||
initialAlbumType: album.albumType,
|
||||
initialTotalTracks: album.totalTracks,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -2013,7 +2020,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Option tile for discography download bottom sheet
|
||||
class _DiscographyOptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
@@ -2051,7 +2057,6 @@ class _DiscographyOptionTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress dialog shown while fetching album tracks
|
||||
class _FetchingProgressDialog extends StatefulWidget {
|
||||
final int totalAlbums;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
@@ -95,7 +96,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
/// Upgrade cover URL to a reasonable resolution for full-screen display.
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
if (url.contains('ab67616d00001e02')) {
|
||||
@@ -111,7 +111,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
return url;
|
||||
}
|
||||
|
||||
/// Get tracks for this album from history provider (reactive)
|
||||
List<DownloadHistoryItem> _getAlbumTracks(
|
||||
List<DownloadHistoryItem> allItems,
|
||||
) {
|
||||
@@ -300,7 +299,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
||||
Future<void> _navigateToMetadataScreen(
|
||||
DownloadHistoryItem item, {
|
||||
required List<DownloadHistoryItem> navigationItems,
|
||||
required int navigationIndex,
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(item.coverUrl);
|
||||
final beforeModTime =
|
||||
@@ -310,7 +313,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
if (!mounted) return;
|
||||
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
|
||||
slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
@@ -464,6 +473,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
final cacheWidth = coverCacheWidthForViewport(context);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
@@ -474,6 +484,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
Image.file(
|
||||
File(embeddedCoverPath),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, _, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
@@ -482,6 +495,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
imageUrl:
|
||||
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
@@ -641,7 +655,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
ColorScheme colorScheme,
|
||||
List<DownloadHistoryItem> tracks,
|
||||
) {
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -688,7 +701,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
key: ValueKey(track.id),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
child: _buildTrackItem(
|
||||
context,
|
||||
colorScheme,
|
||||
track,
|
||||
tracks,
|
||||
index,
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: tracks.length),
|
||||
@@ -706,12 +725,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
|
||||
|
||||
for (final track in discTracks) {
|
||||
final navigationIndex = tracks.indexOf(track);
|
||||
children.add(
|
||||
KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: StaggeredListItem(
|
||||
index: revealIndex++,
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
child: _buildTrackItem(
|
||||
context,
|
||||
colorScheme,
|
||||
track,
|
||||
tracks,
|
||||
navigationIndex,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -771,6 +797,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
DownloadHistoryItem track,
|
||||
List<DownloadHistoryItem> navigationItems,
|
||||
int navigationIndex,
|
||||
) {
|
||||
final isSelected = _selectedIds.contains(track.id);
|
||||
|
||||
@@ -788,7 +816,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(track.id)
|
||||
: () => _navigateToMetadataScreen(track),
|
||||
: () => _navigateToMetadataScreen(
|
||||
track,
|
||||
navigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
onLongPress: _isSelectionMode
|
||||
? null
|
||||
: () => _enterSelectionMode(track.id),
|
||||
@@ -848,7 +880,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Share selected tracks via system share sheet
|
||||
Future<void> _shareSelected(List<DownloadHistoryItem> allTracks) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final safUris = <String>[];
|
||||
@@ -1091,7 +1122,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
// For SAF items, use safFileName to detect format (filePath is content:// URI)
|
||||
final nameToCheck =
|
||||
(item.safFileName != null && item.safFileName!.isNotEmpty)
|
||||
? item.safFileName!.toLowerCase()
|
||||
|
||||
+363
-173
@@ -258,6 +258,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
List<Track>? _searchBucketsSourceTracks;
|
||||
_SearchResultBuckets? _searchBucketsCache;
|
||||
_SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder;
|
||||
List<SearchArtist>? _sortedArtistsSource;
|
||||
_SearchSortOption? _sortedArtistsMode;
|
||||
List<SearchArtist>? _sortedArtistsCache;
|
||||
List<SearchAlbum>? _sortedAlbumsSource;
|
||||
_SearchSortOption? _sortedAlbumsMode;
|
||||
List<SearchAlbum>? _sortedAlbumsCache;
|
||||
List<SearchPlaylist>? _sortedPlaylistsSource;
|
||||
_SearchSortOption? _sortedPlaylistsMode;
|
||||
List<SearchPlaylist>? _sortedPlaylistsCache;
|
||||
List<Track>? _sortedTracksSource;
|
||||
List<int>? _sortedTrackIndexesSource;
|
||||
_SearchSortOption? _sortedTracksMode;
|
||||
List<Track>? _sortedTracksCache;
|
||||
List<int>? _sortedTrackIndexesCache;
|
||||
|
||||
double _responsiveScale({
|
||||
required BuildContext context,
|
||||
@@ -476,6 +490,23 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return buckets;
|
||||
}
|
||||
|
||||
void _invalidateSearchSortCaches() {
|
||||
_sortedArtistsSource = null;
|
||||
_sortedArtistsMode = null;
|
||||
_sortedArtistsCache = null;
|
||||
_sortedAlbumsSource = null;
|
||||
_sortedAlbumsMode = null;
|
||||
_sortedAlbumsCache = null;
|
||||
_sortedPlaylistsSource = null;
|
||||
_sortedPlaylistsMode = null;
|
||||
_sortedPlaylistsCache = null;
|
||||
_sortedTracksSource = null;
|
||||
_sortedTrackIndexesSource = null;
|
||||
_sortedTracksMode = null;
|
||||
_sortedTracksCache = null;
|
||||
_sortedTrackIndexesCache = null;
|
||||
}
|
||||
|
||||
void _onSearchFocusChanged() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
@@ -496,7 +527,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if live search is available (extension is set as search provider)
|
||||
bool _isLiveSearchEnabled() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extState = ref.read(extensionProvider);
|
||||
@@ -564,7 +594,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in search providers that are not extensions
|
||||
static const _builtInSearchProviders = {'tidal', 'qobuz'};
|
||||
|
||||
Future<void> _performSearch(String query, {String? filterOverride}) async {
|
||||
@@ -579,6 +608,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (_lastSearchQuery == searchKey) return;
|
||||
_lastSearchQuery = searchKey;
|
||||
_searchSortOption = _SearchSortOption.defaultOrder;
|
||||
_invalidateSearchSortCaches();
|
||||
|
||||
final isBuiltInProvider =
|
||||
searchProvider != null &&
|
||||
@@ -599,7 +629,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
.read(trackProvider.notifier)
|
||||
.customSearch(searchProvider, query, options: options);
|
||||
} else if (isBuiltInProvider) {
|
||||
// Use built-in Tidal or Qobuz search
|
||||
await ref
|
||||
.read(trackProvider.notifier)
|
||||
.search(
|
||||
@@ -757,7 +786,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
recommendedService: trackState.searchSource,
|
||||
recommendedService:
|
||||
trackState.searchExtensionId ?? trackState.searchSource,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
@@ -1121,7 +1151,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
title: Text(
|
||||
context.l10n.homeTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||
fontSize: 20 + (14 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
@@ -1407,64 +1437,33 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final embeddedCoverPath = DownloadedEmbeddedCoverResolver.resolve(
|
||||
item.filePath,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Open track ${item.trackName} by ${item.artistName}',
|
||||
child: GestureDetector(
|
||||
onTap: () => _navigateToMetadataScreen(item),
|
||||
onTap: () => _navigateToMetadataScreen(
|
||||
item,
|
||||
navigationItems: items
|
||||
.take(itemCount)
|
||||
.toList(growable: false),
|
||||
navigationIndex: index,
|
||||
),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
ClipRRect(
|
||||
_DownloadedOrRemoteCover(
|
||||
downloadedFilePath: item.filePath,
|
||||
imageUrl: item.coverUrl,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: embeddedCoverPath != null
|
||||
? Image.file(
|
||||
File(embeddedCoverPath),
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: (coverSize * 2).round(),
|
||||
cacheHeight: (coverSize * 2).round(),
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
)
|
||||
: item.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (coverSize * 2).round(),
|
||||
memCacheHeight: (coverSize * 2).round(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
fallbackIcon: Icons.music_note,
|
||||
fallbackIconSize: 32,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
@@ -1495,7 +1494,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
) {
|
||||
final hasGreeting = greeting != null && greeting.isNotEmpty;
|
||||
final sectionOffset = hasGreeting ? 1 : 0;
|
||||
final totalCount = sections.length + sectionOffset + 1; // + bottom padding
|
||||
final totalCount = sections.length + sectionOffset + 1;
|
||||
|
||||
return [
|
||||
SliverList(
|
||||
@@ -1845,10 +1844,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
albumName: item.albumName ?? '',
|
||||
albumId: item.albumId,
|
||||
duration: item.durationMs ~/ 1000,
|
||||
trackNumber: 1,
|
||||
discNumber: 1,
|
||||
trackNumber: null,
|
||||
discNumber: null,
|
||||
totalDiscs: null,
|
||||
isrc: null,
|
||||
releaseDate: null,
|
||||
releaseDate: item.releaseDate,
|
||||
coverUrl: item.coverUrl,
|
||||
source: item.providerId ?? 'spotify-web',
|
||||
);
|
||||
@@ -1997,12 +1997,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
IconData typeIcon;
|
||||
String typeLabel;
|
||||
final isDownloaded = item.providerId == 'download';
|
||||
final embeddedCoverPath = isDownloaded
|
||||
? DownloadedEmbeddedCoverResolver.resolve(
|
||||
downloadFilePathByRecentKey['${item.type.name}:${item.id}'],
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
)
|
||||
: null;
|
||||
|
||||
switch (item.type) {
|
||||
case RecentAccessType.artist:
|
||||
@@ -2028,55 +2022,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
_DownloadedOrRemoteCover(
|
||||
downloadedFilePath: isDownloaded
|
||||
? downloadFilePathByRecentKey['${item.type.name}:${item.id}']
|
||||
: null,
|
||||
imageUrl: item.imageUrl,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: BorderRadius.circular(
|
||||
item.type == RecentAccessType.artist ? 28 : 4,
|
||||
),
|
||||
child: embeddedCoverPath != null
|
||||
? Image.file(
|
||||
File(embeddedCoverPath),
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 112,
|
||||
cacheHeight: 112,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
typeIcon,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: item.imageUrl != null && item.imageUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.imageUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
typeIcon,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
typeIcon,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
fallbackIcon: typeIcon,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -2266,7 +2223,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
||||
Future<void> _navigateToMetadataScreen(
|
||||
DownloadHistoryItem item, {
|
||||
List<DownloadHistoryItem>? navigationItems,
|
||||
int? navigationIndex,
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(item.coverUrl);
|
||||
final beforeModTime =
|
||||
@@ -2275,7 +2236,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
|
||||
slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
@@ -2412,8 +2379,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Search result sorting ──────────────────────────────────────────────
|
||||
|
||||
String _sortOptionLabel(_SearchSortOption option) {
|
||||
switch (option) {
|
||||
case _SearchSortOption.defaultOrder:
|
||||
@@ -2574,6 +2539,114 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return sorted;
|
||||
}
|
||||
|
||||
List<SearchArtist>? _sortSearchArtists(List<SearchArtist>? artists) {
|
||||
if (artists == null ||
|
||||
artists.isEmpty ||
|
||||
_searchSortOption == _SearchSortOption.defaultOrder) {
|
||||
return artists;
|
||||
}
|
||||
if (identical(artists, _sortedArtistsSource) &&
|
||||
_sortedArtistsMode == _searchSortOption &&
|
||||
_sortedArtistsCache != null) {
|
||||
return _sortedArtistsCache;
|
||||
}
|
||||
final sorted = _applySortToList<SearchArtist>(
|
||||
artists,
|
||||
(a) => a.name,
|
||||
(a) => a.name,
|
||||
(a) => 0,
|
||||
(a) => null,
|
||||
);
|
||||
_sortedArtistsSource = artists;
|
||||
_sortedArtistsMode = _searchSortOption;
|
||||
_sortedArtistsCache = sorted;
|
||||
return sorted;
|
||||
}
|
||||
|
||||
List<SearchAlbum>? _sortSearchAlbums(List<SearchAlbum>? albums) {
|
||||
if (albums == null ||
|
||||
albums.isEmpty ||
|
||||
_searchSortOption == _SearchSortOption.defaultOrder) {
|
||||
return albums;
|
||||
}
|
||||
if (identical(albums, _sortedAlbumsSource) &&
|
||||
_sortedAlbumsMode == _searchSortOption &&
|
||||
_sortedAlbumsCache != null) {
|
||||
return _sortedAlbumsCache;
|
||||
}
|
||||
final sorted = _applySortToList<SearchAlbum>(
|
||||
albums,
|
||||
(a) => a.name,
|
||||
(a) => a.artists,
|
||||
(a) => 0,
|
||||
(a) => a.releaseDate,
|
||||
);
|
||||
_sortedAlbumsSource = albums;
|
||||
_sortedAlbumsMode = _searchSortOption;
|
||||
_sortedAlbumsCache = sorted;
|
||||
return sorted;
|
||||
}
|
||||
|
||||
List<SearchPlaylist>? _sortSearchPlaylists(List<SearchPlaylist>? playlists) {
|
||||
if (playlists == null ||
|
||||
playlists.isEmpty ||
|
||||
_searchSortOption == _SearchSortOption.defaultOrder) {
|
||||
return playlists;
|
||||
}
|
||||
if (identical(playlists, _sortedPlaylistsSource) &&
|
||||
_sortedPlaylistsMode == _searchSortOption &&
|
||||
_sortedPlaylistsCache != null) {
|
||||
return _sortedPlaylistsCache;
|
||||
}
|
||||
final sorted = _applySortToList<SearchPlaylist>(
|
||||
playlists,
|
||||
(p) => p.name,
|
||||
(p) => p.owner,
|
||||
(p) => 0,
|
||||
(p) => null,
|
||||
);
|
||||
_sortedPlaylistsSource = playlists;
|
||||
_sortedPlaylistsMode = _searchSortOption;
|
||||
_sortedPlaylistsCache = sorted;
|
||||
return sorted;
|
||||
}
|
||||
|
||||
({List<Track> tracks, List<int> indexes}) _sortTrackResults(
|
||||
List<Track> tracks,
|
||||
List<int> indexes,
|
||||
) {
|
||||
if (tracks.isEmpty || _searchSortOption == _SearchSortOption.defaultOrder) {
|
||||
return (tracks: tracks, indexes: indexes);
|
||||
}
|
||||
if (identical(tracks, _sortedTracksSource) &&
|
||||
identical(indexes, _sortedTrackIndexesSource) &&
|
||||
_sortedTracksMode == _searchSortOption &&
|
||||
_sortedTracksCache != null &&
|
||||
_sortedTrackIndexesCache != null) {
|
||||
return (tracks: _sortedTracksCache!, indexes: _sortedTrackIndexesCache!);
|
||||
}
|
||||
final paired = List.generate(
|
||||
tracks.length,
|
||||
(i) => (tracks[i], indexes[i]),
|
||||
growable: false,
|
||||
);
|
||||
final sortedPairs = _applySortToList<(Track, int)>(
|
||||
paired,
|
||||
(p) => p.$1.name,
|
||||
(p) => p.$1.artistName,
|
||||
(p) => p.$1.duration,
|
||||
(p) => p.$1.releaseDate,
|
||||
);
|
||||
final sortedTracks = sortedPairs.map((p) => p.$1).toList(growable: false);
|
||||
final sortedIndexes = sortedPairs.map((p) => p.$2).toList(growable: false);
|
||||
_sortedTracksSource = tracks;
|
||||
_sortedTrackIndexesSource = indexes;
|
||||
_sortedTracksMode = _searchSortOption;
|
||||
_sortedTracksCache = sortedTracks;
|
||||
_sortedTrackIndexesCache = sortedIndexes;
|
||||
return (tracks: sortedTracks, indexes: sortedIndexes);
|
||||
}
|
||||
|
||||
List<Widget> _buildSearchResults({
|
||||
required List<Track> tracks,
|
||||
required List<SearchArtist>? searchArtists,
|
||||
@@ -2607,58 +2680,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final playlistItems = buckets.playlistItems;
|
||||
final artistItems = buckets.artistItems;
|
||||
|
||||
final sortedArtists = searchArtists != null && searchArtists.isNotEmpty
|
||||
? _applySortToList<SearchArtist>(
|
||||
searchArtists,
|
||||
(a) => a.name,
|
||||
(a) => a.name,
|
||||
(a) => 0,
|
||||
(a) => null,
|
||||
)
|
||||
: searchArtists;
|
||||
|
||||
final sortedAlbums = searchAlbums != null && searchAlbums.isNotEmpty
|
||||
? _applySortToList<SearchAlbum>(
|
||||
searchAlbums,
|
||||
(a) => a.name,
|
||||
(a) => a.artists,
|
||||
(a) => 0,
|
||||
(a) => a.releaseDate,
|
||||
)
|
||||
: searchAlbums;
|
||||
|
||||
final sortedPlaylists =
|
||||
searchPlaylists != null && searchPlaylists.isNotEmpty
|
||||
? _applySortToList<SearchPlaylist>(
|
||||
searchPlaylists,
|
||||
(p) => p.name,
|
||||
(p) => p.owner,
|
||||
(p) => 0,
|
||||
(p) => null,
|
||||
)
|
||||
: searchPlaylists;
|
||||
|
||||
List<Track> sortedTracks;
|
||||
List<int> sortedTrackIndexes;
|
||||
if (realTracks.isNotEmpty &&
|
||||
_searchSortOption != _SearchSortOption.defaultOrder) {
|
||||
final paired = List.generate(
|
||||
realTracks.length,
|
||||
(i) => (realTracks[i], realTrackIndexes[i]),
|
||||
);
|
||||
final sortedPairs = _applySortToList<(Track, int)>(
|
||||
paired,
|
||||
(p) => p.$1.name,
|
||||
(p) => p.$1.artistName,
|
||||
(p) => p.$1.duration,
|
||||
(p) => p.$1.releaseDate,
|
||||
);
|
||||
sortedTracks = sortedPairs.map((p) => p.$1).toList();
|
||||
sortedTrackIndexes = sortedPairs.map((p) => p.$2).toList();
|
||||
} else {
|
||||
sortedTracks = realTracks;
|
||||
sortedTrackIndexes = realTrackIndexes;
|
||||
}
|
||||
final sortedArtists = _sortSearchArtists(searchArtists);
|
||||
final sortedAlbums = _sortSearchAlbums(searchAlbums);
|
||||
final sortedPlaylists = _sortSearchPlaylists(searchPlaylists);
|
||||
final sortedTrackResults = _sortTrackResults(realTracks, realTrackIndexes);
|
||||
final sortedTracks = sortedTrackResults.tracks;
|
||||
final sortedTrackIndexes = sortedTrackResults.indexes;
|
||||
|
||||
final slivers = <Widget>[
|
||||
if (error != null)
|
||||
@@ -2940,7 +2967,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
coverUrl: album.imageUrl,
|
||||
tracks: const [], // Will be fetched by AlbumScreen
|
||||
tracks: const [],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -2966,7 +2993,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
builder: (context) => PlaylistScreen(
|
||||
playlistName: playlist.name,
|
||||
coverUrl: playlist.imageUrl,
|
||||
tracks: const [], // Will be fetched
|
||||
tracks: const [],
|
||||
playlistId: playlist.id,
|
||||
),
|
||||
),
|
||||
@@ -3004,6 +3031,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
albumId: albumItem.id,
|
||||
albumName: albumItem.name,
|
||||
coverUrl: albumItem.coverUrl,
|
||||
initialAlbumType: albumItem.albumType,
|
||||
initialTotalTracks: albumItem.totalTracks,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -3693,9 +3722,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent:
|
||||
thumbWidth +
|
||||
24, // Adjust divider indent based on thumbnail width
|
||||
indent: thumbWidth + 24,
|
||||
endIndent: 12,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
@@ -4165,11 +4192,133 @@ class _SearchPlaylistItemWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadedOrRemoteCover extends StatefulWidget {
|
||||
final String? downloadedFilePath;
|
||||
final String? imageUrl;
|
||||
final double width;
|
||||
final double height;
|
||||
final BorderRadius borderRadius;
|
||||
final IconData fallbackIcon;
|
||||
final double fallbackIconSize;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _DownloadedOrRemoteCover({
|
||||
required this.downloadedFilePath,
|
||||
required this.imageUrl,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.borderRadius,
|
||||
required this.fallbackIcon,
|
||||
required this.colorScheme,
|
||||
this.fallbackIconSize = 24,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_DownloadedOrRemoteCover> createState() =>
|
||||
_DownloadedOrRemoteCoverState();
|
||||
}
|
||||
|
||||
class _DownloadedOrRemoteCoverState extends State<_DownloadedOrRemoteCover> {
|
||||
String? _embeddedCoverPath;
|
||||
bool _refreshScheduled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_embeddedCoverPath = _resolveEmbeddedCoverPath();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _DownloadedOrRemoteCover oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.downloadedFilePath != widget.downloadedFilePath ||
|
||||
oldWidget.imageUrl != widget.imageUrl) {
|
||||
final nextPath = _resolveEmbeddedCoverPath();
|
||||
if (nextPath != _embeddedCoverPath) {
|
||||
setState(() => _embeddedCoverPath = nextPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _resolveEmbeddedCoverPath() {
|
||||
final filePath = widget.downloadedFilePath;
|
||||
if (filePath == null || filePath.isEmpty) return null;
|
||||
return DownloadedEmbeddedCoverResolver.resolve(
|
||||
filePath,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
}
|
||||
|
||||
void _onEmbeddedCoverChanged() {
|
||||
if (!mounted || _refreshScheduled) return;
|
||||
_refreshScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_refreshScheduled = false;
|
||||
if (!mounted) return;
|
||||
final nextPath = _resolveEmbeddedCoverPath();
|
||||
if (nextPath != _embeddedCoverPath) {
|
||||
setState(() => _embeddedCoverPath = nextPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _fallback() {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
color: widget.colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
widget.fallbackIcon,
|
||||
color: widget.colorScheme.onSurfaceVariant,
|
||||
size: widget.fallbackIconSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cacheWidth = (widget.width * 2).round();
|
||||
final cacheHeight = (widget.height * 2).round();
|
||||
|
||||
Widget child;
|
||||
if (_embeddedCoverPath != null) {
|
||||
child = Image.file(
|
||||
File(_embeddedCoverPath!),
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
cacheHeight: cacheHeight,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, _, _) => _fallback(),
|
||||
);
|
||||
} else if (widget.imageUrl != null && widget.imageUrl!.isNotEmpty) {
|
||||
child = CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl!,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
memCacheHeight: cacheHeight,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (_, _, _) => _fallback(),
|
||||
);
|
||||
} else {
|
||||
child = _fallback();
|
||||
}
|
||||
|
||||
return ClipRRect(borderRadius: widget.borderRadius, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String albumId;
|
||||
final String albumName;
|
||||
final String? coverUrl;
|
||||
final String? initialAlbumType;
|
||||
final int? initialTotalTracks;
|
||||
|
||||
const ExtensionAlbumScreen({
|
||||
super.key,
|
||||
@@ -4177,6 +4326,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
required this.albumId,
|
||||
required this.albumName,
|
||||
this.coverUrl,
|
||||
this.initialAlbumType,
|
||||
this.initialTotalTracks,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -4190,10 +4341,14 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
String? _error;
|
||||
String? _artistId;
|
||||
String? _artistName;
|
||||
String? _albumType;
|
||||
int? _albumTotalTracks;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_albumType = normalizeOptionalString(widget.initialAlbumType);
|
||||
_albumTotalTracks = widget.initialTotalTracks;
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
@@ -4227,17 +4382,28 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
|
||||
final artistName = result['artists'] as String?;
|
||||
final albumType =
|
||||
normalizeOptionalString(result['album_type']?.toString()) ??
|
||||
_albumType;
|
||||
final totalTracks = result['total_tracks'] as int? ?? _albumTotalTracks;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseTrack(
|
||||
t as Map<String, dynamic>,
|
||||
albumTypeFallback: albumType,
|
||||
totalTracksFallback: totalTracks,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_artistName = artistName;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -4249,7 +4415,11 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
Track _parseTrack(
|
||||
Map<String, dynamic> data, {
|
||||
String? albumTypeFallback,
|
||||
int? totalTracksFallback,
|
||||
}) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
@@ -4274,6 +4444,18 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
albumType:
|
||||
normalizeOptionalString(data['album_type']?.toString()) ??
|
||||
albumTypeFallback ??
|
||||
_albumType,
|
||||
totalTracks:
|
||||
data['total_tracks'] as int? ??
|
||||
totalTracksFallback ??
|
||||
_albumTotalTracks,
|
||||
composer: data['composer']?.toString(),
|
||||
source: widget.extensionId,
|
||||
);
|
||||
}
|
||||
@@ -4431,6 +4613,11 @@ class _ExtensionPlaylistScreenState
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
source: widget.extensionId,
|
||||
);
|
||||
}
|
||||
@@ -4607,7 +4794,10 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
source: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
|
||||
@@ -598,7 +597,6 @@ class _LibraryTracksFolderScreenState
|
||||
final customCoverPath = playlist?.coverImagePath;
|
||||
final isLovedMode = widget.mode == LibraryTracksFolderMode.loved;
|
||||
final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist;
|
||||
// Loved always shows the heart icon (like Spotify's Liked Songs)
|
||||
final coverUrl = isLovedMode ? null : _firstCoverUrl(entries, localState);
|
||||
final hasCustomCover =
|
||||
customCoverPath != null && customCoverPath.isNotEmpty;
|
||||
@@ -668,7 +666,6 @@ class _LibraryTracksFolderScreenState
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Cover background: custom > first track URL > icon
|
||||
if (hasCustomCover)
|
||||
Image.file(
|
||||
File(customCoverPath),
|
||||
@@ -1238,23 +1235,19 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
trailing: isSelectionMode
|
||||
? null
|
||||
: historyItem != null || localItem != null
|
||||
? IconButton(
|
||||
tooltip: context.l10n.tooltipPlay,
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playTrackList([track]);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.play_arrow,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
? IconButton(
|
||||
tooltip: context.l10n.tooltipPlay,
|
||||
onPressed: () {
|
||||
ref.read(playbackProvider.notifier).playTrackList([track]);
|
||||
},
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: isSelectionMode
|
||||
? onTap
|
||||
: () {
|
||||
@@ -1333,155 +1326,6 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) {
|
||||
final track = entry.track;
|
||||
final effectiveCoverUrl = _resolveCoverUrl(track);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final isDownloaded =
|
||||
historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null &&
|
||||
track.isrc!.isNotEmpty &&
|
||||
historyState.getByIsrc(track.isrc!) != null) ||
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) != null;
|
||||
// Wishlist: only show "Add to Playlist" if track is already downloaded
|
||||
final showAddToPlaylist =
|
||||
mode != LibraryTracksFolderMode.wishlist || isDownloaded;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child:
|
||||
effectiveCoverUrl != null &&
|
||||
effectiveCoverUrl.isNotEmpty
|
||||
? _buildTrackCover(context, effectiveCoverUrl, 56)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.name,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
track.artistName,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
if (showAddToPlaylist)
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.playlist_add,
|
||||
title: context.l10n.collectionAddToPlaylist,
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
showAddTrackToPlaylistSheet(context, ref, track);
|
||||
},
|
||||
),
|
||||
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.remove_circle_outline,
|
||||
iconColor: colorScheme.error,
|
||||
title: mode == LibraryTracksFolderMode.playlist
|
||||
? context.l10n.collectionRemoveFromPlaylist
|
||||
: context.l10n.collectionRemoveFromFolder,
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_removeFromCurrentFolder(context, ref);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _removeFromCurrentFolder(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
final key = entry.key;
|
||||
|
||||
switch (mode) {
|
||||
case LibraryTracksFolderMode.wishlist:
|
||||
await notifier.removeFromWishlist(key);
|
||||
break;
|
||||
case LibraryTracksFolderMode.loved:
|
||||
await notifier.removeFromLoved(key);
|
||||
break;
|
||||
case LibraryTracksFolderMode.playlist:
|
||||
if (playlistId != null) {
|
||||
await notifier.removeTrackFromPlaylist(playlistId!, key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.collectionRemoved(entry.track.name))),
|
||||
);
|
||||
}
|
||||
|
||||
void _downloadTrack(BuildContext context, WidgetRef ref) {
|
||||
final track = entry.track;
|
||||
final settings = ref.read(settingsProvider);
|
||||
@@ -1518,15 +1362,12 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
final track = entry.track;
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
|
||||
// 1. Download history by Spotify ID
|
||||
var historyItem = historyState.getBySpotifyId(track.id);
|
||||
|
||||
// 2. Download history by ISRC
|
||||
if (historyItem == null && track.isrc != null && track.isrc!.isNotEmpty) {
|
||||
historyItem = historyState.getByIsrc(track.isrc!);
|
||||
}
|
||||
|
||||
// 3. Download history by track name + artist (handles ID/ISRC mismatch)
|
||||
historyItem ??= historyState.findByTrackAndArtist(
|
||||
track.name,
|
||||
track.artistName,
|
||||
@@ -1539,14 +1380,12 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Local library by ISRC
|
||||
final localState = ref.read(localLibraryProvider);
|
||||
LocalLibraryItem? localItem;
|
||||
if (track.isrc != null && track.isrc!.isNotEmpty) {
|
||||
localItem = localState.getByIsrc(track.isrc!);
|
||||
}
|
||||
|
||||
// 5. Local library by track name + artist
|
||||
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
|
||||
|
||||
if (localItem != null) {
|
||||
@@ -1556,7 +1395,6 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Not found anywhere — offer to download
|
||||
_downloadTrack(context, ref);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
@@ -335,6 +337,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
final cacheWidth = coverCacheWidthForViewport(context);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
@@ -345,6 +348,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
Image.file(
|
||||
File(widget.coverPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, _, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
@@ -525,7 +531,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
ColorScheme colorScheme,
|
||||
List<LocalLibraryItem> tracks,
|
||||
) {
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -824,12 +829,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
mp3Path: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
@@ -837,6 +844,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -867,7 +875,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
Future<bool> _reEnrichLocalTrack(LocalLibraryItem item) async {
|
||||
Future<bool> _reEnrichLocalTrack(
|
||||
LocalLibraryItem item, {
|
||||
List<String>? updateFields,
|
||||
}) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final request = <String, dynamic>{
|
||||
@@ -890,6 +901,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
'copyright': '',
|
||||
'duration_ms': durationMs,
|
||||
'search_online': true,
|
||||
// ignore: use_null_aware_elements
|
||||
if (updateFields != null) 'update_fields': updateFields,
|
||||
};
|
||||
|
||||
final result = await PlatformBridge.reEnrichFile(request);
|
||||
@@ -1048,31 +1061,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.trackReEnrich),
|
||||
content: Text(
|
||||
'${context.l10n.trackReEnrichOnlineSubtitle}\n\n'
|
||||
'${context.l10n.downloadedAlbumSelectedCount(selected.length)}',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(context.l10n.trackReEnrich),
|
||||
),
|
||||
],
|
||||
),
|
||||
// The bar uses AnimatedPositioned (250ms), so wait for the slide-out.
|
||||
setState(() => _isSelectionMode = false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
if (!mounted) return;
|
||||
|
||||
final selection = await showReEnrichFieldDialog(
|
||||
context,
|
||||
selectedCount: selected.length,
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) {
|
||||
if (selection == null || !mounted) {
|
||||
// Cancelled — restore selection mode (IDs are still intact).
|
||||
if (mounted) setState(() => _isSelectionMode = true);
|
||||
return;
|
||||
}
|
||||
|
||||
final updateFields = selection.isAll ? null : selection.fields;
|
||||
|
||||
var successCount = 0;
|
||||
final total = selected.length;
|
||||
|
||||
@@ -1098,7 +1104,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
);
|
||||
|
||||
try {
|
||||
final ok = await _reEnrichLocalTrack(item);
|
||||
final ok = await _reEnrichLocalTrack(item, updateFields: updateFields);
|
||||
if (ok) {
|
||||
successCount++;
|
||||
}
|
||||
@@ -1625,7 +1631,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
await localDb.deleteByPath(item.filePath);
|
||||
await localDb.replaceWithConvertedItem(
|
||||
item: item,
|
||||
newFilePath: safUri,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1637,8 +1648,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
} catch (_) {}
|
||||
}
|
||||
} else {
|
||||
// Regular file: just remove old entry, rescan will find the new one
|
||||
await localDb.deleteByPath(item.filePath);
|
||||
await localDb.replaceWithConvertedItem(
|
||||
item: item,
|
||||
newFilePath: newPath,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
}
|
||||
|
||||
successCount++;
|
||||
|
||||
@@ -329,6 +329,8 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final trackState = ref.read(trackProvider);
|
||||
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
@@ -346,7 +348,6 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
trackState.isShowingRecentAccess &&
|
||||
!trackState.isLoading &&
|
||||
(trackState.hasSearchText || trackState.hasContent)) {
|
||||
// Has recent access AND search content — clear everything at once
|
||||
_log.i(
|
||||
'Back: step 3a - dismiss recent access + clear search/content '
|
||||
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
|
||||
@@ -358,7 +359,6 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
}
|
||||
|
||||
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
|
||||
// Recent access overlay only (no search content) — just dismiss it
|
||||
_log.i('Back: step 3b - dismiss recent access only');
|
||||
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
@@ -60,7 +61,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
if (playlistId != null) {
|
||||
if (playlistId.startsWith('tidal:')) return 'tidal';
|
||||
if (playlistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (playlistId.startsWith('deezer:')) return 'deezer';
|
||||
}
|
||||
|
||||
final source = _tracks.firstOrNull?.source;
|
||||
@@ -71,7 +71,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
final trackId = _tracks.firstOrNull?.id ?? '';
|
||||
if (trackId.startsWith('tidal:')) return 'tidal';
|
||||
if (trackId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (trackId.startsWith('deezer:')) return 'deezer';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -117,7 +116,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
|
||||
final owner = playlistInfo?['owner'] as Map<String, dynamic>?;
|
||||
|
||||
// Go backend returns 'track_list' not 'tracks'
|
||||
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
@@ -164,7 +162,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,14 +183,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
/// Upgrade cover URL to a reasonable resolution for full-screen display.
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
// Spotify CDN: upgrade 300 → 640 only
|
||||
if (url.contains('ab67616d00001e02')) {
|
||||
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
|
||||
}
|
||||
// Deezer CDN: upgrade to 1000x1000
|
||||
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
|
||||
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
|
||||
return url.replaceAllMapped(
|
||||
@@ -246,6 +244,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
final cacheWidth = coverCacheWidthForViewport(context);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
@@ -256,6 +255,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
CachedNetworkImage(
|
||||
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
@@ -729,7 +729,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
class _PlaylistTrackItem extends ConsumerWidget {
|
||||
final Track track;
|
||||
final VoidCallback onDownload;
|
||||
|
||||
+267
-98
@@ -28,6 +28,7 @@ import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||
@@ -155,7 +156,6 @@ class UnifiedLibraryItem {
|
||||
return 'builtin:$id';
|
||||
}
|
||||
|
||||
/// Convert to a [Track] for adding to collections/playlists.
|
||||
Track toTrack() {
|
||||
if (historyItem != null) {
|
||||
final h = historyItem!;
|
||||
@@ -359,6 +359,24 @@ class _QueueGroupedAlbumFilterRequest {
|
||||
);
|
||||
}
|
||||
|
||||
class _QueueHistoryStatsMemoEntry {
|
||||
final List<DownloadHistoryItem> historyItems;
|
||||
final List<LocalLibraryItem> localItems;
|
||||
final _HistoryStats stats;
|
||||
|
||||
const _QueueHistoryStatsMemoEntry({
|
||||
required this.historyItems,
|
||||
required this.localItems,
|
||||
required this.stats,
|
||||
});
|
||||
}
|
||||
|
||||
_QueueHistoryStatsMemoEntry? _queueHistoryStatsMemo;
|
||||
|
||||
String _queueHistoryAlbumKey(String albumName, String artistName) {
|
||||
return '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
}
|
||||
|
||||
String _queueFileExtLower(String filePath) {
|
||||
final slashIndex = filePath.lastIndexOf('/');
|
||||
final dotIndex = filePath.lastIndexOf('.');
|
||||
@@ -558,21 +576,31 @@ _HistoryStats _buildQueueHistoryStats(
|
||||
List<DownloadHistoryItem> items, [
|
||||
List<LocalLibraryItem> localItems = const [],
|
||||
]) {
|
||||
final memo = _queueHistoryStatsMemo;
|
||||
if (memo != null &&
|
||||
identical(memo.historyItems, items) &&
|
||||
identical(memo.localItems, localItems)) {
|
||||
return memo.stats;
|
||||
}
|
||||
|
||||
final albumCounts = <String, int>{};
|
||||
final albumMap = <String, List<DownloadHistoryItem>>{};
|
||||
for (final item in items) {
|
||||
final key =
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
final key = _queueHistoryAlbumKey(
|
||||
item.albumName,
|
||||
item.albumArtist ?? item.artistName,
|
||||
);
|
||||
albumCounts[key] = (albumCounts[key] ?? 0) + 1;
|
||||
albumMap.putIfAbsent(key, () => []).add(item);
|
||||
}
|
||||
|
||||
var singleTracks = 0;
|
||||
for (final item in items) {
|
||||
final key =
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
if ((albumCounts[key] ?? 0) <= 1) {
|
||||
singleTracks++;
|
||||
var albumCount = 0;
|
||||
for (final count in albumCounts.values) {
|
||||
if (count > 1) {
|
||||
albumCount++;
|
||||
} else {
|
||||
singleTracks += count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,11 +628,6 @@ _HistoryStats _buildQueueHistoryStats(
|
||||
});
|
||||
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
|
||||
|
||||
var albumCount = 0;
|
||||
for (final count in albumCounts.values) {
|
||||
if (count > 1) albumCount++;
|
||||
}
|
||||
|
||||
final downloadedPathKeys = <String>{};
|
||||
for (final item in items) {
|
||||
downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath));
|
||||
@@ -620,8 +643,10 @@ _HistoryStats _buildQueueHistoryStats(
|
||||
final localAlbumCounts = <String, int>{};
|
||||
final localAlbumMap = <String, List<LocalLibraryItem>>{};
|
||||
for (final item in dedupedLocalItems) {
|
||||
final key =
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
final key = _queueHistoryAlbumKey(
|
||||
item.albumName,
|
||||
item.albumArtist ?? item.artistName,
|
||||
);
|
||||
localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1;
|
||||
localAlbumMap.putIfAbsent(key, () => []).add(item);
|
||||
}
|
||||
@@ -664,7 +689,7 @@ _HistoryStats _buildQueueHistoryStats(
|
||||
});
|
||||
groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned));
|
||||
|
||||
return _HistoryStats(
|
||||
final stats = _HistoryStats(
|
||||
albumCounts: albumCounts,
|
||||
localAlbumCounts: localAlbumCounts,
|
||||
groupedAlbums: groupedAlbums,
|
||||
@@ -674,6 +699,12 @@ _HistoryStats _buildQueueHistoryStats(
|
||||
localAlbumCount: localAlbumCount,
|
||||
localSingleTracks: localSingleTracks,
|
||||
);
|
||||
_queueHistoryStatsMemo = _QueueHistoryStatsMemoEntry(
|
||||
historyItems: items,
|
||||
localItems: localItems,
|
||||
stats: stats,
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
|
||||
List<_GroupedAlbum> _queueFilterGroupedAlbums(
|
||||
@@ -1121,6 +1152,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
List<UnifiedLibraryItem> _cachedUnifiedLocal = const [];
|
||||
List<DownloadHistoryItem>? _cachedDownloadedPathKeysSource;
|
||||
Set<String> _cachedDownloadedPathKeys = const <String>{};
|
||||
final Map<String, List<String>> _localPathMatchKeysCache = {};
|
||||
List<LocalLibraryItem>? _cachedLocalSinglesSource;
|
||||
Map<String, int>? _cachedLocalSinglesAlbumCountsSource;
|
||||
List<LocalLibraryItem> _cachedLocalSingles = const [];
|
||||
final Map<String, _FilterContentData> _filterContentDataCache = {};
|
||||
List<DownloadHistoryItem>? _filterCacheAllHistoryItems;
|
||||
_HistoryStats? _filterCacheHistoryStats;
|
||||
@@ -1132,11 +1167,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
String? _filterCacheFormat;
|
||||
String? _filterCacheMetadata;
|
||||
String _filterCacheSortMode = 'latest';
|
||||
String? _filterSource; // null = all, 'downloaded', 'local'
|
||||
String? _filterQuality; // null = all, 'hires', 'cd', 'lossy'
|
||||
String? _filterFormat; // null = all, 'flac', 'mp3', 'm4a', 'opus', 'ogg'
|
||||
String? _filterMetadata; // null = all, 'complete', 'missing-*'
|
||||
String _sortMode = 'latest'; // 'latest', 'oldest', 'a-z', 'z-a'
|
||||
String? _filterSource;
|
||||
String? _filterQuality;
|
||||
String? _filterFormat;
|
||||
String? _filterMetadata;
|
||||
String _sortMode = 'latest';
|
||||
|
||||
double _effectiveTextScale() {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
||||
@@ -1264,9 +1299,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
if (localChanged) {
|
||||
_localSearchIndexCache.clear();
|
||||
_localPathMatchKeysCache.clear();
|
||||
_localFilterItemsCache = null;
|
||||
_localFilterQueryCache = '';
|
||||
_filteredLocalItemsCache = const [];
|
||||
_cachedLocalSinglesSource = null;
|
||||
_cachedLocalSinglesAlbumCountsSource = null;
|
||||
_cachedLocalSingles = const [];
|
||||
_cachedUnifiedLocalSource = null;
|
||||
_cachedUnifiedLocal = const [];
|
||||
}
|
||||
@@ -1356,6 +1395,32 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return _cachedDownloadedPathKeys;
|
||||
}
|
||||
|
||||
List<String> _localPathMatchKeys(LocalLibraryItem item) {
|
||||
final cached = _localPathMatchKeysCache[item.id];
|
||||
if (cached != null) return cached;
|
||||
final keys = buildPathMatchKeys(item.filePath).toList(growable: false);
|
||||
_localPathMatchKeysCache[item.id] = keys;
|
||||
return keys;
|
||||
}
|
||||
|
||||
List<LocalLibraryItem> _localSingleItems(
|
||||
List<LocalLibraryItem> items,
|
||||
Map<String, int> localAlbumCounts,
|
||||
) {
|
||||
if (identical(items, _cachedLocalSinglesSource) &&
|
||||
identical(localAlbumCounts, _cachedLocalSinglesAlbumCountsSource)) {
|
||||
return _cachedLocalSingles;
|
||||
}
|
||||
|
||||
final singles = items
|
||||
.where((item) => (localAlbumCounts[item.albumKey] ?? 0) == 1)
|
||||
.toList(growable: false);
|
||||
_cachedLocalSinglesSource = items;
|
||||
_cachedLocalSinglesAlbumCountsSource = localAlbumCounts;
|
||||
_cachedLocalSingles = singles;
|
||||
return singles;
|
||||
}
|
||||
|
||||
List<LocalLibraryItem> _filterLocalItems(
|
||||
List<LocalLibraryItem> items,
|
||||
String query,
|
||||
@@ -2036,7 +2101,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return quality.split('/').first;
|
||||
}
|
||||
|
||||
// Supports "MP3 320k", "Opus 256kbps", etc.
|
||||
final bitrateTextMatch = RegExp(
|
||||
r'(\d+)\s*k(?:bps)?',
|
||||
caseSensitive: false,
|
||||
@@ -2045,7 +2109,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return '${bitrateTextMatch.group(1)}k';
|
||||
}
|
||||
|
||||
// Supports legacy quality IDs like "opus_256" / "mp3_320".
|
||||
final bitrateIdMatch = RegExp(r'_(\d+)$').firstMatch(q);
|
||||
if (bitrateIdMatch != null) {
|
||||
return '${bitrateIdMatch.group(1)}k';
|
||||
@@ -2100,7 +2163,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
// Reload local library if we deleted any local items
|
||||
if (allItems.any(
|
||||
(i) =>
|
||||
_selectedIds.contains(i.id) && i.source == LibraryItemSource.local,
|
||||
@@ -2120,7 +2182,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip EXISTS: prefix from file path (legacy history items)
|
||||
String _cleanFilePath(String? filePath) {
|
||||
return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
|
||||
}
|
||||
@@ -2303,7 +2364,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
List<UnifiedLibraryItem> _applySorting(List<UnifiedLibraryItem> items) {
|
||||
if (_sortMode == 'latest') {
|
||||
return items; // Already sorted newest first from _getUnifiedItems
|
||||
return items;
|
||||
}
|
||||
final sorted = List<UnifiedLibraryItem>.of(items);
|
||||
switch (_sortMode) {
|
||||
@@ -2902,15 +2963,23 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
Future<void> _navigateToHistoryMetadataScreen(
|
||||
DownloadHistoryItem item,
|
||||
) async {
|
||||
DownloadHistoryItem item, {
|
||||
List<DownloadHistoryItem>? navigationItems,
|
||||
int? navigationIndex,
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(item.coverUrl);
|
||||
_searchFocusNode.unfocus();
|
||||
final beforeModTime = await _readFileModTimeMillis(item.filePath);
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
|
||||
slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
_searchFocusNode.unfocus();
|
||||
if (result == true) {
|
||||
@@ -2927,11 +2996,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToLocalMetadataScreen(LocalLibraryItem item) {
|
||||
void _navigateToLocalMetadataScreen(
|
||||
LocalLibraryItem item, {
|
||||
List<LocalLibraryItem>? navigationItems,
|
||||
int? navigationIndex,
|
||||
}) {
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.push(
|
||||
context,
|
||||
slidePageRoute<void>(page: TrackMetadataScreen(localItem: item)),
|
||||
slidePageRoute<void>(
|
||||
page: TrackMetadataScreen(
|
||||
localItem: item,
|
||||
localNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
),
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
}
|
||||
|
||||
@@ -2970,7 +3049,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate with unfocus pattern — unfocuses search before and after navigation.
|
||||
void _navigateWithUnfocus(Route<dynamic> route) {
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.of(context).push(route).then((_) => _searchFocusNode.unfocus());
|
||||
@@ -3200,14 +3278,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}) async {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
|
||||
// If in selection mode and the dragged item is selected, add ALL selected
|
||||
if (_isSelectionMode &&
|
||||
_selectedIds.isNotEmpty &&
|
||||
_selectedIds.contains(item.id)) {
|
||||
final selectedItems = allItems
|
||||
.where((e) => _selectedIds.contains(e.id))
|
||||
.toList();
|
||||
// Fallback: if allItems is empty or no match, at least add the dragged item
|
||||
if (selectedItems.isEmpty) {
|
||||
selectedItems.add(item);
|
||||
}
|
||||
@@ -3246,8 +3322,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a compact floating feedback widget shown while dragging a track.
|
||||
/// Shows the count when multiple tracks are selected and being dragged.
|
||||
Widget _buildDragFeedback(
|
||||
BuildContext context,
|
||||
UnifiedLibraryItem item,
|
||||
@@ -3371,7 +3445,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final selectionItems = getFilterData(
|
||||
historyFilterMode,
|
||||
).filteredUnifiedItems;
|
||||
// Only sync overlays when selection mode is active
|
||||
if (_isSelectionMode || _isPlaylistSelectionMode) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_isSelectionMode) {
|
||||
@@ -3627,12 +3700,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (filterMode == 'all') {
|
||||
localItemsForMerge = _filterLocalItems(localLibraryItems, query);
|
||||
} else {
|
||||
final localSingles = localLibraryItems
|
||||
.where((item) {
|
||||
final count = localAlbumCounts[item.albumKey] ?? 0;
|
||||
return count == 1;
|
||||
})
|
||||
.toList(growable: false);
|
||||
final localSingles = _localSingleItems(
|
||||
localLibraryItems,
|
||||
localAlbumCounts,
|
||||
);
|
||||
localItemsForMerge = _filterLocalItems(localSingles, query);
|
||||
}
|
||||
|
||||
@@ -3641,7 +3712,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
final dedupedUnifiedLocal = <UnifiedLibraryItem>[];
|
||||
for (final item in unifiedLocal) {
|
||||
final localPathKeys = buildPathMatchKeys(item.filePath);
|
||||
final localSource = item.localItem;
|
||||
final localPathKeys = localSource != null
|
||||
? _localPathMatchKeys(localSource)
|
||||
: buildPathMatchKeys(item.filePath);
|
||||
final overlapsDownloaded = localPathKeys.any(downloadedPathKeys.contains);
|
||||
if (!overlapsDownloaded) {
|
||||
dedupedUnifiedLocal.add(item);
|
||||
@@ -3776,7 +3850,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a Spotify-style collection list item (Wishlist, Loved, Playlists)
|
||||
Widget _buildCollectionListItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -3853,7 +3926,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a collection grid item for grid view mode
|
||||
Widget _buildCollectionGridItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -3936,7 +4008,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// Build a collection item for the unified "All" tab grid view.
|
||||
Widget _buildAllTabGridCollectionItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -4054,7 +4125,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a collection item for the unified "All" tab list view.
|
||||
Widget _buildAllTabListCollectionItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -4175,6 +4245,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final filteredUnifiedItems = filterData.filteredUnifiedItems;
|
||||
final totalTrackCount = filterData.totalTrackCount;
|
||||
final totalAlbumCount = filterData.totalAlbumCount;
|
||||
final downloadedNavigationItems = <DownloadHistoryItem>[];
|
||||
final downloadedNavigationIndexByUnifiedId = <String, int>{};
|
||||
final localNavigationItems = <LocalLibraryItem>[];
|
||||
final localNavigationIndexByUnifiedId = <String, int>{};
|
||||
|
||||
for (final item in filteredUnifiedItems) {
|
||||
final historyItem = item.historyItem;
|
||||
if (historyItem != null) {
|
||||
downloadedNavigationIndexByUnifiedId[item.id] =
|
||||
downloadedNavigationItems.length;
|
||||
downloadedNavigationItems.add(historyItem);
|
||||
}
|
||||
|
||||
final localItem = item.localItem;
|
||||
if (localItem != null) {
|
||||
localNavigationIndexByUnifiedId[item.id] = localNavigationItems.length;
|
||||
localNavigationItems.add(localItem);
|
||||
}
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
@@ -4207,8 +4296,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
// Collection folders as list items (Spotify-style) in "All" tab
|
||||
// are now rendered inline with tracks below (unified sliver)
|
||||
if ((filteredGroupedAlbums.isNotEmpty ||
|
||||
filteredGroupedLocalAlbums.isNotEmpty) &&
|
||||
filterMode == 'albums')
|
||||
@@ -4369,12 +4456,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems:
|
||||
downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
),
|
||||
),
|
||||
child: _buildUnifiedGridItem(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems:
|
||||
downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -4422,12 +4523,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems:
|
||||
downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
),
|
||||
),
|
||||
child: _buildUnifiedLibraryItem(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems: downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -4490,6 +4604,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems: downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
),
|
||||
);
|
||||
}, childCount: filteredUnifiedItems.length),
|
||||
@@ -4504,6 +4624,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems: downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
),
|
||||
);
|
||||
}, childCount: filteredUnifiedItems.length),
|
||||
@@ -4695,7 +4821,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Album grid item for local library albums
|
||||
Widget _buildLocalAlbumGridItem(
|
||||
BuildContext context,
|
||||
_GroupedLocalAlbum album,
|
||||
@@ -4913,12 +5038,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
mp3Path: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
@@ -4926,6 +5053,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4958,7 +5086,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
Future<bool> _reEnrichQueueLocalTrack(LocalLibraryItem item) async {
|
||||
Future<bool> _reEnrichQueueLocalTrack(
|
||||
LocalLibraryItem item, {
|
||||
List<String>? updateFields,
|
||||
}) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final request = <String, dynamic>{
|
||||
@@ -4981,6 +5112,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
'copyright': '',
|
||||
'duration_ms': durationMs,
|
||||
'search_online': true,
|
||||
// ignore: use_null_aware_elements
|
||||
if (updateFields != null) 'update_fields': updateFields,
|
||||
};
|
||||
|
||||
final result = await PlatformBridge.reEnrichFile(request);
|
||||
@@ -5144,31 +5277,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.trackReEnrich),
|
||||
content: Text(
|
||||
'${context.l10n.trackReEnrichOnlineSubtitle}\n\n'
|
||||
'${context.l10n.downloadedAlbumSelectedCount(selectedLocalItems.length)}',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(context.l10n.trackReEnrich),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Hide the selection overlay: set the flag (prevents build() from
|
||||
// re-inserting via postFrameCallback) and remove the entry immediately.
|
||||
setState(() => _isSelectionMode = false);
|
||||
_hideSelectionOverlay();
|
||||
|
||||
final selection = await showReEnrichFieldDialog(
|
||||
context,
|
||||
selectedCount: selectedLocalItems.length,
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) {
|
||||
if (selection == null || !mounted) {
|
||||
// Cancelled — restore selection mode; the next build cycle will
|
||||
// re-create the overlay via _syncSelectionOverlay in postFrameCallback.
|
||||
if (mounted) setState(() => _isSelectionMode = true);
|
||||
return;
|
||||
}
|
||||
|
||||
final updateFields = selection.isAll ? null : selection.fields;
|
||||
|
||||
var successCount = 0;
|
||||
final total = selectedLocalItems.length;
|
||||
|
||||
@@ -5194,7 +5321,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
|
||||
try {
|
||||
final ok = await _reEnrichQueueLocalTrack(item);
|
||||
final ok = await _reEnrichQueueLocalTrack(
|
||||
item,
|
||||
updateFields: updateFields,
|
||||
);
|
||||
if (ok) {
|
||||
successCount++;
|
||||
}
|
||||
@@ -5269,7 +5399,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Share SAF content URIs via native intent
|
||||
if (safUris.isNotEmpty) {
|
||||
try {
|
||||
if (safUris.length == 1) {
|
||||
@@ -5280,7 +5409,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Share regular files via SharePlus
|
||||
if (filesToShare.isNotEmpty) {
|
||||
await SharePlus.instance.share(ShareParams(files: filesToShare));
|
||||
}
|
||||
@@ -5801,13 +5929,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
||||
? '.opus'
|
||||
: '.mp3';
|
||||
String newExt;
|
||||
String mimeType;
|
||||
switch (targetFormat.toLowerCase()) {
|
||||
case 'opus':
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'flac':
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
}
|
||||
final newFileName = '$baseName$newExt';
|
||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
||||
? 'audio/opus'
|
||||
: 'audio/mpeg';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
treeUri: treeUri,
|
||||
@@ -5832,7 +5974,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
await LibraryDatabase.instance.deleteByPath(item.filePath);
|
||||
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||
item: item.localItem!,
|
||||
newFilePath: safUri,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -5851,7 +5998,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
} else if (item.localItem != null) {
|
||||
await LibraryDatabase.instance.deleteByPath(item.filePath);
|
||||
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||
item: item.localItem!,
|
||||
newFilePath: newPath,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
}
|
||||
|
||||
successCount++;
|
||||
@@ -6394,7 +6546,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable filter button with badge showing active filter count.
|
||||
Widget _buildFilterButton(
|
||||
BuildContext context,
|
||||
List<UnifiedLibraryItem> unifiedItems,
|
||||
@@ -6489,7 +6640,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
// Network URL cover (downloaded items)
|
||||
if (item.coverUrl != null) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -6507,7 +6657,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
// Local file cover (from library scan)
|
||||
if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -6524,7 +6673,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
// Placeholder (no cover)
|
||||
if (size != null) {
|
||||
return buildPlaceholder();
|
||||
}
|
||||
@@ -6534,12 +6682,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a unified library item (merged downloaded + local)
|
||||
Widget _buildUnifiedLibraryItem(
|
||||
BuildContext context,
|
||||
UnifiedLibraryItem item,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
ColorScheme colorScheme, {
|
||||
required List<DownloadHistoryItem> downloadedNavigationItems,
|
||||
required int? downloadedNavigationIndex,
|
||||
required List<LocalLibraryItem> localNavigationItems,
|
||||
required int? localNavigationIndex,
|
||||
}) {
|
||||
final fileExistsListenable = _fileExistsListenable(item.filePath);
|
||||
final isSelected = _selectedIds.contains(item.id);
|
||||
final date = item.addedAt;
|
||||
@@ -6569,9 +6720,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(item.id)
|
||||
: isDownloaded
|
||||
? () => _navigateToHistoryMetadataScreen(item.historyItem!)
|
||||
? () => _navigateToHistoryMetadataScreen(
|
||||
item.historyItem!,
|
||||
navigationItems: downloadedNavigationItems,
|
||||
navigationIndex: downloadedNavigationIndex,
|
||||
)
|
||||
: item.localItem != null
|
||||
? () => _navigateToLocalMetadataScreen(item.localItem!)
|
||||
? () => _navigateToLocalMetadataScreen(
|
||||
item.localItem!,
|
||||
navigationItems: localNavigationItems,
|
||||
navigationIndex: localNavigationIndex,
|
||||
)
|
||||
: () => _openFile(
|
||||
item.filePath,
|
||||
title: item.trackName,
|
||||
@@ -6742,12 +6901,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build unified grid item for grid view mode
|
||||
Widget _buildUnifiedGridItem(
|
||||
BuildContext context,
|
||||
UnifiedLibraryItem item,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
ColorScheme colorScheme, {
|
||||
required List<DownloadHistoryItem> downloadedNavigationItems,
|
||||
required int? downloadedNavigationIndex,
|
||||
required List<LocalLibraryItem> localNavigationItems,
|
||||
required int? localNavigationIndex,
|
||||
}) {
|
||||
final fileExistsListenable = _fileExistsListenable(item.filePath);
|
||||
final isSelected = _selectedIds.contains(item.id);
|
||||
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
||||
@@ -6756,9 +6918,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(item.id)
|
||||
: isDownloaded
|
||||
? () => _navigateToHistoryMetadataScreen(item.historyItem!)
|
||||
? () => _navigateToHistoryMetadataScreen(
|
||||
item.historyItem!,
|
||||
navigationItems: downloadedNavigationItems,
|
||||
navigationIndex: downloadedNavigationIndex,
|
||||
)
|
||||
: item.localItem != null
|
||||
? () => _navigateToLocalMetadataScreen(item.localItem!)
|
||||
? () => _navigateToLocalMetadataScreen(
|
||||
item.localItem!,
|
||||
navigationItems: localNavigationItems,
|
||||
navigationIndex: localNavigationIndex,
|
||||
)
|
||||
: () => _openFile(
|
||||
item.filePath,
|
||||
title: item.trackName,
|
||||
@@ -7032,7 +7202,6 @@ class _FilterChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable action button for selection mode bottom bar
|
||||
class _SelectionActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user