mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 580e2b6ab8 | |||
| 298b89acf1 | |||
| b6e2675b86 | |||
| 7786501cd1 | |||
| bc4b5a5b17 | |||
| 5d160f71f1 | |||
| 20cf7d49e5 | |||
| 88d22477d5 | |||
| b77def62f4 | |||
| a15313e573 | |||
| 4a90d3f38a | |||
| d4e56567a2 | |||
| 277a7f24fa | |||
| 3735aaf3bd | |||
| 3bbe8553ab | |||
| ca0cfa4524 | |||
| 8b185e964a | |||
| c104a5d8a3 | |||
| 8615cde898 | |||
| 207c0653cc | |||
| de756e5d86 | |||
| fd5db3f7b6 | |||
| d087da9409 | |||
| 43469a7ef2 | |||
| add4af831e |
@@ -20,6 +20,10 @@ android {
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
|
||||
@@ -86,6 +86,20 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="music.youtube.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
@@ -307,8 +308,40 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun forceFilenameExt(name: String, outputExt: String): String {
|
||||
val normalizedExt = normalizeExt(outputExt)
|
||||
if (normalizedExt.isBlank()) return sanitizeFilename(name)
|
||||
|
||||
val safeName = sanitizeFilename(name)
|
||||
val lower = safeName.lowercase(Locale.ROOT)
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp3", ".opus", ".lrc")
|
||||
for (knownExt in knownExts) {
|
||||
if (lower.endsWith(knownExt)) {
|
||||
return safeName.dropLast(knownExt.length) + normalizedExt
|
||||
}
|
||||
}
|
||||
return safeName + normalizedExt
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||
.filter { ch ->
|
||||
val code = ch.code
|
||||
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
|
||||
code == 0x7F ||
|
||||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
|
||||
}
|
||||
.trim()
|
||||
.trim('.', ' ')
|
||||
|
||||
sanitized = sanitized
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.replace(Regex("_+"), "_")
|
||||
.trim('_', ' ')
|
||||
|
||||
return if (sanitized.isBlank()) "Unknown" else sanitized
|
||||
}
|
||||
|
||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
@@ -368,6 +401,43 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return current
|
||||
}
|
||||
|
||||
private fun createOrReuseDocumentFile(
|
||||
parent: DocumentFile,
|
||||
mimeType: String,
|
||||
fileName: String
|
||||
): DocumentFile? {
|
||||
val safeFileName = sanitizeFilename(fileName)
|
||||
if (safeFileName.isBlank()) return null
|
||||
|
||||
synchronized(safDirLock) {
|
||||
val existing = parent.findFile(safeFileName)
|
||||
if (existing != null && existing.isFile) {
|
||||
return existing
|
||||
}
|
||||
|
||||
val created = parent.createFile(mimeType, safeFileName) ?: return null
|
||||
val createdName = created.name ?: safeFileName
|
||||
if (createdName == safeFileName) {
|
||||
return created
|
||||
}
|
||||
|
||||
// SAF can auto-rename to "name (1)" when another writer wins the race
|
||||
// between findFile() and createFile(). Prefer the exact sibling if it
|
||||
// appeared, and discard the duplicate document we just created.
|
||||
val winner = parent.findFile(safeFileName)
|
||||
if (winner != null && winner.isFile) {
|
||||
if (winner.uri != created.uri) {
|
||||
try {
|
||||
created.delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return winner
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetSafScanProgress() {
|
||||
synchronized(safScanLock) {
|
||||
safScanProgress = SafScanProgress()
|
||||
@@ -599,12 +669,12 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||
val provided = req.optString("saf_file_name", "")
|
||||
if (provided.isNotBlank()) return sanitizeFilename(provided)
|
||||
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
|
||||
|
||||
val trackName = req.optString("track_name", "track")
|
||||
val artistName = req.optString("artist_name", "")
|
||||
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
|
||||
return sanitizeFilename(baseName) + outputExt
|
||||
return forceFilenameExt(baseName, outputExt)
|
||||
}
|
||||
|
||||
private fun errorJson(message: String): String {
|
||||
@@ -918,8 +988,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val targetDir = ensureDocumentDir(treeUri, relativeDir)
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
val existingFile = targetDir.findFile(fileName)
|
||||
val document = existingFile ?: targetDir.createFile(mimeType, fileName)
|
||||
var document = createOrReuseDocumentFile(targetDir, mimeType, fileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
@@ -944,16 +1013,34 @@ 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")
|
||||
}
|
||||
val actualExt = normalizeExt(srcFile.extension)
|
||||
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||
val actualFileName = buildSafFileName(req, actualExt)
|
||||
val actualMimeType = mimeTypeForExt(actualExt)
|
||||
val replacement = createOrReuseDocumentFile(
|
||||
targetDir,
|
||||
actualMimeType,
|
||||
actualFileName,
|
||||
)
|
||||
?: throw IllegalStateException("failed to create SAF output with actual extension")
|
||||
if (replacement.uri != document.uri) {
|
||||
document.delete()
|
||||
document = replacement
|
||||
}
|
||||
}
|
||||
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
srcFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||
srcFile.delete()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}")
|
||||
return errorJson("Failed to copy extension output to SAF: ${e.message}")
|
||||
}
|
||||
}
|
||||
respObj.put("file_path", document.uri.toString())
|
||||
@@ -1931,9 +2018,54 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
|
||||
override fun shouldHandleDeeplinking(): Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleExtensionOAuthIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleExtensionOAuthIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver Spotify (or other) OAuth authorization code to the extension runtime
|
||||
* and run its token exchange (e.g. completeSpotifyLogin). State must be the extension id.
|
||||
*/
|
||||
private fun handleExtensionOAuthIntent(intent: Intent?) {
|
||||
val uri = intent?.data ?: return
|
||||
if (!uri.scheme.equals("spotiflac", ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
val host = (uri.host ?: "").lowercase(Locale.US)
|
||||
val path = (uri.path ?: "").lowercase(Locale.US)
|
||||
val isCallback =
|
||||
host == "callback" ||
|
||||
host == "spotify-callback" ||
|
||||
path.contains("callback")
|
||||
if (!isCallback) {
|
||||
return
|
||||
}
|
||||
val code = uri.getQueryParameter("code")?.trim().orEmpty()
|
||||
if (code.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val extId = uri.getQueryParameter("state")?.trim().orEmpty()
|
||||
if (extId.isEmpty()) {
|
||||
android.util.Log.w("SpotiFLAC", "Extension OAuth redirect missing state (extension id)")
|
||||
return
|
||||
}
|
||||
intent.data = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.setExtensionAuthCodeByID(extId, code)
|
||||
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
|
||||
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -1949,6 +2081,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
Gobackend.setAppVersion(BuildConfig.VERSION_NAME)
|
||||
|
||||
// Always-enabled back callback to ensure back presses reach Flutter.
|
||||
// Nested tab navigators can incorrectly set frameworkHandlesBack(false),
|
||||
@@ -2133,7 +2266,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
result.error("saf_pending", "SAF picker already active", null)
|
||||
return@launch
|
||||
}
|
||||
pendingSafTreeResult = result
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
intent.addFlags(
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
@@ -2141,7 +2273,24 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||
)
|
||||
safTreeLauncher.launch(intent)
|
||||
val resolver = intent.resolveActivity(packageManager)
|
||||
if (resolver == null) {
|
||||
result.error("saf_unavailable", "No folder picker available on this device", null)
|
||||
return@launch
|
||||
}
|
||||
pendingSafTreeResult = result
|
||||
try {
|
||||
android.util.Log.i("SpotiFLAC", "Launching SAF picker via $resolver")
|
||||
safTreeLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
pendingSafTreeResult = null
|
||||
android.util.Log.e("SpotiFLAC", "Failed to launch SAF picker: ${e.message}", e)
|
||||
result.error(
|
||||
"saf_launch_failed",
|
||||
e.message ?: "Failed to launch folder picker",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
"safExists" -> {
|
||||
val uriStr = call.argument<String>("uri") ?: ""
|
||||
@@ -2216,7 +2365,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
|
||||
val existing = dir.findFile(fileName)
|
||||
val createdNew = existing == null
|
||||
val doc = existing ?: dir.createFile(mimeType, fileName) ?: return@withContext null
|
||||
val doc = createOrReuseDocumentFile(dir, mimeType, fileName)
|
||||
?: return@withContext null
|
||||
if (!writeUriFromPath(doc.uri, srcPath)) {
|
||||
if (createdNew) {
|
||||
doc.delete()
|
||||
@@ -2714,16 +2864,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"searchDeezerAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchTidalAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
@@ -2965,6 +3105,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) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||
|
||||
type cancelEntry struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
canceled bool
|
||||
}
|
||||
@@ -27,8 +28,21 @@ func initDownloadCancel(itemID string) context.Context {
|
||||
cancelMu.Lock()
|
||||
defer cancelMu.Unlock()
|
||||
|
||||
if entry, ok := cancelMap[itemID]; ok {
|
||||
if entry.ctx == nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
entry.ctx = ctx
|
||||
entry.cancel = cancel
|
||||
if entry.canceled && entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
return entry.ctx
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelMap[itemID] = &cancelEntry{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
}
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr"
|
||||
|
||||
type DeezerDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func isLikelySpotifyTrackID(value string) bool {
|
||||
if len(value) != 22 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||
deezerID := strings.TrimSpace(req.DeezerID)
|
||||
if deezerID == "" {
|
||||
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
|
||||
deezerID = strings.TrimSpace(prefixed)
|
||||
}
|
||||
}
|
||||
if deezerID != "" {
|
||||
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
|
||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||
}
|
||||
return trackURL, nil
|
||||
}
|
||||
|
||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
|
||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
// Fall through to ISRC search instead of using wrong track.
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isrc := strings.TrimSpace(req.ISRC)
|
||||
if isrc != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||
if err == nil && track != nil {
|
||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
|
||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||
}
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||
}
|
||||
|
||||
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||
if err != nil {
|
||||
return nil // Can't verify — don't block the download.
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: trackResp.Track.Name,
|
||||
ArtistName: trackResp.Track.Artists,
|
||||
ISRC: trackResp.Track.ISRC,
|
||||
Duration: trackResp.Track.DurationMS / 1000,
|
||||
SkipNameVerification: skipNameVerification,
|
||||
}
|
||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
type deezerMusicDLRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
|
||||
payload := deezerMusicDLRequest{
|
||||
Platform: "deezer",
|
||||
URL: deezerTrackURL,
|
||||
}
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("MusicDL request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
|
||||
}
|
||||
|
||||
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||
}
|
||||
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
if data, ok := raw["data"].(map[string]any); ok {
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no download URL found in MusicDL response")
|
||||
}
|
||||
|
||||
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
|
||||
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
|
||||
|
||||
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
|
||||
|
||||
ctx := context.Background()
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := GetDownloadClient().Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
deezerClient := GetDeezerClient()
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
embedLyrics = false
|
||||
}
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
coverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
embedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}()
|
||||
|
||||
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
||||
if deezerURLErr != nil {
|
||||
return DeezerDownloadResult{}, fmt.Errorf(
|
||||
"deezer download failed: could not resolve Deezer URL: %w",
|
||||
deezerURLErr,
|
||||
)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
||||
downloadErr := deezerClient.DownloadFromMusicDL(
|
||||
deezerTrackURL,
|
||||
outputPath,
|
||||
req.OutputFD,
|
||||
req.ItemID,
|
||||
)
|
||||
if downloadErr != nil {
|
||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return DeezerDownloadResult{}, fmt.Errorf(
|
||||
"deezer download failed via MusicDL: %w",
|
||||
downloadErr,
|
||||
)
|
||||
}
|
||||
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
}
|
||||
|
||||
if isSafOutput || !req.EmbedMetadata {
|
||||
if !req.EmbedMetadata {
|
||||
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
||||
} else {
|
||||
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
}
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
}
|
||||
|
||||
bitDepth, sampleRate := 0, 0
|
||||
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return DeezerDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
+347
-141
@@ -5,12 +5,16 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||
@@ -33,6 +37,113 @@ func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
|
||||
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
||||
}
|
||||
|
||||
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
|
||||
|
||||
type musicBrainzTag struct {
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type musicBrainzRecordingResponse struct {
|
||||
Recordings []struct {
|
||||
Tags []musicBrainzTag `json:"tags"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
|
||||
func formatMusicBrainzGenre(tags []musicBrainzTag) string {
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
caser := cases.Title(language.English)
|
||||
seen := make(map[string]struct{}, len(tags))
|
||||
maxCount := -1
|
||||
bestTag := ""
|
||||
|
||||
for _, tag := range tags {
|
||||
name := strings.TrimSpace(tag.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToLower(name)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
|
||||
formatted := caser.String(name)
|
||||
if tag.Count > maxCount {
|
||||
maxCount = tag.Count
|
||||
bestTag = formatted
|
||||
}
|
||||
}
|
||||
|
||||
return bestTag
|
||||
}
|
||||
|
||||
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
|
||||
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if normalizedISRC == "" {
|
||||
return "", fmt.Errorf("no ISRC provided")
|
||||
}
|
||||
|
||||
client := NewMetadataHTTPClient(10 * time.Second)
|
||||
query := fmt.Sprintf("isrc:%s", normalizedISRC)
|
||||
reqURL := fmt.Sprintf(
|
||||
"%s/recording?query=%s&fmt=json&inc=tags",
|
||||
musicBrainzAPIBase,
|
||||
url.QueryEscape(query),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
var resp *http.Response
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
resp, lastErr = client.Do(req)
|
||||
if lastErr == nil && resp.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if attempt < 2 {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("MusicBrainz request failed without response")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var payload musicBrainzRecordingResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(payload.Recordings) == 0 {
|
||||
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
|
||||
}
|
||||
|
||||
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
|
||||
if genre == "" {
|
||||
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
|
||||
}
|
||||
return genre, nil
|
||||
}
|
||||
|
||||
type DownloadRequest struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
@@ -74,33 +185,34 @@ type DownloadRequest struct {
|
||||
}
|
||||
|
||||
type DownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResult struct {
|
||||
@@ -123,8 +235,15 @@ type DownloadResult struct {
|
||||
Composer string
|
||||
LyricsLRC string
|
||||
DecryptionKey string
|
||||
Decryption *DownloadDecryptionInfo
|
||||
}
|
||||
|
||||
var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return GetDeezerClient().GetExtendedMetadataByISRC(ctx, isrc)
|
||||
}
|
||||
|
||||
var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC
|
||||
|
||||
type reEnrichRequest struct {
|
||||
FilePath string `json:"file_path"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
@@ -183,6 +302,12 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
}
|
||||
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
if track.Name != "" {
|
||||
req.TrackName = track.Name
|
||||
}
|
||||
if track.Artists != "" {
|
||||
req.ArtistName = track.Artists
|
||||
}
|
||||
if track.AlbumName != "" {
|
||||
req.AlbumName = track.AlbumName
|
||||
}
|
||||
@@ -236,6 +361,29 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
}
|
||||
}
|
||||
|
||||
func isPlaceholderReEnrichValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "unknown", "unknown artist", "unknown title", "unknown album":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildReEnrichSearchQuery(req reEnrichRequest) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if !isPlaceholderReEnrichValue(req.TrackName) {
|
||||
parts = append(parts, strings.TrimSpace(req.TrackName))
|
||||
}
|
||||
if !isPlaceholderReEnrichValue(req.ArtistName) {
|
||||
parts = append(parts, strings.TrimSpace(req.ArtistName))
|
||||
}
|
||||
if len(parts) == 0 && !isPlaceholderReEnrichValue(req.AlbumName) {
|
||||
parts = append(parts, strings.TrimSpace(req.AlbumName))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
||||
return DownloadRequest{
|
||||
TrackName: req.TrackName,
|
||||
@@ -256,6 +404,12 @@ func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
||||
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
|
||||
metadata := map[string]string{}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
if req.TrackName != "" {
|
||||
metadata["TITLE"] = req.TrackName
|
||||
}
|
||||
if req.ArtistName != "" {
|
||||
metadata["ARTIST"] = req.ArtistName
|
||||
}
|
||||
if req.AlbumName != "" {
|
||||
metadata["ALBUM"] = req.AlbumName
|
||||
}
|
||||
@@ -599,6 +753,7 @@ func buildDownloadSuccessResponse(
|
||||
Composer: composer,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,6 +796,75 @@ func enrichResultQualityFromFile(result *DownloadResult) {
|
||||
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
|
||||
}
|
||||
|
||||
func applyExtendedMetadataFields(
|
||||
genre *string,
|
||||
label *string,
|
||||
copyright *string,
|
||||
extMeta *AlbumExtendedMetadata,
|
||||
) {
|
||||
if extMeta == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if genre != nil && *genre == "" && extMeta.Genre != "" {
|
||||
*genre = extMeta.Genre
|
||||
}
|
||||
if label != nil && *label == "" && extMeta.Label != "" {
|
||||
*label = extMeta.Label
|
||||
}
|
||||
if copyright != nil && *copyright == "" && extMeta.Copyright != "" {
|
||||
*copyright = extMeta.Copyright
|
||||
}
|
||||
}
|
||||
|
||||
func enrichExtraMetadataByISRC(
|
||||
logPrefix string,
|
||||
isrc string,
|
||||
genre *string,
|
||||
label *string,
|
||||
copyright *string,
|
||||
) {
|
||||
normalizedISRC := strings.TrimSpace(isrc)
|
||||
if normalizedISRC == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
extMeta, err := fetchDeezerExtendedMetadataByISRC(ctx, normalizedISRC)
|
||||
if err != nil {
|
||||
GoLog("[%s] Failed to get extended metadata from Deezer: %v\n", logPrefix, err)
|
||||
}
|
||||
applyExtendedMetadataFields(genre, label, copyright, extMeta)
|
||||
|
||||
if genre != nil && *genre == "" {
|
||||
musicBrainzGenre, err := fetchMusicBrainzGenreByISRC(normalizedISRC)
|
||||
if err != nil {
|
||||
GoLog("[%s] Failed to get genre from MusicBrainz: %v\n", logPrefix, err)
|
||||
} else if musicBrainzGenre != "" {
|
||||
*genre = musicBrainzGenre
|
||||
GoLog("[%s] Genre fallback from MusicBrainz: %s\n", logPrefix, *genre)
|
||||
}
|
||||
}
|
||||
|
||||
currentGenre := ""
|
||||
currentLabel := ""
|
||||
currentCopyright := ""
|
||||
if genre != nil {
|
||||
currentGenre = *genre
|
||||
}
|
||||
if label != nil {
|
||||
currentLabel = *label
|
||||
}
|
||||
if copyright != nil {
|
||||
currentCopyright = *copyright
|
||||
}
|
||||
if currentGenre != "" || currentLabel != "" || currentCopyright != "" {
|
||||
GoLog("[%s] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", logPrefix, currentGenre, currentLabel, currentCopyright)
|
||||
}
|
||||
}
|
||||
|
||||
func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
@@ -650,30 +874,13 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
deezerClient := GetDeezerClient()
|
||||
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
if err != nil || extMeta == nil {
|
||||
if err != nil {
|
||||
GoLog("[DownloadWithFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if req.Genre == "" && extMeta.Genre != "" {
|
||||
req.Genre = extMeta.Genre
|
||||
}
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
|
||||
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
enrichExtraMetadataByISRC(
|
||||
"DownloadWithFallback",
|
||||
req.ISRC,
|
||||
&req.Genre,
|
||||
&req.Label,
|
||||
&req.Copyright,
|
||||
)
|
||||
}
|
||||
|
||||
func applySongLinkRegionFromRequest(req *DownloadRequest) {
|
||||
@@ -746,24 +953,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
}
|
||||
}
|
||||
err = qobuzErr
|
||||
case "deezer":
|
||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||
if deezerErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: deezerResult.FilePath,
|
||||
BitDepth: deezerResult.BitDepth,
|
||||
SampleRate: deezerResult.SampleRate,
|
||||
Title: deezerResult.Title,
|
||||
Artist: deezerResult.Artist,
|
||||
Album: deezerResult.Album,
|
||||
ReleaseDate: deezerResult.ReleaseDate,
|
||||
TrackNumber: deezerResult.TrackNumber,
|
||||
DiscNumber: deezerResult.DiscNumber,
|
||||
ISRC: deezerResult.ISRC,
|
||||
LyricsLRC: deezerResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = deezerErr
|
||||
default:
|
||||
return errorResponse("Unknown service: " + req.Service)
|
||||
}
|
||||
@@ -814,7 +1003,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
||||
serviceNormalized := strings.ToLower(serviceRaw)
|
||||
|
||||
normalizedReq := req
|
||||
if isBuiltInProvider(serviceNormalized) {
|
||||
if isBuiltInDownloadProvider(serviceNormalized) {
|
||||
normalizedReq.Service = serviceNormalized
|
||||
}
|
||||
|
||||
@@ -827,7 +1016,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
||||
if req.UseExtensions {
|
||||
// Respect strict mode when auto fallback is disabled:
|
||||
// for built-in providers, route directly to selected service only.
|
||||
if !req.UseFallback && isBuiltInProvider(serviceNormalized) {
|
||||
if !req.UseFallback && isBuiltInDownloadProvider(serviceNormalized) {
|
||||
return DownloadTrack(normalizedJSON)
|
||||
}
|
||||
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
|
||||
@@ -866,9 +1055,9 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
|
||||
enrichRequestExtendedMetadata(&req)
|
||||
|
||||
allServices := []string{"tidal", "qobuz", "deezer"}
|
||||
allServices := []string{"tidal", "qobuz"}
|
||||
preferredService := req.Service
|
||||
if preferredService == "" {
|
||||
if !isBuiltInDownloadProvider(preferredService) {
|
||||
preferredService = "tidal"
|
||||
}
|
||||
|
||||
@@ -934,26 +1123,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||
}
|
||||
err = qobuzErr
|
||||
case "deezer":
|
||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||
if deezerErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: deezerResult.FilePath,
|
||||
BitDepth: deezerResult.BitDepth,
|
||||
SampleRate: deezerResult.SampleRate,
|
||||
Title: deezerResult.Title,
|
||||
Artist: deezerResult.Artist,
|
||||
Album: deezerResult.Album,
|
||||
ReleaseDate: deezerResult.ReleaseDate,
|
||||
TrackNumber: deezerResult.TrackNumber,
|
||||
DiscNumber: deezerResult.DiscNumber,
|
||||
ISRC: deezerResult.ISRC,
|
||||
LyricsLRC: deezerResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(deezerErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Deezer error: %v\n", deezerErr)
|
||||
}
|
||||
err = deezerErr
|
||||
}
|
||||
|
||||
if err != nil && errors.Is(err, ErrDownloadCancelled) {
|
||||
@@ -1316,6 +1485,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
lower := strings.ToLower(filePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
|
||||
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
|
||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||
|
||||
if isFlac {
|
||||
@@ -1361,6 +1531,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
DiscNumber: discNum,
|
||||
TotalDiscs: totalDiscs,
|
||||
ISRC: fields["isrc"],
|
||||
Lyrics: fields["lyrics"],
|
||||
Genre: fields["genre"],
|
||||
Label: fields["label"],
|
||||
Copyright: fields["copyright"],
|
||||
@@ -1427,6 +1598,19 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
if isM4AFile && hasOnlyM4AReplayGainFields(fields) {
|
||||
if err := EditM4AReplayGain(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "native_m4a_replaygain",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "ffmpeg",
|
||||
@@ -1436,6 +1620,29 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
|
||||
allowed := map[string]struct{}{
|
||||
"replaygain_track_gain": {},
|
||||
"replaygain_track_peak": {},
|
||||
"replaygain_album_gain": {},
|
||||
"replaygain_album_peak": {},
|
||||
}
|
||||
|
||||
hasReplayGain := false
|
||||
for key, value := range fields {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok {
|
||||
hasReplayGain = true
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return hasReplayGain
|
||||
}
|
||||
|
||||
func SetDownloadDirectory(path string) error {
|
||||
return setDownloadDir(path)
|
||||
}
|
||||
@@ -1672,24 +1879,6 @@ func ClearTrackIDCache() {
|
||||
ClearTrackCache()
|
||||
}
|
||||
|
||||
func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit, filter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
|
||||
@@ -2295,12 +2484,9 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
|
||||
// When search_online is true, search for metadata from internet using the
|
||||
// configured metadata-provider priority.
|
||||
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
|
||||
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
|
||||
searchQuery := req.TrackName + " " + req.ArtistName
|
||||
if req.SearchOnline {
|
||||
found := false
|
||||
|
||||
deezerClient := GetDeezerClient()
|
||||
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
||||
manager := getExtensionManager()
|
||||
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
|
||||
@@ -2310,36 +2496,28 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
found = true
|
||||
}
|
||||
|
||||
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||
if searchErr == nil && len(tracks) > 0 {
|
||||
track := selectBestReEnrichTrack(req, tracks)
|
||||
if track != nil {
|
||||
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
|
||||
applyReEnrichTrackMetadata(&req, *track)
|
||||
found = true
|
||||
searchQuery := buildReEnrichSearchQuery(req)
|
||||
if searchQuery != "" {
|
||||
GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery)
|
||||
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||
if searchErr == nil && len(tracks) > 0 {
|
||||
track := selectBestReEnrichTrack(req, tracks)
|
||||
if track != nil {
|
||||
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
|
||||
applyReEnrichTrackMetadata(&req, *track)
|
||||
found = true
|
||||
}
|
||||
} else if searchErr != nil {
|
||||
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||
}
|
||||
} else if searchErr != nil {
|
||||
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||
} else {
|
||||
GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
|
||||
}
|
||||
|
||||
// Try to get extended metadata from Deezer if not already set
|
||||
// Try to enrich extra metadata from ISRC if not already set.
|
||||
if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
if err == nil && extMeta != nil {
|
||||
if req.Genre == "" && extMeta.Genre != "" {
|
||||
req.Genre = extMeta.Genre
|
||||
}
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
|
||||
}
|
||||
|
||||
if !found {
|
||||
@@ -2439,6 +2617,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
"duration_ms": req.DurationMs,
|
||||
}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
enrichedMeta["track_name"] = req.TrackName
|
||||
enrichedMeta["artist_name"] = req.ArtistName
|
||||
enrichedMeta["album_name"] = req.AlbumName
|
||||
enrichedMeta["album_artist"] = req.AlbumArtist
|
||||
}
|
||||
@@ -2471,6 +2651,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
metadata.Title = req.TrackName
|
||||
metadata.Artist = req.ArtistName
|
||||
metadata.Album = req.AlbumName
|
||||
metadata.AlbumArtist = req.AlbumArtist
|
||||
}
|
||||
@@ -2665,6 +2847,30 @@ func GetProviderPriorityJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SetExtensionFallbackProviderIDsJSON(providerIDsJSON string) error {
|
||||
if strings.TrimSpace(providerIDsJSON) == "" {
|
||||
SetExtensionFallbackProviderIDs(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
var providerIDs []string
|
||||
if err := json.Unmarshal([]byte(providerIDsJSON), &providerIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
SetExtensionFallbackProviderIDs(providerIDs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetExtensionFallbackProviderIDsJSON() (string, error) {
|
||||
providerIDs := GetExtensionFallbackProviderIDs()
|
||||
jsonBytes, err := json.Marshal(providerIDs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
|
||||
var priority []string
|
||||
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
||||
|
||||
+169
-7
@@ -1,6 +1,24 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
|
||||
|
||||
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := GetExtensionFallbackProviderIDs(); got != nil {
|
||||
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
@@ -114,6 +132,124 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Track",
|
||||
Artist: "Artist",
|
||||
DecryptionKey: "00112233",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"amazon",
|
||||
"ok",
|
||||
"/tmp/test.m4a",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.Decryption == nil {
|
||||
t.Fatal("expected decryption descriptor to be present")
|
||||
}
|
||||
if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", resp.Decryption.Strategy)
|
||||
}
|
||||
if resp.Decryption.Key != result.DecryptionKey {
|
||||
t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) {
|
||||
got := formatMusicBrainzGenre([]musicBrainzTag{
|
||||
{Name: "art pop", Count: 3},
|
||||
{Name: "pop", Count: 8},
|
||||
{Name: "dance pop", Count: 5},
|
||||
})
|
||||
|
||||
if got != "Pop" {
|
||||
t.Fatalf("genre = %q, want %q", got, "Pop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichExtraMetadataByISRCFallsBackToMusicBrainzGenre(t *testing.T) {
|
||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
|
||||
defer func() {
|
||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
|
||||
}()
|
||||
|
||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return nil, nil
|
||||
}
|
||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||
if isrc != "TEST123" {
|
||||
t.Fatalf("unexpected isrc: %q", isrc)
|
||||
}
|
||||
return "Alternative Rock", nil
|
||||
}
|
||||
|
||||
genre := ""
|
||||
label := ""
|
||||
copyright := ""
|
||||
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST123", &genre, &label, ©right)
|
||||
|
||||
if genre != "Alternative Rock" {
|
||||
t.Fatalf("genre = %q, want fallback genre", genre)
|
||||
}
|
||||
if label != "" {
|
||||
t.Fatalf("label = %q, want empty", label)
|
||||
}
|
||||
if copyright != "" {
|
||||
t.Fatalf("copyright = %q, want empty", copyright)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichExtraMetadataByISRCPrefersDeezerGenre(t *testing.T) {
|
||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
|
||||
defer func() {
|
||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
|
||||
}()
|
||||
|
||||
musicBrainzCalled := false
|
||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return &AlbumExtendedMetadata{
|
||||
Genre: "Synthpop",
|
||||
Label: "EMI",
|
||||
Copyright: "(C) Test",
|
||||
}, nil
|
||||
}
|
||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||
musicBrainzCalled = true
|
||||
return "Rock", nil
|
||||
}
|
||||
|
||||
genre := ""
|
||||
label := ""
|
||||
copyright := ""
|
||||
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST456", &genre, &label, ©right)
|
||||
|
||||
if genre != "Synthpop" {
|
||||
t.Fatalf("genre = %q, want Deezer genre", genre)
|
||||
}
|
||||
if label != "EMI" {
|
||||
t.Fatalf("label = %q, want Deezer label", label)
|
||||
}
|
||||
if copyright != "(C) Test" {
|
||||
t.Fatalf("copyright = %q, want Deezer copyright", copyright)
|
||||
}
|
||||
if musicBrainzCalled {
|
||||
t.Fatal("expected MusicBrainz not to be called when Deezer already provides genre")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
SpotifyID: "spotify-track-id",
|
||||
@@ -195,13 +331,11 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||
|
||||
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||
|
||||
// Title and Artist are never written by re-enrich (they are search keys
|
||||
// preserved as-is from the file).
|
||||
if _, exists := metadata["TITLE"]; exists {
|
||||
t.Fatalf("TITLE should not be in metadata: %#v", metadata)
|
||||
if metadata["TITLE"] != "Song" {
|
||||
t.Fatalf("title = %q", metadata["TITLE"])
|
||||
}
|
||||
if _, exists := metadata["ARTIST"]; exists {
|
||||
t.Fatalf("ARTIST should not be in metadata: %#v", metadata)
|
||||
if metadata["ARTIST"] != "Artist" {
|
||||
t.Fatalf("artist = %q", metadata["ARTIST"])
|
||||
}
|
||||
if metadata["ALBUM"] != "Album" {
|
||||
t.Fatalf("album = %q", metadata["ALBUM"])
|
||||
@@ -225,10 +359,35 @@ 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,
|
||||
@@ -242,6 +401,9 @@ func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
|
||||
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
|
||||
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
|
||||
}
|
||||
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
|
||||
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
|
||||
}
|
||||
if req.Composer != "Composer" {
|
||||
t.Fatalf("composer = %q", req.Composer)
|
||||
}
|
||||
|
||||
@@ -893,7 +893,6 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
IconPath string `json:"icon_path,omitempty"`
|
||||
@@ -951,7 +950,6 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
Name: ext.Manifest.Name,
|
||||
DisplayName: ext.Manifest.DisplayName,
|
||||
Version: ext.Manifest.Version,
|
||||
Author: ext.Manifest.Author,
|
||||
Description: ext.Manifest.Description,
|
||||
Homepage: ext.Manifest.Homepage,
|
||||
IconPath: iconPath,
|
||||
@@ -1055,15 +1053,29 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
}
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
// Merge extension return values onto the top-level JSON object so Flutter can read
|
||||
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
try {
|
||||
var result = extension.%s();
|
||||
if (result && typeof result.then === 'function') {
|
||||
// Handle promise - return pending status
|
||||
return { success: true, pending: true, message: 'Action started' };
|
||||
}
|
||||
if (result !== null && result !== undefined && typeof result === 'object') {
|
||||
var isArr = false;
|
||||
if (typeof Array !== 'undefined' && Array.isArray) {
|
||||
isArr = Array.isArray(result);
|
||||
}
|
||||
if (!isArr) {
|
||||
var out = { success: true };
|
||||
for (var k in result) {
|
||||
out[k] = result[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return { success: true, result: result };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
|
||||
@@ -105,7 +105,6 @@ type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
@@ -155,10 +154,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Author) == "" {
|
||||
return &ManifestValidationError{Field: "author", Message: "author is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Description) == "" {
|
||||
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -96,6 +95,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"`
|
||||
@@ -104,16 +112,90 @@ 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"`
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -533,6 +615,10 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
|
||||
p.extension.runtime.setActiveDownloadItemID(itemID)
|
||||
defer p.extension.runtime.clearActiveDownloadItemID()
|
||||
}
|
||||
if itemID != "" {
|
||||
initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
@@ -600,6 +686,14 @@ 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
|
||||
}
|
||||
@@ -676,6 +770,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
|
||||
|
||||
@@ -684,8 +781,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 {
|
||||
@@ -693,7 +790,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))
|
||||
@@ -701,11 +798,104 @@ func GetProviderPriority() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func sanitizeDownloadProviderPriority(providerIDs []string) []string {
|
||||
sanitized := make([]string, 0, len(providerIDs)+2)
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
for _, providerID := range providerIDs {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedBuiltIn := strings.ToLower(providerID)
|
||||
if isBuiltInDownloadProvider(normalizedBuiltIn) {
|
||||
providerID = normalizedBuiltIn
|
||||
}
|
||||
|
||||
seenKey := strings.ToLower(providerID)
|
||||
if _, exists := seen[seenKey]; exists {
|
||||
continue
|
||||
}
|
||||
seen[seenKey] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
for _, providerID := range []string{"tidal", "qobuz"} {
|
||||
if _, exists := seen[providerID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func SetExtensionFallbackProviderIDs(providerIDs []string) {
|
||||
extensionFallbackProviderIDsMu.Lock()
|
||||
defer extensionFallbackProviderIDsMu.Unlock()
|
||||
|
||||
if providerIDs == nil {
|
||||
extensionFallbackProviderIDs = nil
|
||||
GoLog("[Extension] Extension fallback providers reset to default (all enabled download extensions)\n")
|
||||
return
|
||||
}
|
||||
|
||||
sanitized := make([]string, 0, len(providerIDs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, providerID := range providerIDs {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" || isBuiltInDownloadProvider(strings.ToLower(providerID)) {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[providerID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
extensionFallbackProviderIDs = sanitized
|
||||
GoLog("[Extension] Extension fallback providers set: %v\n", sanitized)
|
||||
}
|
||||
|
||||
func GetExtensionFallbackProviderIDs() []string {
|
||||
extensionFallbackProviderIDsMu.RLock()
|
||||
defer extensionFallbackProviderIDsMu.RUnlock()
|
||||
|
||||
if extensionFallbackProviderIDs == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, len(extensionFallbackProviderIDs))
|
||||
copy(result, extensionFallbackProviderIDs)
|
||||
return result
|
||||
}
|
||||
|
||||
func isExtensionFallbackAllowed(providerID string) bool {
|
||||
if isBuiltInDownloadProvider(strings.ToLower(providerID)) {
|
||||
return true
|
||||
}
|
||||
|
||||
allowed := GetExtensionFallbackProviderIDs()
|
||||
if allowed == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, allowedProviderID := range allowed {
|
||||
if allowedProviderID == providerID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func SetMetadataProviderPriority(providerIDs []string) {
|
||||
metadataProviderPriorityMu.Lock()
|
||||
defer metadataProviderPriorityMu.Unlock()
|
||||
|
||||
sanitized := make([]string, 0, len(providerIDs)+3)
|
||||
sanitized := make([]string, 0, len(providerIDs)+2)
|
||||
seen := map[string]struct{}{}
|
||||
for _, providerID := range providerIDs {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
@@ -718,7 +908,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
for _, providerID := range []string{"deezer", "qobuz", "tidal"} {
|
||||
for _, providerID := range []string{"qobuz", "tidal"} {
|
||||
if _, exists := seen[providerID]; exists {
|
||||
continue
|
||||
}
|
||||
@@ -735,7 +925,7 @@ func GetMetadataProviderPriority() []string {
|
||||
defer metadataProviderPriorityMu.RUnlock()
|
||||
|
||||
if len(metadataProviderPriority) == 0 {
|
||||
return []string{"deezer", "qobuz", "tidal"}
|
||||
return []string{"qobuz", "tidal"}
|
||||
}
|
||||
|
||||
result := make([]string, len(metadataProviderPriority))
|
||||
@@ -745,7 +935,16 @@ func GetMetadataProviderPriority() []string {
|
||||
|
||||
func isBuiltInProvider(providerID string) bool {
|
||||
switch providerID {
|
||||
case "tidal", "qobuz", "deezer":
|
||||
case "tidal", "qobuz":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isBuiltInDownloadProvider(providerID string) bool {
|
||||
switch providerID {
|
||||
case "tidal", "qobuz":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -807,20 +1006,6 @@ func metadataTrackDedupKey(track ExtTrackMetadata) string {
|
||||
|
||||
func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
switch providerID {
|
||||
case "deezer":
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results, err := GetDeezerClient().SearchAll(ctx, query, limit, 0, "track")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracks := make([]ExtTrackMetadata, 0, len(results.Tracks))
|
||||
for _, track := range results.Tracks {
|
||||
tracks = append(tracks, normalizeBuiltInMetadataTrack(track, "deezer"))
|
||||
}
|
||||
return tracks, nil
|
||||
case "qobuz":
|
||||
return NewQobuzDownloader().SearchTracks(query, limit)
|
||||
case "tidal":
|
||||
@@ -930,7 +1115,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
if !strictMode && req.Service != "" && isBuiltInProvider(strings.ToLower(req.Service)) {
|
||||
if !strictMode && req.Service != "" && isBuiltInDownloadProvider(strings.ToLower(req.Service)) {
|
||||
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
|
||||
newPriority := []string{req.Service}
|
||||
for _, p := range priority {
|
||||
@@ -940,7 +1125,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) {
|
||||
@@ -1128,21 +1313,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
if req.ISRC != "" &&
|
||||
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
if err == nil && extMeta != nil {
|
||||
if req.Genre == "" && extMeta.Genre != "" {
|
||||
req.Genre = extMeta.Genre
|
||||
}
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1198,14 +1369,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 {
|
||||
@@ -1303,37 +1477,23 @@ 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)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
deezerClient := GetDeezerClient()
|
||||
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
if err == nil && extMeta != nil {
|
||||
if req.Genre == "" && extMeta.Genre != "" {
|
||||
req.Genre = extMeta.Genre
|
||||
GoLog("[DownloadWithExtensionFallback] Genre from Deezer: %s\n", req.Genre)
|
||||
}
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
|
||||
}
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
|
||||
}
|
||||
} else if err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching extra metadata from ISRC: %s\n", req.ISRC)
|
||||
enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
|
||||
}
|
||||
|
||||
result, err := tryBuiltInProvider(providerIDNormalized, req)
|
||||
@@ -1424,14 +1584,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 {
|
||||
@@ -1564,24 +1727,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)
|
||||
}
|
||||
@@ -1609,6 +1754,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
Copyright: req.Copyright,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1650,19 +1796,24 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
outputDir := req.OutputDir
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
|
||||
os.MkdirAll(outputDir, 0755)
|
||||
AddAllowedDownloadDir(outputDir)
|
||||
}
|
||||
os.MkdirAll(outputDir, 0755)
|
||||
AddAllowedDownloadDir(outputDir)
|
||||
|
||||
return filepath.Join(outputDir, filename+ext)
|
||||
}
|
||||
|
||||
func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string {
|
||||
if strings.TrimSpace(req.OutputPath) != "" {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1703,6 +1854,18 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
|
||||
return filepath.Join(tempDir, filename+outputExt)
|
||||
}
|
||||
|
||||
func canEmbedGenreLabel(filePath string) bool {
|
||||
path := strings.TrimSpace(filePath)
|
||||
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
return false
|
||||
}
|
||||
if !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)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
original := GetMetadataProviderPriority()
|
||||
@@ -8,7 +12,7 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
|
||||
SetMetadataProviderPriority([]string{"tidal"})
|
||||
got := GetMetadataProviderPriority()
|
||||
want := []string{"tidal", "deezer", "qobuz"}
|
||||
want := []string{"tidal", "qobuz"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
@@ -19,6 +23,183 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
|
||||
|
||||
got := GetExtensionFallbackProviderIDs()
|
||||
want := []string{"ext-a", "ext-b"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs(nil)
|
||||
|
||||
if !isExtensionFallbackAllowed("custom-ext") {
|
||||
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
|
||||
}
|
||||
if !isExtensionFallbackAllowed("qobuz") {
|
||||
t.Fatal("expected built-in provider to remain allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
|
||||
|
||||
if !isExtensionFallbackAllowed("allowed-ext") {
|
||||
t.Fatal("expected explicitly allowed extension to be permitted")
|
||||
}
|
||||
if isExtensionFallbackAllowed("blocked-ext") {
|
||||
t.Fatal("expected extension outside allowlist to be blocked")
|
||||
}
|
||||
if isExtensionFallbackAllowed("deezer") {
|
||||
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||
original := GetProviderPriority()
|
||||
defer SetProviderPriority(original)
|
||||
|
||||
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
||||
|
||||
got := GetProviderPriority()
|
||||
want := []string{"qobuz", "custom-ext", "tidal"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
||||
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
||||
if normalized == nil {
|
||||
t.Fatal("expected legacy decryption key to produce normalized descriptor")
|
||||
}
|
||||
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||
}
|
||||
if normalized.Key != "001122" {
|
||||
t.Fatalf("key = %q", normalized.Key)
|
||||
}
|
||||
if normalized.InputFormat != "mov" {
|
||||
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
||||
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
|
||||
Strategy: "mp4_decryption_key",
|
||||
Key: "abcd",
|
||||
InputFormat: "",
|
||||
}, "")
|
||||
if normalized == nil {
|
||||
t.Fatal("expected descriptor to remain available")
|
||||
}
|
||||
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||
}
|
||||
if normalized.InputFormat != "mov" {
|
||||
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := buildOutputPath(DownloadRequest{
|
||||
TrackName: "Song",
|
||||
ArtistName: "Artist",
|
||||
OutputDir: outputDir,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "",
|
||||
})
|
||||
|
||||
if !isPathInAllowedDirs(outputPath) {
|
||||
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := filepath.Join(outputDir, "custom.flac")
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
OutputPath: outputPath,
|
||||
}, ext)
|
||||
|
||||
if resolved != outputPath {
|
||||
t.Fatalf("resolved output path = %q", resolved)
|
||||
}
|
||||
if !isPathInAllowedDirs(outputPath) {
|
||||
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
TrackName: "Song",
|
||||
ArtistName: "Artist",
|
||||
OutputDir: filepath.Join("Artist", "Album"),
|
||||
OutputFD: 123,
|
||||
OutputExt: ".flac",
|
||||
}, ext)
|
||||
|
||||
expectedBase := filepath.Join(ext.DataDir, "downloads")
|
||||
if !isPathWithinBase(expectedBase, resolved) {
|
||||
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
|
||||
}
|
||||
if !isPathInAllowedDirs(resolved) {
|
||||
t.Fatalf("expected resolved output path %q to be allowed", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
||||
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
|
||||
if canEmbedGenreLabel("relative.flac") {
|
||||
t.Fatal("expected relative path to be rejected")
|
||||
}
|
||||
if canEmbedGenreLabel("content://example") {
|
||||
t.Fatal("expected content URI to be rejected")
|
||||
}
|
||||
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
|
||||
t.Fatal("expected missing file to be rejected")
|
||||
}
|
||||
if !canEmbedGenreLabel(tempFile) {
|
||||
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
originalPriority := GetMetadataProviderPriority()
|
||||
originalSearch := searchBuiltInMetadataTracksFunc
|
||||
@@ -27,7 +208,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
searchBuiltInMetadataTracksFunc = originalSearch
|
||||
}()
|
||||
|
||||
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
|
||||
SetMetadataProviderPriority([]string{"qobuz", "tidal"})
|
||||
|
||||
var calls []string
|
||||
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
@@ -42,10 +223,6 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
|
||||
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
|
||||
}, nil
|
||||
case "deezer":
|
||||
return []ExtTrackMetadata{
|
||||
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
@@ -56,13 +233,13 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||
}
|
||||
if len(tracks) != 3 {
|
||||
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("unexpected track count: got %d want 2", len(tracks))
|
||||
}
|
||||
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
|
||||
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" {
|
||||
t.Fatalf("unexpected track provider order: %+v", tracks)
|
||||
}
|
||||
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
|
||||
if len(calls) != 2 || calls[0] != "qobuz" || calls[1] != "tidal" {
|
||||
t.Fatalf("unexpected provider call order: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,19 @@ func (r *extensionRuntime) getActiveDownloadItemID() string {
|
||||
return r.activeDownloadItemID
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
if itemID == "" {
|
||||
return req
|
||||
}
|
||||
|
||||
return req.WithContext(initDownloadCancel(itemID))
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
@@ -377,7 +390,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,8 +422,14 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
||||
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||
utilsObj.Set("appVersion", r.appVersion)
|
||||
utilsObj.Set("appUserAgent", r.appUserAgent)
|
||||
utilsObj.Set("sleep", r.sleep)
|
||||
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
logObj := vm.NewObject()
|
||||
|
||||
@@ -458,6 +458,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -346,6 +347,104 @@ func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 1)
|
||||
offset := runtimeOptionInt64(options, "offset", 0)
|
||||
length := runtimeOptionInt64(options, "length", -1)
|
||||
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||
if offset < 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "offset must be >= 0",
|
||||
})
|
||||
}
|
||||
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
size := info.Size()
|
||||
if offset > size {
|
||||
offset = size
|
||||
}
|
||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
var data []byte
|
||||
switch {
|
||||
case length == 0:
|
||||
data = []byte{}
|
||||
case length > 0:
|
||||
buf := make([]byte, int(length))
|
||||
n, readErr := file.Read(buf)
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read file: %v", readErr),
|
||||
})
|
||||
}
|
||||
data = buf[:n]
|
||||
default:
|
||||
data, err = io.ReadAll(file)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read file: %v", err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(data, encoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"bytes_read": len(data),
|
||||
"offset": offset,
|
||||
"size": size,
|
||||
"eof": offset+int64(len(data)) >= size,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -386,6 +485,107 @@ func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path and data are required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 2)
|
||||
appendMode := runtimeOptionBool(options, "append", false)
|
||||
truncate := runtimeOptionBool(options, "truncate", false)
|
||||
hasOffset := runtimeOptionHasKey(options, "offset")
|
||||
offset := runtimeOptionInt64(options, "offset", 0)
|
||||
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||
|
||||
if appendMode && hasOffset {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "append and offset cannot be used together",
|
||||
})
|
||||
}
|
||||
if offset < 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "offset must be >= 0",
|
||||
})
|
||||
}
|
||||
|
||||
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
flags := os.O_CREATE | os.O_WRONLY
|
||||
if appendMode {
|
||||
flags |= os.O_APPEND
|
||||
}
|
||||
if truncate {
|
||||
flags |= os.O_TRUNC
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(fullPath, flags, 0644)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if hasOffset && !appendMode {
|
||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
written, err := file.Write(data)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
info, statErr := file.Stat()
|
||||
size := int64(0)
|
||||
if statErr == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
"bytes_written": written,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
|
||||
@@ -81,6 +81,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -175,6 +176,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -284,6 +286,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -410,6 +413,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
|
||||
@@ -69,6 +69,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
|
||||
@@ -249,6 +249,69 @@ func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(getRandomUserAgent())
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) appVersion(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(GetAppVersion())
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) appUserAgent(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(appUserAgent())
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) sleep(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
sleepMs := 0
|
||||
switch value := call.Arguments[0].Export().(type) {
|
||||
case int64:
|
||||
sleepMs = int(value)
|
||||
case int32:
|
||||
sleepMs = int(value)
|
||||
case int:
|
||||
sleepMs = value
|
||||
case float64:
|
||||
sleepMs = int(value)
|
||||
default:
|
||||
sleepMs = 0
|
||||
}
|
||||
|
||||
if sleepMs <= 0 {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
if sleepMs > 5*60*1000 {
|
||||
sleepMs = 5 * 60 * 1000
|
||||
}
|
||||
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
deadline := time.Now().Add(time.Duration(sleepMs) * time.Millisecond)
|
||||
|
||||
for {
|
||||
if itemID != "" && isDownloadCancelled(itemID) {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
step := 100 * time.Millisecond
|
||||
if remaining < step {
|
||||
step = remaining
|
||||
}
|
||||
time.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Value {
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
if itemID == "" {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
return r.vm.ToValue(isDownloadCancelled(itemID))
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
|
||||
@@ -26,7 +26,6 @@ type storeExtension struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
@@ -83,7 +82,6 @@ type storeExtensionResponse struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
@@ -103,7 +101,6 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
|
||||
Name: e.Name,
|
||||
DisplayName: e.getDisplayName(),
|
||||
Version: e.Version,
|
||||
Author: e.Author,
|
||||
Description: e.Description,
|
||||
DownloadURL: e.getDownloadURL(),
|
||||
IconURL: e.getIconURL(),
|
||||
@@ -253,7 +250,17 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
|
||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||
|
||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||
resp, err := client.Get(s.registryURL)
|
||||
req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Failed to build registry request, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to build registry request: %w", err)
|
||||
}
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||
@@ -348,7 +355,13 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
||||
resp, err := client.Get(ext.getDownloadURL())
|
||||
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build download request: %w", err)
|
||||
}
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
@@ -481,8 +494,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
|
||||
if query != "" {
|
||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||
!containsIgnoreCase(ext.Author, queryLower) {
|
||||
!containsIgnoreCase(ext.Description, queryLower) {
|
||||
found := false
|
||||
for _, tag := range ext.Tags {
|
||||
if containsIgnoreCase(tag, queryLower) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
@@ -12,7 +14,6 @@ func TestParseManifest_Valid(t *testing.T) {
|
||||
"name": "test-provider",
|
||||
"displayName": "Test Provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"],
|
||||
"permissions": {
|
||||
@@ -46,7 +47,6 @@ func TestParseManifest_Valid(t *testing.T) {
|
||||
func TestParseManifest_MissingName(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"]
|
||||
}`
|
||||
@@ -61,7 +61,6 @@ func TestParseManifest_MissingType(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"name": "test-provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension"
|
||||
}`
|
||||
|
||||
@@ -239,6 +238,128 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
if result.String() == "" {
|
||||
t.Error("Expected non-empty JSON string")
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.sleep(1)`)
|
||||
if err != nil {
|
||||
t.Fatalf("sleep failed: %v", err)
|
||||
}
|
||||
if !result.ToBoolean() {
|
||||
t.Error("Expected sleep to complete successfully")
|
||||
}
|
||||
|
||||
runtime.setActiveDownloadItemID("test-item")
|
||||
cancelDownload("test-item")
|
||||
t.Cleanup(func() {
|
||||
clearDownloadCancel("test-item")
|
||||
runtime.clearActiveDownloadItemID()
|
||||
})
|
||||
|
||||
result, err = vm.RunString(`utils.isDownloadCancelled()`)
|
||||
if err != nil {
|
||||
t.Fatalf("isDownloadCancelled failed: %v", err)
|
||||
}
|
||||
if !result.ToBoolean() {
|
||||
t.Error("Expected active download cancellation to be visible to JS")
|
||||
}
|
||||
|
||||
SetAppVersion("4.2.2")
|
||||
t.Cleanup(func() {
|
||||
SetAppVersion("")
|
||||
})
|
||||
|
||||
result, err = vm.RunString(`utils.appVersion()`)
|
||||
if err != nil {
|
||||
t.Fatalf("appVersion failed: %v", err)
|
||||
}
|
||||
if got := result.String(); got != "4.2.2" {
|
||||
t.Fatalf("Expected appVersion 4.2.2, got %q", got)
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.appUserAgent()`)
|
||||
if err != nil {
|
||||
t.Fatalf("appUserAgent failed: %v", err)
|
||||
}
|
||||
if got := result.String(); got != "SpotiFLAC-Mobile/4.2.2" {
|
||||
t.Fatalf("Expected appUserAgent SpotiFLAC-Mobile/4.2.2, got %q", got)
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.sleep(50)`)
|
||||
if err != nil {
|
||||
t.Fatalf("cancel-aware sleep failed: %v", err)
|
||||
}
|
||||
if result.ToBoolean() {
|
||||
t.Error("Expected sleep to abort when download is cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.setActiveDownloadItemID("test-item")
|
||||
t.Cleanup(func() {
|
||||
clearDownloadCancel("test-item")
|
||||
runtime.clearActiveDownloadItemID()
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
req = runtime.bindDownloadCancelContext(req)
|
||||
cancelDownload("test-item")
|
||||
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("Expected bound request context to be cancelled")
|
||||
}
|
||||
|
||||
if req.Context().Err() == nil {
|
||||
t.Fatal("Expected request context error after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.setActiveDownloadItemID("test-item")
|
||||
cancelDownload("test-item")
|
||||
t.Cleanup(func() {
|
||||
clearDownloadCancel("test-item")
|
||||
runtime.clearActiveDownloadItemID()
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
req = runtime.bindDownloadCancelContext(req)
|
||||
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("Expected pre-cancelled request context to stay cancelled")
|
||||
}
|
||||
|
||||
if req.Context().Err() == nil {
|
||||
t.Fatal("Expected request context error for pre-cancelled item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
|
||||
+29
-4
@@ -6,6 +6,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -17,19 +19,42 @@ var (
|
||||
)
|
||||
|
||||
func sanitizeFilename(filename string) string {
|
||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||
sanitized := strings.ReplaceAll(filename, "/", " ")
|
||||
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
|
||||
|
||||
var builder strings.Builder
|
||||
for _, r := range sanitized {
|
||||
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
if r == 0x7F {
|
||||
continue
|
||||
}
|
||||
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
|
||||
sanitized = builder.String()
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
sanitized = strings.Trim(sanitized, ".")
|
||||
|
||||
sanitized = strings.Trim(sanitized, ". ")
|
||||
sanitized = strings.Join(strings.Fields(sanitized), " ")
|
||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
|
||||
if !utf8.ValidString(sanitized) {
|
||||
sanitized = strings.ToValidUTF8(sanitized, "_")
|
||||
}
|
||||
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = sanitized[:200]
|
||||
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
}
|
||||
|
||||
if sanitized == "" {
|
||||
sanitized = "untitled"
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
return sanitized
|
||||
|
||||
@@ -83,3 +83,18 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilenameMatchesDesktopSpacingBehavior(t *testing.T) {
|
||||
got := sanitizeFilename(` "Text In Quotes"?%* / Demo `)
|
||||
want := "Text In Quotes % Demo"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
|
||||
got := sanitizeFilename(`<>:"/\|?*`)
|
||||
if got != "Unknown" {
|
||||
t.Fatalf("expected %q, got %q", "Unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -16,6 +16,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func userAgentForURL(u *url.URL) string {
|
||||
if u == nil {
|
||||
return getRandomUserAgent()
|
||||
}
|
||||
|
||||
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||
if host == "api.zarz.moe" {
|
||||
return appUserAgent()
|
||||
}
|
||||
|
||||
return getRandomUserAgent()
|
||||
}
|
||||
|
||||
func getRandomUserAgent() string {
|
||||
chromeVersion := rand.Intn(26) + 120
|
||||
chromeBuild := rand.Intn(1500) + 6000
|
||||
@@ -225,7 +238,7 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
|
||||
}
|
||||
|
||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
@@ -255,7 +268,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
|
||||
|
||||
resp, err := client.Do(reqCopy)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,7 +11,7 @@ func GetCloudflareBypassClient() *http.Client {
|
||||
}
|
||||
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err != nil {
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
|
||||
@@ -101,7 +101,7 @@ func GetCloudflareBypassClient() *http.Client {
|
||||
}
|
||||
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err == nil {
|
||||
@@ -129,7 +129,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
||||
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
|
||||
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
|
||||
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
|
||||
@@ -39,8 +39,34 @@ var DefaultLyricsProviders = []string{
|
||||
var (
|
||||
lyricsProvidersMu sync.RWMutex
|
||||
lyricsProviders []string // ordered list of enabled providers
|
||||
appVersionMu sync.RWMutex
|
||||
appVersion string
|
||||
)
|
||||
|
||||
func SetAppVersion(version string) {
|
||||
normalized := strings.TrimSpace(version)
|
||||
|
||||
appVersionMu.Lock()
|
||||
defer appVersionMu.Unlock()
|
||||
appVersion = normalized
|
||||
}
|
||||
|
||||
func GetAppVersion() string {
|
||||
appVersionMu.RLock()
|
||||
defer appVersionMu.RUnlock()
|
||||
return appVersion
|
||||
}
|
||||
|
||||
func appUserAgent() string {
|
||||
version := GetAppVersion()
|
||||
|
||||
if version == "" {
|
||||
return "SpotiFLAC-Mobile"
|
||||
}
|
||||
|
||||
return "SpotiFLAC-Mobile/" + version
|
||||
}
|
||||
|
||||
type LyricsFetchOptions struct {
|
||||
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||
|
||||
@@ -114,7 +114,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -147,7 +147,8 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -72,7 +72,7 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -70,7 +70,7 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -109,7 +109,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -54,7 +54,7 @@ func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, dura
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
+346
-4
@@ -9,6 +9,7 @@ import (
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -1244,6 +1245,281 @@ func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string
|
||||
return nameValue, dataValue, nil
|
||||
}
|
||||
|
||||
type m4aMetadataPath struct {
|
||||
moov atomHeader
|
||||
udta *atomHeader
|
||||
meta atomHeader
|
||||
ilst atomHeader
|
||||
}
|
||||
|
||||
func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
|
||||
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil || !found {
|
||||
return m4aMetadataPath{}, fmt.Errorf("moov not found")
|
||||
}
|
||||
|
||||
moovBodyStart := moov.offset + moov.headerSize
|
||||
moovBodySize := moov.size - moov.headerSize
|
||||
|
||||
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
|
||||
udtaBodyStart := udta.offset + udta.headerSize
|
||||
udtaBodySize := udta.size - udta.headerSize
|
||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
||||
udtaCopy := udta
|
||||
return m4aMetadataPath{
|
||||
moov: moov,
|
||||
udta: &udtaCopy,
|
||||
meta: meta,
|
||||
ilst: ilst,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
||||
return m4aMetadataPath{
|
||||
moov: moov,
|
||||
meta: meta,
|
||||
ilst: ilst,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return m4aMetadataPath{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
|
||||
}
|
||||
|
||||
func buildM4AAtom(typ string, payload []byte) []byte {
|
||||
size := int64(8 + len(payload))
|
||||
buf := make([]byte, 8+len(payload))
|
||||
binary.BigEndian.PutUint32(buf[0:4], uint32(size))
|
||||
copy(buf[4:8], []byte(typ))
|
||||
copy(buf[8:], payload)
|
||||
return buf
|
||||
}
|
||||
|
||||
func buildM4AFreeformAtom(name, value string) []byte {
|
||||
meanPayload := append([]byte{0, 0, 0, 0}, []byte("com.apple.iTunes")...)
|
||||
namePayload := append([]byte{0, 0, 0, 0}, []byte(name)...)
|
||||
dataPayload := make([]byte, 8+len(value))
|
||||
binary.BigEndian.PutUint32(dataPayload[0:4], 1) // UTF-8 text
|
||||
copy(dataPayload[8:], []byte(value))
|
||||
|
||||
payload := append([]byte{}, buildM4AAtom("mean", meanPayload)...)
|
||||
payload = append(payload, buildM4AAtom("name", namePayload)...)
|
||||
payload = append(payload, buildM4AAtom("data", dataPayload)...)
|
||||
return buildM4AAtom("----", payload)
|
||||
}
|
||||
|
||||
func buildITunNORMTag(trackGain, trackPeak string) string {
|
||||
gainDb, ok := parseReplayGainDb(trackGain)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
peakLinear, ok := parseReplayGainPeak(trackPeak)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
clamp := func(v int64) int64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 65534 {
|
||||
return 65534
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
g1 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 1000.0)))
|
||||
g2 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 2500.0)))
|
||||
peak := clamp(int64(math.Round(peakLinear * 32768.0)))
|
||||
values := []int64{g1, g1, g2, g2, 0, 0, peak, peak, 0, 0}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts = append(parts, strings.ToUpper(fmt.Sprintf("%08x", value)))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func parseReplayGainDb(value string) (float64, bool) {
|
||||
match := regexp.MustCompile(`([+-]?\d+(?:\.\d+)?)`).FindStringSubmatch(strings.TrimSpace(value))
|
||||
if len(match) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(match[1], 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func parseReplayGainPeak(value string) (float64, bool) {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func collectM4AReplayGainFields(fields map[string]string) map[string]string {
|
||||
result := map[string]string{}
|
||||
if value := strings.TrimSpace(fields["replaygain_track_gain"]); value != "" {
|
||||
result["replaygain_track_gain"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(fields["replaygain_track_peak"]); value != "" {
|
||||
result["replaygain_track_peak"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(fields["replaygain_album_gain"]); value != "" {
|
||||
result["replaygain_album_gain"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(fields["replaygain_album_peak"]); value != "" {
|
||||
result["replaygain_album_peak"] = value
|
||||
}
|
||||
|
||||
if norm := buildITunNORMTag(result["replaygain_track_gain"], result["replaygain_track_peak"]); norm != "" {
|
||||
result["iTunNORM"] = norm
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func writeAtomSize(buf []byte, header atomHeader, newSize int64) error {
|
||||
if newSize <= 0 {
|
||||
return fmt.Errorf("invalid size for %s", header.typ)
|
||||
}
|
||||
if header.headerSize == 16 {
|
||||
if int(header.offset)+16 > len(buf) {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], 1)
|
||||
binary.BigEndian.PutUint64(buf[header.offset+8:header.offset+16], uint64(newSize))
|
||||
return nil
|
||||
}
|
||||
if newSize > math.MaxUint32 {
|
||||
return fmt.Errorf("atom %s too large for 32-bit header", header.typ)
|
||||
}
|
||||
if int(header.offset)+8 > len(buf) {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], uint32(newSize))
|
||||
return nil
|
||||
}
|
||||
|
||||
func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
||||
replayGainFields := collectM4AReplayGainFields(fields)
|
||||
if len(replayGainFields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := findM4AMetadataPath(f, info.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bodyStart := path.ilst.offset + path.ilst.headerSize
|
||||
bodyEnd := path.ilst.offset + path.ilst.size
|
||||
newBody := make([]byte, 0, int(path.ilst.size))
|
||||
targets := map[string]struct{}{
|
||||
"REPLAYGAIN_TRACK_GAIN": {},
|
||||
"REPLAYGAIN_TRACK_PEAK": {},
|
||||
"REPLAYGAIN_ALBUM_GAIN": {},
|
||||
"REPLAYGAIN_ALBUM_PEAK": {},
|
||||
"ITUNNORM": {},
|
||||
}
|
||||
|
||||
for pos := bodyStart; pos+8 <= bodyEnd; {
|
||||
header, readErr := readAtomHeaderAt(f, pos, info.Size())
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if header.size == 0 {
|
||||
header.size = bodyEnd - pos
|
||||
}
|
||||
if header.size < header.headerSize {
|
||||
return fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
keep := true
|
||||
if header.typ == "----" {
|
||||
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
|
||||
if freeformErr == nil {
|
||||
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
|
||||
keep = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if keep {
|
||||
newBody = append(newBody, data[pos:pos+header.size]...)
|
||||
}
|
||||
|
||||
pos += header.size
|
||||
}
|
||||
|
||||
order := []string{
|
||||
"replaygain_track_gain",
|
||||
"replaygain_track_peak",
|
||||
"replaygain_album_gain",
|
||||
"replaygain_album_peak",
|
||||
"iTunNORM",
|
||||
}
|
||||
for _, key := range order {
|
||||
value := strings.TrimSpace(replayGainFields[key])
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
name := key
|
||||
if key != "iTunNORM" {
|
||||
name = strings.ToLower(key)
|
||||
}
|
||||
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
|
||||
}
|
||||
|
||||
newIlst := buildM4AAtom("ilst", newBody)
|
||||
updated := append([]byte{}, data[:path.ilst.offset]...)
|
||||
updated = append(updated, newIlst...)
|
||||
updated = append(updated, data[path.ilst.offset+path.ilst.size:]...)
|
||||
|
||||
delta := int64(len(newIlst)) - path.ilst.size
|
||||
if err := writeAtomSize(updated, path.ilst, path.ilst.size+delta); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomSize(updated, path.meta, path.meta.size+delta); err != nil {
|
||||
return err
|
||||
}
|
||||
if path.udta != nil {
|
||||
if err := writeAtomSize(updated, *path.udta, path.udta.size+delta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeAtomSize(updated, path.moov, path.moov.size+delta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filePath, updated, 0o644)
|
||||
}
|
||||
|
||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||
ext := filepath.Ext(filePath)
|
||||
base := strings.TrimSuffix(filePath, ext)
|
||||
@@ -1423,16 +1699,82 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
// [28:32] samplerate (16.16 fixed-point)
|
||||
sampleRate := int(buf[28])<<8 | int(buf[29])
|
||||
bitDepth := int(buf[22])<<8 | int(buf[23])
|
||||
if bitDepth <= 0 {
|
||||
bitDepth = 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
|
||||
if atomType == "alac" {
|
||||
if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
|
||||
if alacBitDepth > 0 {
|
||||
bitDepth = alacBitDepth
|
||||
}
|
||||
if alacSampleRate > 0 {
|
||||
sampleRate = alacSampleRate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bitDepth <= 0 {
|
||||
bitDepth = 16
|
||||
}
|
||||
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
}
|
||||
|
||||
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
|
||||
if sampleOffset < 4 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
childStart := sampleOffset + 32
|
||||
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
|
||||
if childStart >= childEnd {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "alac", fileSize)
|
||||
if err != nil || !found {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
payloadSize := configHeader.size - configHeader.headerSize
|
||||
if payloadSize <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
payload := make([]byte, payloadSize)
|
||||
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
return parseALACSpecificConfig(payload)
|
||||
}
|
||||
|
||||
func parseALACSpecificConfig(payload []byte) (int, int, bool) {
|
||||
if len(payload) < 24 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
bitDepth := int(payload[5])
|
||||
sampleRate := int(binary.BigEndian.Uint32(payload[20:24]))
|
||||
if bitDepth > 0 && sampleRate > 0 {
|
||||
return bitDepth, sampleRate, true
|
||||
}
|
||||
|
||||
// Some encoders prepend 4 bytes before the ALACSpecificConfig payload.
|
||||
if len(payload) >= 28 {
|
||||
bitDepth = int(payload[9])
|
||||
sampleRate = int(binary.BigEndian.Uint32(payload[24:28]))
|
||||
if bitDepth > 0 && sampleRate > 0 {
|
||||
return bitDepth, sampleRate, true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
type atomHeader struct {
|
||||
offset int64
|
||||
size int64
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseALACSpecificConfigStandardPayload(t *testing.T) {
|
||||
payload := make([]byte, 24)
|
||||
payload[5] = 24
|
||||
payload[20] = 0x00
|
||||
payload[21] = 0x00
|
||||
payload[22] = 0xac
|
||||
payload[23] = 0x44
|
||||
|
||||
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
|
||||
if !ok {
|
||||
t.Fatal("expected standard ALAC payload to parse")
|
||||
}
|
||||
if bitDepth != 24 {
|
||||
t.Fatalf("bitDepth = %d, want 24", bitDepth)
|
||||
}
|
||||
if sampleRate != 44100 {
|
||||
t.Fatalf("sampleRate = %d, want 44100", sampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseALACSpecificConfigPayloadWithLeadingFourBytes(t *testing.T) {
|
||||
payload := make([]byte, 28)
|
||||
payload[9] = 16
|
||||
payload[24] = 0x00
|
||||
payload[25] = 0x00
|
||||
payload[26] = 0xbb
|
||||
payload[27] = 0x80
|
||||
|
||||
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
|
||||
if !ok {
|
||||
t.Fatal("expected offset ALAC payload to parse")
|
||||
}
|
||||
if bitDepth != 16 {
|
||||
t.Fatalf("bitDepth = %d, want 16", bitDepth)
|
||||
}
|
||||
if sampleRate != 48000 {
|
||||
t.Fatalf("sampleRate = %d, want 48000", sampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseALACSpecificConfigRejectsShortPayload(t *testing.T) {
|
||||
if _, _, ok := parseALACSpecificConfig(make([]byte, 12)); ok {
|
||||
t.Fatal("expected short ALAC payload to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ const (
|
||||
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
|
||||
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
|
||||
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
|
||||
qobuzZarzDownloadAPIURL = "https://api.zarz.moe/dl/qbz"
|
||||
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
||||
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
||||
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
|
||||
@@ -105,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 {
|
||||
@@ -349,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,6 +1140,7 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
|
||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
return []string{
|
||||
qobuzDownloadAPIURL,
|
||||
qobuzZarzDownloadAPIURL,
|
||||
qobuzDabMusicAPIURL,
|
||||
qobuzDeebAPIURL,
|
||||
qobuzAfkarAPIURL,
|
||||
@@ -1154,6 +1162,7 @@ const (
|
||||
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
|
||||
return []qobuzAPIProvider{
|
||||
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
|
||||
{Name: "zarz", URL: qobuzZarzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
|
||||
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
|
||||
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
|
||||
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
|
||||
|
||||
@@ -241,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,
|
||||
@@ -518,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
|
||||
return nil, fmt.Errorf("failed to create resolve request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -164,9 +165,9 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
|
||||
}
|
||||
|
||||
var resolveResp struct {
|
||||
Success bool `json:"success"`
|
||||
ISRC string `json:"isrc"`
|
||||
SongUrls map[string]json.RawMessage `json:"songUrls"`
|
||||
Success bool `json:"success"`
|
||||
ISRC string `json:"isrc"`
|
||||
SongUrls map[string]json.RawMessage `json:"songUrls"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resolveResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
|
||||
|
||||
@@ -22,6 +22,9 @@ import Gobackend // Import Go framework
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
|
||||
GobackendSetAppVersion(version)
|
||||
}
|
||||
|
||||
let controller = window?.rootViewController as! FlutterViewController
|
||||
let channel = FlutterMethodChannel(
|
||||
@@ -66,9 +69,59 @@ import Gobackend // Import Go framework
|
||||
)
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
if let url = launchOptions?[.url] as? URL {
|
||||
_ = handleExtensionOAuthRedirect(url: url)
|
||||
}
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
|
||||
@discardableResult
|
||||
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
|
||||
let host = (url.host ?? "").lowercased()
|
||||
let path = url.path.lowercased()
|
||||
let ok =
|
||||
host == "callback" || host == "spotify-callback" || path.contains("callback")
|
||||
guard ok else { return false }
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return false
|
||||
}
|
||||
let q = components.queryItems ?? []
|
||||
let code =
|
||||
q.first { $0.name == "code" }?.value?.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines) ?? ""
|
||||
let state =
|
||||
q.first { $0.name == "state" }?.value?.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines) ?? ""
|
||||
if code.isEmpty { return false }
|
||||
if state.isEmpty {
|
||||
NSLog("SpotiFLAC: Extension OAuth redirect missing state (extension id)")
|
||||
return false
|
||||
}
|
||||
streamQueue.async {
|
||||
var err: NSError?
|
||||
GobackendSetExtensionAuthCodeByID(state, code)
|
||||
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
|
||||
if let err = err {
|
||||
NSLog(
|
||||
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override func application(
|
||||
_ app: UIApplication,
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||
) -> Bool {
|
||||
if handleExtensionOAuthRedirect(url: url) {
|
||||
return true
|
||||
}
|
||||
return super.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopDownloadProgressStream()
|
||||
stopLibraryScanProgressStream()
|
||||
@@ -371,16 +424,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchDeezerAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchTidalAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
@@ -607,6 +650,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]
|
||||
|
||||
@@ -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.2.1';
|
||||
static const String buildNumber = '122';
|
||||
static const String version = '4.3.0';
|
||||
static const String buildNumber = '125';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
/// Shows "Internal" in debug builds, actual version in release.
|
||||
|
||||
@@ -352,6 +352,18 @@ abstract class AppLocalizations {
|
||||
/// **'Using extension: {extensionName}'**
|
||||
String optionsUsingExtension(String extensionName);
|
||||
|
||||
/// Title for the preferred default search tab setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Default Search Tab'**
|
||||
String get optionsDefaultSearchTab;
|
||||
|
||||
/// Subtitle for the preferred default search tab setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which tab opens first for new search results.'**
|
||||
String get optionsDefaultSearchTabSubtitle;
|
||||
|
||||
/// Hint to switch back to built-in providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -718,6 +730,12 @@ abstract class AppLocalizations {
|
||||
/// **'PC source code'**
|
||||
String get aboutPCSource;
|
||||
|
||||
/// Link to Keep Android Open campaign website
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep Android Open'**
|
||||
String get aboutKeepAndroidOpen;
|
||||
|
||||
/// Link to report bugs
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1738,6 +1756,24 @@ abstract class AppLocalizations {
|
||||
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
|
||||
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:
|
||||
@@ -2644,6 +2680,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:
|
||||
|
||||
@@ -129,6 +129,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Erweiterung verwenden: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tippe auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln';
|
||||
@@ -341,6 +348,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC Quellcode';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Problem melden';
|
||||
|
||||
@@ -940,6 +950,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';
|
||||
|
||||
@@ -1438,6 +1459,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';
|
||||
|
||||
@@ -127,6 +127,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Using extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
@@ -334,6 +341,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
@@ -926,6 +936,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';
|
||||
|
||||
@@ -1415,6 +1436,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';
|
||||
|
||||
@@ -127,6 +127,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return 'Using extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
@@ -334,6 +341,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
@@ -926,6 +936,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';
|
||||
|
||||
@@ -1415,6 +1436,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';
|
||||
@@ -3689,6 +3717,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
@override
|
||||
String get aboutPCSource => 'Código fuente de PC';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Reportar un problema';
|
||||
|
||||
|
||||
@@ -128,6 +128,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Utilisation de l\'extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
|
||||
@@ -336,6 +343,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
@@ -928,6 +938,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';
|
||||
|
||||
@@ -1417,6 +1438,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';
|
||||
|
||||
@@ -127,6 +127,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Using extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
@@ -334,6 +341,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
@@ -926,6 +936,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';
|
||||
|
||||
@@ -1415,6 +1436,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';
|
||||
|
||||
@@ -129,6 +129,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Menggunakan ekstensi: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Tab Pencarian Default';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Pilih tab yang dibuka lebih dulu untuk hasil pencarian baru.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
|
||||
@@ -337,6 +344,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'Kode sumber PC';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Laporkan masalah';
|
||||
|
||||
@@ -930,6 +940,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';
|
||||
|
||||
@@ -1423,6 +1444,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';
|
||||
|
||||
@@ -127,6 +127,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '拡張の使用: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
@@ -330,6 +337,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC 版のソースコード';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => '問題を報告する';
|
||||
|
||||
@@ -920,6 +930,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 => '内蔵';
|
||||
|
||||
@@ -1409,6 +1430,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 => 'ダウンロードプロバイダーの拡張はありません';
|
||||
|
||||
|
||||
@@ -125,6 +125,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return '확장 기능을 사용: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
|
||||
|
||||
@@ -323,6 +330,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC 소스 코드';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => '문제 신고';
|
||||
|
||||
@@ -908,6 +918,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';
|
||||
|
||||
@@ -1395,6 +1416,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';
|
||||
|
||||
@@ -127,6 +127,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Using extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
@@ -334,6 +341,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
@@ -926,6 +936,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';
|
||||
|
||||
@@ -1415,6 +1436,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';
|
||||
|
||||
@@ -127,6 +127,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return 'Using extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
@@ -334,6 +341,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
@@ -926,6 +936,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';
|
||||
|
||||
@@ -1415,6 +1436,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';
|
||||
@@ -3689,6 +3717,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
@override
|
||||
String get aboutPCSource => 'Código-fonte do app desktop';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Reportar um problema';
|
||||
|
||||
|
||||
@@ -129,6 +129,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Используется расширение: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Нажмите Deezer или Spotify для возврата с расширения';
|
||||
@@ -340,6 +347,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'Исходный код ПК версии';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Сообщить о проблеме';
|
||||
|
||||
@@ -940,6 +950,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 => 'Встроенные';
|
||||
|
||||
@@ -1439,6 +1460,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 =>
|
||||
'Нет расширений с провайдером загрузки';
|
||||
|
||||
@@ -129,6 +129,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return 'Kullanılan eklenti: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Dahili kaynaklara dönmek için Deezer veya Spotify\'a tıkla';
|
||||
@@ -337,6 +344,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC kaynak kodu';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Sorun bildir';
|
||||
|
||||
@@ -931,6 +941,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';
|
||||
|
||||
@@ -1421,6 +1442,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';
|
||||
|
||||
@@ -127,6 +127,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return 'Using extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
@@ -334,6 +341,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
@@ -926,6 +936,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';
|
||||
|
||||
@@ -1415,6 +1436,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';
|
||||
@@ -3671,6 +3699,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
@override
|
||||
String get aboutPCSource => '桌面版本源代码';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => '报告一个问题';
|
||||
|
||||
@@ -6065,6 +6096,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Problem melden",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -158,6 +158,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsDefaultSearchTab": "Default Search Tab",
|
||||
"@optionsDefaultSearchTab": {
|
||||
"description": "Title for the preferred default search tab setting"
|
||||
},
|
||||
"optionsDefaultSearchTabSubtitle": "Choose which tab opens first for new search results.",
|
||||
"@optionsDefaultSearchTabSubtitle": {
|
||||
"description": "Subtitle for the preferred default search tab setting"
|
||||
},
|
||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
@@ -422,6 +430,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
@@ -1203,6 +1215,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)"
|
||||
@@ -1857,6 +1881,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"
|
||||
|
||||
@@ -362,6 +362,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Reportar un problema",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -150,6 +150,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsDefaultSearchTab": "Tab Pencarian Default",
|
||||
"@optionsDefaultSearchTab": {
|
||||
"description": "Title for the preferred default search tab setting"
|
||||
},
|
||||
"optionsDefaultSearchTabSubtitle": "Pilih tab yang dibuka lebih dulu untuk hasil pencarian baru.",
|
||||
"@optionsDefaultSearchTabSubtitle": {
|
||||
"description": "Subtitle for the preferred default search tab setting"
|
||||
},
|
||||
"optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi",
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
@@ -382,6 +390,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Laporkan masalah",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
@@ -1119,6 +1131,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 +1737,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"
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "問題を報告する",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "문제 신고",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -362,6 +362,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Reportar um problema",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Сообщить о проблеме",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Sorun bildir",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -362,6 +362,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "报告一个问题",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -33,7 +33,9 @@ class AppSettings {
|
||||
final bool askQualityBeforeDownload;
|
||||
final bool enableLogging;
|
||||
final bool useExtensionProviders;
|
||||
final List<String>? downloadFallbackExtensionIds;
|
||||
final String? searchProvider;
|
||||
final String defaultSearchTab;
|
||||
final String? homeFeedProvider;
|
||||
final bool separateSingles;
|
||||
final String singleFilenameFormat;
|
||||
@@ -108,7 +110,9 @@ class AppSettings {
|
||||
this.askQualityBeforeDownload = true,
|
||||
this.enableLogging = false,
|
||||
this.useExtensionProviders = true,
|
||||
this.downloadFallbackExtensionIds,
|
||||
this.searchProvider,
|
||||
this.defaultSearchTab = 'all',
|
||||
this.homeFeedProvider,
|
||||
this.separateSingles = false,
|
||||
this.singleFilenameFormat = '{title} - {artist}',
|
||||
@@ -170,8 +174,11 @@ class AppSettings {
|
||||
bool? askQualityBeforeDownload,
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
List<String>? downloadFallbackExtensionIds,
|
||||
bool clearDownloadFallbackExtensionIds = false,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false,
|
||||
String? defaultSearchTab,
|
||||
String? homeFeedProvider,
|
||||
bool clearHomeFeedProvider = false,
|
||||
bool? separateSingles,
|
||||
@@ -232,9 +239,13 @@ class AppSettings {
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
useExtensionProviders:
|
||||
useExtensionProviders ?? this.useExtensionProviders,
|
||||
downloadFallbackExtensionIds: clearDownloadFallbackExtensionIds
|
||||
? null
|
||||
: (downloadFallbackExtensionIds ?? this.downloadFallbackExtensionIds),
|
||||
searchProvider: clearSearchProvider
|
||||
? null
|
||||
: (searchProvider ?? this.searchProvider),
|
||||
defaultSearchTab: defaultSearchTab ?? this.defaultSearchTab,
|
||||
homeFeedProvider: clearHomeFeedProvider
|
||||
? null
|
||||
: (homeFeedProvider ?? this.homeFeedProvider),
|
||||
|
||||
@@ -35,7 +35,12 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
downloadFallbackExtensionIds:
|
||||
(json['downloadFallbackExtensionIds'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList(),
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
defaultSearchTab: json['defaultSearchTab'] as String? ?? 'all',
|
||||
homeFeedProvider: json['homeFeedProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
singleFilenameFormat:
|
||||
@@ -105,7 +110,9 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'defaultSearchTab': instance.defaultSearchTab,
|
||||
'homeFeedProvider': instance.homeFeedProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'singleFilenameFormat': instance.singleFilenameFormat,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,6 @@ class Extension {
|
||||
final String name;
|
||||
final String displayName;
|
||||
final String version;
|
||||
final String author;
|
||||
final String description;
|
||||
final bool enabled;
|
||||
final String status;
|
||||
@@ -45,7 +44,6 @@ class Extension {
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.version,
|
||||
required this.author,
|
||||
required this.description,
|
||||
required this.enabled,
|
||||
required this.status,
|
||||
@@ -73,7 +71,6 @@ class Extension {
|
||||
displayName:
|
||||
json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
author: json['author'] as String? ?? 'Unknown',
|
||||
description: json['description'] as String? ?? '',
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
status: json['status'] as String? ?? 'loaded',
|
||||
@@ -124,7 +121,6 @@ class Extension {
|
||||
String? name,
|
||||
String? displayName,
|
||||
String? version,
|
||||
String? author,
|
||||
String? description,
|
||||
bool? enabled,
|
||||
String? status,
|
||||
@@ -149,7 +145,6 @@ class Extension {
|
||||
name: name ?? this.name,
|
||||
displayName: displayName ?? this.displayName,
|
||||
version: version ?? this.version,
|
||||
author: author ?? this.author,
|
||||
description: description ?? this.description,
|
||||
enabled: enabled ?? this.enabled,
|
||||
status: status ?? this.status,
|
||||
@@ -178,6 +173,12 @@ class Extension {
|
||||
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
||||
bool get hasHomeFeed => capabilities['homeFeed'] == true;
|
||||
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
|
||||
String? get preferredDownloadOutputExtension {
|
||||
final value = capabilities['downloadOutputExtension'];
|
||||
if (value is! String) return null;
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
class SearchFilter {
|
||||
@@ -481,8 +482,10 @@ class ExtensionState {
|
||||
}
|
||||
|
||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
static const _builtInMetadataProviders = ['qobuz', 'tidal'];
|
||||
AppLifecycleListener? _appLifecycleListener;
|
||||
bool _cleanupInFlight = false;
|
||||
Completer<void>? _initializationCompleter;
|
||||
|
||||
@override
|
||||
ExtensionState build() {
|
||||
@@ -520,6 +523,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<void> initialize(String extensionsDir, String dataDir) async {
|
||||
if (state.isInitialized) return;
|
||||
if (_initializationCompleter != null) {
|
||||
await _initializationCompleter!.future;
|
||||
return;
|
||||
}
|
||||
|
||||
final completer = Completer<void>();
|
||||
_initializationCompleter = completer;
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
@@ -531,6 +541,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
error: null,
|
||||
);
|
||||
_log.i('Extension system disabled on this platform');
|
||||
completer.complete();
|
||||
_initializationCompleter = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -544,6 +556,32 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
} catch (e) {
|
||||
_log.e('Failed to initialize extension system: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
} finally {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
if (identical(_initializationCompleter, completer)) {
|
||||
_initializationCompleter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> waitForInitialization({
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
if (state.isInitialized || !PlatformBridge.supportsExtensionSystem) {
|
||||
return;
|
||||
}
|
||||
|
||||
final future = _initializationCompleter?.future;
|
||||
if (future == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await future.timeout(timeout);
|
||||
} on TimeoutException {
|
||||
_log.w('Timed out waiting for extension initialization after $timeout');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +604,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
final list = await PlatformBridge.getInstalledExtensions();
|
||||
final extensions = list.map((e) => Extension.fromJson(e)).toList();
|
||||
state = state.copyWith(extensions: extensions);
|
||||
await _reconcileDownloadProviderPriority();
|
||||
_log.d('Loaded ${extensions.length} extensions');
|
||||
|
||||
for (final ext in extensions) {
|
||||
@@ -661,6 +700,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(extensions: extensions);
|
||||
await _reconcileDownloadProviderPriority();
|
||||
|
||||
if (!enabled && ext != null) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
@@ -685,6 +725,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reconcileDownloadProviderPriority() async {
|
||||
if (state.providerPriority.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority);
|
||||
if (jsonEncode(sanitized) == jsonEncode(state.providerPriority)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
|
||||
await PlatformBridge.setProviderPriority(sanitized);
|
||||
state = state.copyWith(providerPriority: sanitized);
|
||||
_log.d('Reconciled provider priority after extension update: $sanitized');
|
||||
}
|
||||
|
||||
Future<bool> ensureSpotifyWebExtensionReady({
|
||||
bool setAsSearchProvider = true,
|
||||
}) async {
|
||||
@@ -812,6 +869,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
List<String> _sanitizeDownloadProviderPriority(List<String> input) {
|
||||
final allowed = getAllDownloadProviders().toSet();
|
||||
final preferredOrder = getAllDownloadProviders();
|
||||
final result = <String>[];
|
||||
|
||||
for (final provider in input) {
|
||||
@@ -820,7 +878,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
for (final provider in const ['tidal', 'qobuz', 'deezer']) {
|
||||
for (final provider in preferredOrder) {
|
||||
if (!result.contains(provider)) {
|
||||
result.add(provider);
|
||||
}
|
||||
@@ -847,10 +905,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
);
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
} else {
|
||||
priority = _sanitizeMetadataProviderPriority(
|
||||
await PlatformBridge.getMetadataProviderPriority(),
|
||||
);
|
||||
final backendPriority =
|
||||
await PlatformBridge.getMetadataProviderPriority();
|
||||
priority = _sanitizeMetadataProviderPriority(backendPriority);
|
||||
_log.d('Using default metadata provider priority: $priority');
|
||||
await prefs.setString(
|
||||
_metadataProviderPriorityKey,
|
||||
jsonEncode(priority),
|
||||
);
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
}
|
||||
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
@@ -896,7 +959,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);
|
||||
@@ -906,17 +969,26 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> getAllMetadataProviders() {
|
||||
final providers = ['deezer', 'qobuz', 'tidal'];
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasMetadataProvider) {
|
||||
providers.add(ext.id);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
final metadataExtensions = state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasMetadataProvider)
|
||||
.toList();
|
||||
final primarySearchMetadataExtensions = metadataExtensions
|
||||
.where((ext) => ext.searchBehavior?.primary == true)
|
||||
.map((ext) => ext.id);
|
||||
final otherMetadataExtensions = metadataExtensions
|
||||
.where((ext) => ext.searchBehavior?.primary != true)
|
||||
.map((ext) => ext.id);
|
||||
|
||||
return [
|
||||
...primarySearchMetadataExtensions,
|
||||
..._builtInMetadataProviders,
|
||||
...otherMetadataExtensions,
|
||||
];
|
||||
}
|
||||
|
||||
List<String> _sanitizeMetadataProviderPriority(List<String> input) {
|
||||
final allowed = getAllMetadataProviders().toSet();
|
||||
final preferredOrder = getAllMetadataProviders();
|
||||
final result = <String>[];
|
||||
|
||||
for (final provider in input) {
|
||||
@@ -925,7 +997,18 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
for (final provider in const ['deezer', 'qobuz', 'tidal']) {
|
||||
final hasPreferredExtension = preferredOrder.any(
|
||||
(provider) => !_builtInMetadataProviders.contains(provider),
|
||||
);
|
||||
final hasSavedExtension = result.any(
|
||||
(provider) => !_builtInMetadataProviders.contains(provider),
|
||||
);
|
||||
|
||||
if (!hasSavedExtension && hasPreferredExtension) {
|
||||
return List<String>.from(preferredOrder);
|
||||
}
|
||||
|
||||
for (final provider in preferredOrder) {
|
||||
if (!result.contains(provider)) {
|
||||
result.add(provider);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,13 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 9;
|
||||
const _currentMigrationVersion = 10;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||
static const Set<String> _searchTabValues = {'all', 'track', 'artist', 'album'};
|
||||
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
@@ -35,9 +36,23 @@ 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,
|
||||
);
|
||||
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
|
||||
loaded.defaultSearchTab,
|
||||
);
|
||||
state = loaded.copyWith(
|
||||
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
|
||||
clearDownloadFallbackExtensionIds:
|
||||
loaded.downloadFallbackExtensionIds != null &&
|
||||
sanitizedDownloadFallbackExtensionIds == null,
|
||||
defaultSearchTab: sanitizedDefaultSearchTab,
|
||||
);
|
||||
|
||||
await _runMigrations(prefs);
|
||||
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||
@@ -50,6 +65,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
_syncLyricsSettingsToBackend();
|
||||
_syncNetworkCompatibilitySettingsToBackend();
|
||||
_syncExtensionFallbackSettingsToBackend();
|
||||
}
|
||||
|
||||
void _syncLyricsSettingsToBackend() {
|
||||
@@ -83,6 +99,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
});
|
||||
}
|
||||
|
||||
void _syncExtensionFallbackSettingsToBackend() {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
|
||||
PlatformBridge.setDownloadFallbackExtensionIds(
|
||||
state.downloadFallbackExtensionIds,
|
||||
).catchError((Object e) {
|
||||
_log.w('Failed to sync extension fallback settings to backend: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||
|
||||
@@ -111,8 +137,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
);
|
||||
}
|
||||
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);
|
||||
@@ -165,6 +192,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
return 'US';
|
||||
}
|
||||
|
||||
String _normalizeDefaultSearchTab(String value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
if (_searchTabValues.contains(normalized)) return normalized;
|
||||
return 'all';
|
||||
}
|
||||
|
||||
Future<void> _normalizeSongLinkRegionIfNeeded() async {
|
||||
final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
|
||||
if (normalized == state.songLinkRegion) return;
|
||||
@@ -172,6 +205,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
List<String>? _sanitizeDownloadFallbackExtensionIds(List<String>? ids) {
|
||||
if (ids == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final result = <String>[];
|
||||
for (final id in ids) {
|
||||
final normalized = id.trim();
|
||||
if (normalized.isEmpty || result.contains(normalized)) {
|
||||
continue;
|
||||
}
|
||||
result.add(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _cleanupRetiredSpotifySettings() async {
|
||||
final storedSecret = await _secureStorage.read(
|
||||
key: _spotifyClientSecretKey,
|
||||
@@ -370,6 +419,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setDefaultSearchTab(String tab) {
|
||||
state = state.copyWith(defaultSearchTab: _normalizeDefaultSearchTab(tab));
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHomeFeedProvider(String? provider) {
|
||||
if (provider == null || provider.isEmpty) {
|
||||
state = state.copyWith(clearHomeFeedProvider: true);
|
||||
@@ -390,6 +444,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();
|
||||
|
||||
@@ -63,7 +63,6 @@ class StoreExtension {
|
||||
final String name;
|
||||
final String displayName;
|
||||
final String version;
|
||||
final String author;
|
||||
final String description;
|
||||
final String downloadUrl;
|
||||
final String? iconUrl;
|
||||
@@ -81,7 +80,6 @@ class StoreExtension {
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.version,
|
||||
required this.author,
|
||||
required this.description,
|
||||
required this.downloadUrl,
|
||||
this.iconUrl,
|
||||
@@ -102,7 +100,6 @@ class StoreExtension {
|
||||
displayName:
|
||||
json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
author: json['author'] as String? ?? 'Unknown',
|
||||
description: json['description'] as String? ?? '',
|
||||
downloadUrl: json['download_url'] as String? ?? '',
|
||||
iconUrl: json['icon_url'] as String?,
|
||||
@@ -194,7 +191,6 @@ class StoreState {
|
||||
e.name.toLowerCase().contains(query) ||
|
||||
e.displayName.toLowerCase().contains(query) ||
|
||||
e.description.toLowerCase().contains(query) ||
|
||||
e.author.toLowerCase().contains(query) ||
|
||||
e.tags.any((t) => t.toLowerCase().contains(query)),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('TrackProvider');
|
||||
const _extensionInitRetryTimeout = Duration(seconds: 30);
|
||||
|
||||
class TrackState {
|
||||
final List<Track> tracks;
|
||||
@@ -203,13 +204,36 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||
|
||||
bool _usesBuiltInUrlResolver(String url) {
|
||||
final normalized = url.toLowerCase();
|
||||
return normalized.contains('deezer.com') ||
|
||||
normalized.contains('deezer.page.link') ||
|
||||
normalized.contains('qobuz.com') ||
|
||||
normalized.startsWith('qobuzapp://') ||
|
||||
normalized.contains('tidal.com');
|
||||
}
|
||||
|
||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
var extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
if (extensionHandler == null && !_usesBuiltInUrlResolver(url)) {
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
if (!extensionState.isInitialized && extensionState.isLoading) {
|
||||
_log.i(
|
||||
'Extension URL handlers not ready yet, waiting for initialization...',
|
||||
);
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.waitForInitialization(timeout: _extensionInitRetryTimeout);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
|
||||
@@ -559,8 +583,98 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
String? builtInSearchProvider,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||
final requestFilter = currentFilter == 'all' ? null : currentFilter;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
|
||||
String? resolvedProvider = builtInSearchProvider;
|
||||
if (resolvedProvider == null || resolvedProvider.isEmpty) {
|
||||
final explicitProvider = settings.searchProvider?.trim();
|
||||
if (explicitProvider != null && explicitProvider.isNotEmpty) {
|
||||
resolvedProvider = explicitProvider;
|
||||
} else {
|
||||
resolvedProvider =
|
||||
extensionState.extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull ??
|
||||
extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull;
|
||||
}
|
||||
}
|
||||
|
||||
final isEnabledExtensionProvider =
|
||||
resolvedProvider != null &&
|
||||
resolvedProvider.isNotEmpty &&
|
||||
extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.id == resolvedProvider,
|
||||
);
|
||||
|
||||
if (resolvedProvider != null &&
|
||||
resolvedProvider.isNotEmpty &&
|
||||
resolvedProvider != 'tidal' &&
|
||||
resolvedProvider != 'qobuz' &&
|
||||
!isEnabledExtensionProvider &&
|
||||
settings.searchProvider?.trim() == resolvedProvider) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
resolvedProvider =
|
||||
extensionState.extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull ??
|
||||
extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
if (resolvedProvider != null &&
|
||||
resolvedProvider.isNotEmpty &&
|
||||
resolvedProvider != 'tidal' &&
|
||||
resolvedProvider != 'qobuz' &&
|
||||
extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.id == resolvedProvider,
|
||||
)) {
|
||||
final resolvedFilter = requestFilter ?? 'track';
|
||||
Map<String, dynamic>? options;
|
||||
options = {'filter': resolvedFilter};
|
||||
await customSearch(
|
||||
resolvedProvider,
|
||||
query,
|
||||
options: options,
|
||||
selectedFilter: resolvedFilter,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final effectiveBuiltInProvider =
|
||||
resolvedProvider == 'tidal' || resolvedProvider == 'qobuz'
|
||||
? resolvedProvider
|
||||
: builtInSearchProvider;
|
||||
|
||||
if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'No active search provider available',
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
@@ -570,42 +684,21 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
|
||||
try {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
|
||||
final effectiveProvider = builtInSearchProvider ?? 'deezer';
|
||||
final effectiveProvider = effectiveBuiltInProvider;
|
||||
|
||||
_log.i(
|
||||
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
|
||||
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter',
|
||||
);
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||
|
||||
if (effectiveProvider == 'deezer') {
|
||||
try {
|
||||
_log.d('Calling metadata provider search API...');
|
||||
metadataTrackResults =
|
||||
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 20,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
_log.i(
|
||||
'Metadata providers returned ${metadataTrackResults.length} tracks',
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w(
|
||||
'Metadata provider search failed, falling back to Deezer tracks: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
switch (effectiveProvider) {
|
||||
case 'tidal':
|
||||
_log.d('Calling Tidal search API...');
|
||||
@@ -613,7 +706,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
filter: requestFilter,
|
||||
);
|
||||
break;
|
||||
case 'qobuz':
|
||||
@@ -622,17 +715,23 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
filter: requestFilter,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
_log.d('Calling metadata provider track search API...');
|
||||
metadataTrackResults =
|
||||
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 20,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
results = const <String, List<dynamic>>{
|
||||
'tracks': <dynamic>[],
|
||||
'artists': <dynamic>[],
|
||||
'albums': <dynamic>[],
|
||||
'playlists': <dynamic>[],
|
||||
};
|
||||
break;
|
||||
}
|
||||
_log.i(
|
||||
@@ -741,14 +840,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
String extensionId,
|
||||
String query, {
|
||||
Map<String, dynamic>? options,
|
||||
String? selectedFilter,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
final currentFilter = selectedFilter ?? state.selectedSearchFilter;
|
||||
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: state.selectedSearchFilter,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -788,7 +889,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
searchExtensionId: extensionId,
|
||||
selectedSearchFilter: state.selectedSearchFilter,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
@@ -798,6 +899,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -908,7 +1010,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
albumType: data['album_type'] as String?,
|
||||
albumType: normalizeOptionalString(data['album_type']?.toString()),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
);
|
||||
@@ -945,7 +1047,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -75,6 +75,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
String? _error;
|
||||
bool _showTitleInAppBar = false;
|
||||
String? _artistId;
|
||||
String? _albumType;
|
||||
int? _albumTotalTracks;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
@@ -112,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();
|
||||
@@ -179,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);
|
||||
|
||||
@@ -193,6 +206,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -204,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);
|
||||
|
||||
@@ -218,6 +242,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -229,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);
|
||||
|
||||
@@ -243,6 +278,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -255,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);
|
||||
|
||||
@@ -269,6 +315,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -284,7 +332,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
Track _parseTrack(
|
||||
Map<String, dynamic> data, {
|
||||
String? albumTypeFallback,
|
||||
int? totalTracksFallback,
|
||||
}) {
|
||||
return Track(
|
||||
id: data['spotify_id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
@@ -301,8 +353,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -412,7 +411,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
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,
|
||||
@@ -1057,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:', '');
|
||||
@@ -1934,6 +1936,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
coverUrl: album.coverUrl,
|
||||
initialAlbumType: album.albumType,
|
||||
initialTotalTracks: album.totalTracks,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -299,7 +299,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
||||
Future<void> _navigateToMetadataScreen(
|
||||
DownloadHistoryItem item, {
|
||||
required List<DownloadHistoryItem> navigationItems,
|
||||
required int navigationIndex,
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(item.coverUrl);
|
||||
final beforeModTime =
|
||||
@@ -309,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,
|
||||
@@ -691,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),
|
||||
@@ -709,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -774,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);
|
||||
|
||||
@@ -791,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),
|
||||
|
||||
+344
-43
@@ -426,14 +426,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
String? currentSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final resolvedSearchProvider = _resolveSearchProvider(
|
||||
currentSearchProvider,
|
||||
extensions,
|
||||
);
|
||||
final isUsingExtensionSearch =
|
||||
currentSearchProvider != null &&
|
||||
currentSearchProvider.isNotEmpty &&
|
||||
extensions.any((e) => e.id == currentSearchProvider && e.enabled);
|
||||
resolvedSearchProvider != null &&
|
||||
resolvedSearchProvider.isNotEmpty &&
|
||||
extensions.any((e) => e.id == resolvedSearchProvider && e.enabled);
|
||||
|
||||
if (isUsingExtensionSearch) {
|
||||
final currentSearchExtension = extensions
|
||||
.where((e) => e.id == currentSearchProvider && e.enabled)
|
||||
.where((e) => e.id == resolvedSearchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
final filters = currentSearchExtension?.searchBehavior?.filters;
|
||||
if (filters != null && filters.isNotEmpty) {
|
||||
@@ -449,6 +453,154 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
];
|
||||
}
|
||||
|
||||
Extension? _defaultSearchExtension(List<Extension> extensions) {
|
||||
return extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.firstOrNull ??
|
||||
extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
String? _resolveSearchProvider(
|
||||
String? explicitSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final explicit = explicitSearchProvider?.trim();
|
||||
if (explicit != null &&
|
||||
explicit.isNotEmpty &&
|
||||
(_builtInSearchProviders.contains(explicit) ||
|
||||
extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
|
||||
))) {
|
||||
return explicit;
|
||||
}
|
||||
return _defaultSearchExtension(extensions)?.id;
|
||||
}
|
||||
|
||||
String? _sanitizeSearchFilterForProvider(
|
||||
String? filter,
|
||||
String? currentSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
if (filter == null || filter.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final canonicalFilter = _canonicalSearchFilterId(filter);
|
||||
|
||||
if (currentSearchProvider == null ||
|
||||
currentSearchProvider.isEmpty ||
|
||||
_builtInSearchProviders.contains(currentSearchProvider)) {
|
||||
switch (canonicalFilter) {
|
||||
case 'track':
|
||||
case 'artist':
|
||||
case 'album':
|
||||
case 'playlist':
|
||||
return canonicalFilter;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final extension = extensions
|
||||
.where((e) => e.id == currentSearchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
final filters = extension?.searchBehavior?.filters;
|
||||
if (filters == null || filters.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final match = filters
|
||||
.where(
|
||||
(candidate) =>
|
||||
_canonicalSearchFilterId(candidate.id) == canonicalFilter ||
|
||||
(candidate.label != null &&
|
||||
_canonicalSearchFilterId(candidate.label!) ==
|
||||
canonicalFilter) ||
|
||||
(candidate.icon != null &&
|
||||
_canonicalSearchFilterId(candidate.icon!) ==
|
||||
canonicalFilter),
|
||||
)
|
||||
.firstOrNull;
|
||||
return match?.id;
|
||||
}
|
||||
|
||||
String _canonicalSearchFilterId(String value) {
|
||||
final normalized = value.trim().toLowerCase().replaceAll(
|
||||
RegExp(r'[^a-z0-9]+'),
|
||||
'',
|
||||
);
|
||||
switch (normalized) {
|
||||
case 'track':
|
||||
case 'tracks':
|
||||
case 'song':
|
||||
case 'songs':
|
||||
case 'music':
|
||||
return 'track';
|
||||
case 'artist':
|
||||
case 'artists':
|
||||
return 'artist';
|
||||
case 'album':
|
||||
case 'albums':
|
||||
return 'album';
|
||||
case 'playlist':
|
||||
case 'playlists':
|
||||
return 'playlist';
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
String? _preferredSearchFilter(
|
||||
String preferredSearchTab,
|
||||
String? currentSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final preferred = switch (preferredSearchTab) {
|
||||
'track' => 'track',
|
||||
'artist' => 'artist',
|
||||
'album' => 'album',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return _sanitizeSearchFilterForProvider(
|
||||
preferred,
|
||||
currentSearchProvider,
|
||||
extensions,
|
||||
);
|
||||
}
|
||||
|
||||
String _displaySearchFilterSelection(
|
||||
String? selectedSearchFilter,
|
||||
String preferredSearchTab,
|
||||
String? currentSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
if (selectedSearchFilter == 'all') {
|
||||
return 'all';
|
||||
}
|
||||
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
|
||||
return _sanitizeSearchFilterForProvider(
|
||||
selectedSearchFilter,
|
||||
currentSearchProvider,
|
||||
extensions,
|
||||
) ??
|
||||
'all';
|
||||
}
|
||||
return _preferredSearchFilter(
|
||||
preferredSearchTab,
|
||||
currentSearchProvider,
|
||||
extensions,
|
||||
) ??
|
||||
'all';
|
||||
}
|
||||
|
||||
_SearchResultBuckets _getSearchResultBuckets(List<Track> tracks) {
|
||||
final cached = _searchBucketsCache;
|
||||
if (cached != null && identical(tracks, _searchBucketsSourceTracks)) {
|
||||
@@ -530,7 +682,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
bool _isLiveSearchEnabled() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extState = ref.read(extensionProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
final searchProvider = _resolveSearchProvider(
|
||||
settings.searchProvider,
|
||||
extState.extensions,
|
||||
);
|
||||
|
||||
if (searchProvider == null || searchProvider.isEmpty) return false;
|
||||
|
||||
@@ -599,9 +754,32 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
Future<void> _performSearch(String query, {String? filterOverride}) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extState = ref.read(extensionProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
final selectedFilter =
|
||||
filterOverride ?? ref.read(trackProvider).selectedSearchFilter;
|
||||
final searchProvider = _resolveSearchProvider(
|
||||
settings.searchProvider,
|
||||
extState.extensions,
|
||||
);
|
||||
final storedFilter = ref.read(trackProvider).selectedSearchFilter;
|
||||
final selectedFilter = switch (filterOverride) {
|
||||
'all' => null,
|
||||
final explicit? => _sanitizeSearchFilterForProvider(
|
||||
explicit,
|
||||
searchProvider,
|
||||
extState.extensions,
|
||||
),
|
||||
null => switch (storedFilter) {
|
||||
'all' => null,
|
||||
final stored? => _sanitizeSearchFilterForProvider(
|
||||
stored,
|
||||
searchProvider,
|
||||
extState.extensions,
|
||||
),
|
||||
null => _preferredSearchFilter(
|
||||
settings.defaultSearchTab,
|
||||
searchProvider,
|
||||
extState.extensions,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
final searchKey =
|
||||
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
|
||||
@@ -627,7 +805,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
await ref
|
||||
.read(trackProvider.notifier)
|
||||
.customSearch(searchProvider, query, options: options);
|
||||
.customSearch(
|
||||
searchProvider,
|
||||
query,
|
||||
options: options,
|
||||
selectedFilter: selectedFilter,
|
||||
);
|
||||
} else if (isBuiltInProvider) {
|
||||
await ref
|
||||
.read(trackProvider.notifier)
|
||||
@@ -1062,6 +1245,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final hasSearchedBefore = ref.watch(
|
||||
settingsProvider.select((s) => s.hasSearchedBefore),
|
||||
);
|
||||
final defaultSearchTab = ref.watch(
|
||||
settingsProvider.select((s) => s.defaultSearchTab),
|
||||
);
|
||||
|
||||
final hasExploreContent = ref.watch(
|
||||
exploreProvider.select((s) => s.sections.isNotEmpty),
|
||||
@@ -1103,6 +1289,29 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
(hasHomeFeedExtension || hasExploreContent) &&
|
||||
hasExploreContent;
|
||||
|
||||
ref.listen<String>(
|
||||
settingsProvider.select((s) => s.defaultSearchTab),
|
||||
(previous, next) {
|
||||
if (previous == next) return;
|
||||
final selectedSearchFilter = ref.read(
|
||||
trackProvider.select((s) => s.selectedSearchFilter),
|
||||
);
|
||||
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final text = _urlController.text.trim();
|
||||
if (text.isEmpty || text.length < _minLiveSearchChars) return;
|
||||
if (text.startsWith('http') || text.startsWith('spotify:')) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_lastSearchQuery = null;
|
||||
_performSearch(text);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (hasActualResults &&
|
||||
isShowingRecentAccess &&
|
||||
hasSearchInput &&
|
||||
@@ -1246,7 +1455,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return SliverToBoxAdapter(
|
||||
child: _buildSearchFilterBar(
|
||||
searchFilters,
|
||||
selectedSearchFilter,
|
||||
_displaySearchFilterSelection(
|
||||
selectedSearchFilter,
|
||||
defaultSearchTab,
|
||||
currentSearchProvider,
|
||||
extensions,
|
||||
),
|
||||
colorScheme,
|
||||
),
|
||||
);
|
||||
@@ -1443,7 +1657,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
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),
|
||||
@@ -2086,17 +2306,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
}
|
||||
|
||||
bool _isEnabledMetadataExtension(String? providerId) {
|
||||
final normalized = providerId?.trim();
|
||||
if (normalized == null || normalized.isEmpty) return false;
|
||||
|
||||
return ref
|
||||
.read(extensionProvider)
|
||||
.extensions
|
||||
.any(
|
||||
(ext) =>
|
||||
ext.enabled && ext.hasMetadataProvider && ext.id == normalized,
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToRecentItem(RecentAccessItem item) {
|
||||
_searchFocusNode.unfocus();
|
||||
|
||||
switch (item.type) {
|
||||
case RecentAccessType.artist:
|
||||
if (item.providerId != null &&
|
||||
item.providerId!.isNotEmpty &&
|
||||
item.providerId != 'deezer' &&
|
||||
item.providerId != 'spotify' &&
|
||||
item.providerId != 'tidal' &&
|
||||
item.providerId != 'qobuz') {
|
||||
if (_isEnabledMetadataExtension(item.providerId)) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
@@ -2133,12 +2361,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (item.providerId != null &&
|
||||
item.providerId!.isNotEmpty &&
|
||||
item.providerId != 'deezer' &&
|
||||
item.providerId != 'spotify' &&
|
||||
item.providerId != 'tidal' &&
|
||||
item.providerId != 'qobuz') {
|
||||
} else if (_isEnabledMetadataExtension(item.providerId)) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
@@ -2183,12 +2406,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.providerId != null &&
|
||||
item.providerId!.isNotEmpty &&
|
||||
item.providerId != 'deezer' &&
|
||||
item.providerId != 'spotify' &&
|
||||
item.providerId != 'tidal' &&
|
||||
item.providerId != 'qobuz') {
|
||||
if (_isEnabledMetadataExtension(item.providerId)) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
@@ -2217,7 +2435,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
||||
Future<void> _navigateToMetadataScreen(
|
||||
DownloadHistoryItem item, {
|
||||
List<DownloadHistoryItem>? navigationItems,
|
||||
int? navigationIndex,
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(item.coverUrl);
|
||||
final beforeModTime =
|
||||
@@ -2226,7 +2448,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,
|
||||
@@ -3015,6 +3243,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
albumId: albumItem.id,
|
||||
albumName: albumItem.name,
|
||||
coverUrl: albumItem.coverUrl,
|
||||
initialAlbumType: albumItem.albumType,
|
||||
initialTotalTracks: albumItem.totalTracks,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -3093,8 +3323,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
String _getSearchHint() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
final extState = ref.read(extensionProvider);
|
||||
final searchProvider = _resolveSearchProvider(
|
||||
settings.searchProvider,
|
||||
extState.extensions,
|
||||
);
|
||||
|
||||
if (!extState.isInitialized) {
|
||||
return 'Paste supported URL or search...';
|
||||
@@ -3136,10 +3369,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilterChip(
|
||||
label: Text(context.l10n.historyFilterAll),
|
||||
selected: selectedFilter == null,
|
||||
selected: selectedFilter == 'all',
|
||||
onSelected: (_) {
|
||||
ref.read(trackProvider.notifier).setSearchFilter(null);
|
||||
_triggerSearchWithFilter(null);
|
||||
ref.read(trackProvider.notifier).setSearchFilter('all');
|
||||
_triggerSearchWithFilter('all');
|
||||
},
|
||||
showCheckmark: false,
|
||||
),
|
||||
@@ -3295,9 +3528,23 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
|
||||
const _SearchProviderDropdown({this.onProviderChanged});
|
||||
|
||||
Extension? _defaultSearchExtension(List<Extension> extensions) {
|
||||
return extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.firstOrNull ??
|
||||
extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentProvider = ref.watch(
|
||||
final rawCurrentProvider = ref.watch(
|
||||
settingsProvider.select((s) => s.searchProvider),
|
||||
);
|
||||
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
|
||||
@@ -3306,6 +3553,17 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
final searchProviders = extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.toList();
|
||||
final primarySearchExtension = _defaultSearchExtension(searchProviders);
|
||||
final defaultProviderLabel =
|
||||
primarySearchExtension?.displayName ?? 'Deezer';
|
||||
final defaultProviderIconPath = primarySearchExtension?.iconPath;
|
||||
final currentProvider =
|
||||
rawCurrentProvider != null &&
|
||||
rawCurrentProvider.isNotEmpty &&
|
||||
({'tidal', 'qobuz'}.contains(rawCurrentProvider) ||
|
||||
searchProviders.any((e) => e.id == rawCurrentProvider))
|
||||
? rawCurrentProvider
|
||||
: null;
|
||||
|
||||
Extension? currentExt;
|
||||
if (currentProvider != null && currentProvider.isNotEmpty) {
|
||||
@@ -3325,6 +3583,19 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
if (currentExt.searchBehavior?.icon != null) {
|
||||
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
|
||||
}
|
||||
} else if (primarySearchExtension?.searchBehavior?.icon != null) {
|
||||
displayIcon = _getIconFromName(
|
||||
primarySearchExtension!.searchBehavior!.icon!,
|
||||
);
|
||||
iconPath = defaultProviderIconPath;
|
||||
} else if (defaultProviderIconPath != null &&
|
||||
defaultProviderIconPath.isNotEmpty) {
|
||||
iconPath = defaultProviderIconPath;
|
||||
if (primarySearchExtension?.searchBehavior?.icon != null) {
|
||||
displayIcon = _getIconFromName(
|
||||
primarySearchExtension!.searchBehavior!.icon!,
|
||||
);
|
||||
}
|
||||
} else if (isBuiltInProvider) {
|
||||
displayIcon = Icons.music_note;
|
||||
}
|
||||
@@ -3379,7 +3650,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Deezer',
|
||||
defaultProviderLabel,
|
||||
style: TextStyle(
|
||||
fontWeight:
|
||||
currentProvider == null || currentProvider.isEmpty
|
||||
@@ -4299,6 +4570,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumId;
|
||||
final String albumName;
|
||||
final String? coverUrl;
|
||||
final String? initialAlbumType;
|
||||
final int? initialTotalTracks;
|
||||
|
||||
const ExtensionAlbumScreen({
|
||||
super.key,
|
||||
@@ -4306,6 +4579,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
required this.albumId,
|
||||
required this.albumName,
|
||||
this.coverUrl,
|
||||
this.initialAlbumType,
|
||||
this.initialTotalTracks,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -4319,10 +4594,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();
|
||||
}
|
||||
|
||||
@@ -4356,17 +4635,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) {
|
||||
@@ -4378,7 +4668,11 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
Track _parseTrack(
|
||||
Map<String, dynamic> data, {
|
||||
String? albumTypeFallback,
|
||||
int? totalTracksFallback,
|
||||
}) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
@@ -4406,7 +4700,14 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
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(),
|
||||
source: widget.extensionId,
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
@@ -94,20 +93,6 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
}
|
||||
|
||||
Future<void> _handleSharedUrl(String url) async {
|
||||
// Wait for extensions to be initialized before handling URL
|
||||
final extState = ref.read(extensionProvider);
|
||||
if (!extState.isInitialized) {
|
||||
_log.d('Waiting for extensions to initialize before handling URL...');
|
||||
for (int i = 0; i < 50; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
if (!mounted) return;
|
||||
if (ref.read(extensionProvider).isInitialized) {
|
||||
_log.d('Extensions initialized, proceeding with URL handling');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
|
||||
@@ -61,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;
|
||||
@@ -72,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;
|
||||
}
|
||||
|
||||
|
||||
+113
-13
@@ -2963,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) {
|
||||
@@ -2988,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());
|
||||
}
|
||||
|
||||
@@ -4227,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: [
|
||||
@@ -4419,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],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -4472,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],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -4540,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),
|
||||
@@ -4554,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),
|
||||
@@ -6609,8 +6685,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
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;
|
||||
@@ -6640,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,
|
||||
@@ -6816,8 +6904,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
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;
|
||||
@@ -6826,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,
|
||||
|
||||
@@ -789,13 +789,6 @@ class _ExtensionItem extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'by ${extension.author}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (extension.requiresNewerApp) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
|
||||
@@ -182,6 +182,13 @@ class AboutPage extends StatelessWidget {
|
||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.campaign_outlined,
|
||||
title: context.l10n.aboutKeepAndroidOpen,
|
||||
subtitle: 'keepandroidopen.org',
|
||||
onTap: () => _launchUrl('https://keepandroidopen.org/'),
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.bug_report_outlined,
|
||||
title: context.l10n.aboutReportIssue,
|
||||
|
||||
@@ -164,7 +164,15 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
const donorNames = <String>['R4ND0MIZ3D', 'Isra', 'bigJr48'];
|
||||
const donorNames = <String>[
|
||||
'Ldav',
|
||||
'Nico',
|
||||
'Feuerstern',
|
||||
'R4ND0MIZ3D',
|
||||
'Isra',
|
||||
'bigJr48',
|
||||
'Mick',
|
||||
];
|
||||
|
||||
// Match SettingsGroup color logic
|
||||
final cardColor = isDark
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class DownloadFallbackExtensionsPage extends ConsumerStatefulWidget {
|
||||
const DownloadFallbackExtensionsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DownloadFallbackExtensionsPage> createState() =>
|
||||
_DownloadFallbackExtensionsPageState();
|
||||
}
|
||||
|
||||
class _DownloadFallbackExtensionsPageState
|
||||
extends ConsumerState<DownloadFallbackExtensionsPage> {
|
||||
late List<Extension> _extensions;
|
||||
late Set<String> _selectedExtensionIds;
|
||||
bool _hasChanges = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadExtensions();
|
||||
}
|
||||
|
||||
void _loadExtensions() {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
_extensions = extState.extensions
|
||||
.where(
|
||||
(extension) => extension.enabled && extension.hasDownloadProvider,
|
||||
)
|
||||
.toList();
|
||||
|
||||
final savedIds = settings.downloadFallbackExtensionIds;
|
||||
if (savedIds == null) {
|
||||
_selectedExtensionIds = _extensions
|
||||
.map((extension) => extension.id)
|
||||
.toSet();
|
||||
} else {
|
||||
final allowedIds = _extensions.map((extension) => extension.id).toSet();
|
||||
_selectedExtensionIds = savedIds
|
||||
.where((extensionId) => allowedIds.contains(extensionId))
|
||||
.toSet();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: !_hasChanges,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () async {
|
||||
if (_hasChanges) {
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
],
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.extensionsFallbackTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
context.l10n.providerPriorityFallbackExtensionsDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_extensions.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.extensionsNoDownloadProvider,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_extensions.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
margin: EdgeInsets.zero,
|
||||
children: List.generate(_extensions.length, (index) {
|
||||
final extension = _extensions[index];
|
||||
final isSelected = _selectedExtensionIds.contains(
|
||||
extension.id,
|
||||
);
|
||||
return SettingsSwitchItem(
|
||||
icon: Icons.extension_rounded,
|
||||
title: extension.displayName,
|
||||
subtitle: extension.id,
|
||||
value: isSelected,
|
||||
showDivider: index != _extensions.length - 1,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value) {
|
||||
_selectedExtensionIds.add(extension.id);
|
||||
} else {
|
||||
_selectedExtensionIds.remove(extension.id);
|
||||
}
|
||||
_hasChanges = true;
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_extensions.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Text(
|
||||
context.l10n.providerPriorityFallbackExtensionsHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.dialogDiscardChanges),
|
||||
content: Text(context.l10n.dialogUnsavedChanges),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.dialogDiscard),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
void _saveChanges() {
|
||||
final allExtensionIds = _extensions
|
||||
.map((extension) => extension.id)
|
||||
.toList();
|
||||
final selectedExtensionIds = allExtensionIds
|
||||
.where(_selectedExtensionIds.contains)
|
||||
.toList();
|
||||
final fallbackExtensionIds =
|
||||
selectedExtensionIds.length == allExtensionIds.length
|
||||
? null
|
||||
: selectedExtensionIds;
|
||||
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadFallbackExtensionIds(fallbackExtensionIds);
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'deezer'];
|
||||
static const _builtInServices = ['tidal', 'qobuz'];
|
||||
static const _songLinkRegions = [
|
||||
'AD',
|
||||
'AE',
|
||||
@@ -2053,7 +2053,7 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final builtInServiceIds = ['tidal', 'qobuz', 'deezer'];
|
||||
final builtInServiceIds = ['tidal', 'qobuz'];
|
||||
|
||||
final extensionProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ExtensionDetailPage extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
@@ -49,7 +50,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
name: '',
|
||||
displayName: 'Unknown',
|
||||
version: '0.0.0',
|
||||
author: 'Unknown',
|
||||
description: '',
|
||||
enabled: false,
|
||||
status: 'error',
|
||||
@@ -205,10 +205,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_InfoRow(
|
||||
label: context.l10n.extensionAuthor,
|
||||
value: extension.author,
|
||||
),
|
||||
_InfoRow(
|
||||
label: context.l10n.extensionId,
|
||||
value: extension.id,
|
||||
@@ -404,6 +400,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
onChanged: (value) =>
|
||||
_updateSetting(setting.key, value),
|
||||
extensionId: widget.extensionId,
|
||||
onActionPayload: _handleExtensionActionPayload,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@@ -445,6 +442,27 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
.setExtensionSettings(widget.extensionId, _settings);
|
||||
}
|
||||
|
||||
/// Extensions may return `setting_updates` from button actions (e.g. OAuth URL field).
|
||||
Future<void> _handleExtensionActionPayload(
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final raw = payload['setting_updates'];
|
||||
if (raw is! Map) return;
|
||||
final partial = <String, dynamic>{};
|
||||
for (final entry in raw.entries) {
|
||||
partial[entry.key.toString()] = entry.value;
|
||||
}
|
||||
if (partial.isEmpty) return;
|
||||
final merged = Map<String, dynamic>.from(_settings);
|
||||
merged.addAll(partial);
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.setExtensionSettings(widget.extensionId, merged);
|
||||
if (mounted) {
|
||||
setState(() => _settings = merged);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmRemove(BuildContext context) async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -478,6 +496,41 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Long OAuth URLs: selectable text so users can copy without relying on snackbars.
|
||||
class _OauthLoginLinkPreview extends StatelessWidget {
|
||||
final String? value;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _OauthLoginLinkPreview({
|
||||
required this.value,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) {
|
||||
return Text(
|
||||
'Tap Connect to Spotify to fill this field.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
);
|
||||
}
|
||||
return SelectionArea(
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
@@ -645,12 +698,14 @@ class _SettingItem extends StatefulWidget {
|
||||
final bool showDivider;
|
||||
final ValueChanged<dynamic> onChanged;
|
||||
final String extensionId;
|
||||
final Future<void> Function(Map<String, dynamic> payload)? onActionPayload;
|
||||
|
||||
const _SettingItem({
|
||||
required this.setting,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.extensionId,
|
||||
this.onActionPayload,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
@@ -772,11 +827,17 @@ class _SettingItemState extends State<_SettingItem> {
|
||||
if (widget.setting.type == 'string' ||
|
||||
widget.setting.type == 'number') ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.value?.toString() ?? 'Not set',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
if (widget.setting.key == 'oauth_login_url')
|
||||
_OauthLoginLinkPreview(
|
||||
value: widget.value?.toString(),
|
||||
colorScheme: colorScheme,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
widget.value?.toString() ?? 'Not set',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -815,15 +876,45 @@ class _SettingItemState extends State<_SettingItem> {
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
final success = result['success'] as bool? ?? false;
|
||||
// Go may return either a flat map or { success, result: { ... } }.
|
||||
Map<String, dynamic> payload = result;
|
||||
final nested = result['result'];
|
||||
if (nested is Map) {
|
||||
payload = Map<String, dynamic>.from(nested);
|
||||
}
|
||||
|
||||
final success = payload['success'] as bool? ?? false;
|
||||
if (!success) {
|
||||
final error = result['error'] as String? ?? 'Action failed';
|
||||
final error =
|
||||
payload['error'] as String? ??
|
||||
result['error'] as String? ??
|
||||
'Action failed';
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(error)));
|
||||
} else {
|
||||
final message = result['message'] as String?;
|
||||
if (message != null) {
|
||||
if (widget.onActionPayload != null) {
|
||||
await widget.onActionPayload!(payload);
|
||||
}
|
||||
final openAuth = payload['open_auth_url'] as String?;
|
||||
if (openAuth != null && openAuth.isNotEmpty) {
|
||||
final uri = Uri.parse(openAuth);
|
||||
final launched = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarError('Could not open browser'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
final message = payload['message'] as String?;
|
||||
if (message != null && message.isNotEmpty && context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
|
||||
@@ -8,9 +8,10 @@ import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/explore_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/settings/download_fallback_extensions_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
@@ -151,6 +152,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_DownloadPriorityItem(),
|
||||
_DownloadFallbackItem(),
|
||||
_MetadataPriorityItem(),
|
||||
_SearchProviderSelector(),
|
||||
_HomeFeedProviderSelector(),
|
||||
@@ -423,7 +425,7 @@ class _ExtensionItem extends StatelessWidget {
|
||||
hasError
|
||||
? extension.errorMessage ??
|
||||
context.l10n.extensionsErrorLoading
|
||||
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
|
||||
: 'v${extension.version}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
@@ -588,6 +590,73 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadFallbackItem extends ConsumerWidget {
|
||||
const _DownloadFallbackItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final hasDownloadExtensions = extState.extensions.any(
|
||||
(e) => e.enabled && e.hasDownloadProvider,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: hasDownloadExtensions
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const DownloadFallbackExtensionsPage(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.alt_route,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.extensionsFallbackTitle,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasDownloadExtensions ? null : colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasDownloadExtensions
|
||||
? context.l10n.extensionsFallbackSubtitle
|
||||
: context.l10n.extensionsNoDownloadProvider,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchProviderSelector extends ConsumerWidget {
|
||||
const _SearchProviderSelector();
|
||||
|
||||
|
||||
@@ -225,8 +225,8 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Deezer',
|
||||
icon: Icons.album,
|
||||
description: context.l10n.metadataNoRateLimits,
|
||||
isBuiltIn: true,
|
||||
description: context.l10n.providerExtension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
case 'qobuz':
|
||||
return _MetadataProviderInfo(
|
||||
|
||||
@@ -70,7 +70,12 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(children: [const _MetadataSourceSelector()]),
|
||||
child: SettingsGroup(
|
||||
children: const [
|
||||
_MetadataSourceSelector(),
|
||||
_DefaultSearchTabSelector(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
@@ -714,13 +719,39 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
|
||||
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'};
|
||||
|
||||
Extension? _defaultSearchExtension(List<Extension> extensions) {
|
||||
return extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.firstOrNull ??
|
||||
extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
final searchProvider = settings.searchProvider ?? '';
|
||||
final rawSearchProvider = settings.searchProvider?.trim() ?? '';
|
||||
final isValidBuiltIn = _builtInProviders.containsKey(rawSearchProvider);
|
||||
final primarySearchExtension = _defaultSearchExtension(extState.extensions);
|
||||
final defaultProviderLabel =
|
||||
primarySearchExtension?.displayName ?? 'Deezer';
|
||||
final searchProvider =
|
||||
isValidBuiltIn ||
|
||||
extState.extensions.any(
|
||||
(e) =>
|
||||
e.enabled && e.hasCustomSearch && e.id == rawSearchProvider,
|
||||
)
|
||||
? rawSearchProvider
|
||||
: '';
|
||||
final isBuiltIn = _builtInProviders.containsKey(searchProvider);
|
||||
|
||||
Extension? activeExtension;
|
||||
@@ -765,37 +796,45 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_SourceChip(
|
||||
icon: Icons.graphic_eq,
|
||||
label: 'Deezer',
|
||||
isSelected: searchProvider.isEmpty,
|
||||
onTap: () {
|
||||
if (hasNonDefaultProvider) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
},
|
||||
Expanded(
|
||||
child: _SourceChip(
|
||||
icon: Icons.graphic_eq,
|
||||
label: defaultProviderLabel,
|
||||
isSelected: searchProvider.isEmpty,
|
||||
onTap: () {
|
||||
if (hasNonDefaultProvider) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(null);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_SourceChip(
|
||||
icon: Icons.waves,
|
||||
label: 'Tidal',
|
||||
isSelected: searchProvider == 'tidal',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider('tidal');
|
||||
},
|
||||
Expanded(
|
||||
child: _SourceChip(
|
||||
icon: Icons.waves,
|
||||
label: 'Tidal',
|
||||
isSelected: searchProvider == 'tidal',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider('tidal');
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_SourceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: searchProvider == 'qobuz',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider('qobuz');
|
||||
},
|
||||
Expanded(
|
||||
child: _SourceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: searchProvider == 'qobuz',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider('qobuz');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -811,7 +850,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tap Deezer to switch back from extension',
|
||||
'Tap $defaultProviderLabel to switch back from extension',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -826,6 +865,88 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DefaultSearchTabSelector extends ConsumerWidget {
|
||||
const _DefaultSearchTabSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final selectedTab = ref.watch(
|
||||
settingsProvider.select((s) => s.defaultSearchTab),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.optionsDefaultSearchTab,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.optionsDefaultSearchTabSubtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SourceChip(
|
||||
icon: Icons.dashboard_outlined,
|
||||
label: context.l10n.historyFilterAll,
|
||||
isSelected: selectedTab == 'all',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDefaultSearchTab('all'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SourceChip(
|
||||
icon: Icons.music_note,
|
||||
label: context.l10n.searchSongs,
|
||||
isSelected: selectedTab == 'track',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDefaultSearchTab('track'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SourceChip(
|
||||
icon: Icons.person,
|
||||
label: context.l10n.searchArtists,
|
||||
isSelected: selectedTab == 'artist',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDefaultSearchTab('artist'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SourceChip(
|
||||
icon: Icons.album,
|
||||
label: context.l10n.searchAlbums,
|
||||
isSelected: selectedTab == 'album',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDefaultSearchTab('album'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
@@ -851,39 +972,36 @@ class _SourceChip extends StatelessWidget {
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
return Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 28,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 28,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('SetupScreen');
|
||||
|
||||
class SetupScreen extends ConsumerStatefulWidget {
|
||||
const SetupScreen({super.key});
|
||||
@@ -233,7 +236,21 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
if (Platform.isIOS) {
|
||||
await _showIOSDirectoryOptions();
|
||||
} else if (Platform.isAndroid) {
|
||||
final result = await PlatformBridge.pickSafTree();
|
||||
Map<String, dynamic>? result;
|
||||
try {
|
||||
result = await PlatformBridge.pickSafTree();
|
||||
} catch (e) {
|
||||
_log.w('Failed to open Android SAF picker: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarCannotOpenFile(e.toString()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (result != null) {
|
||||
final treeUri = result['tree_uri'] as String? ?? '';
|
||||
final displayName = result['display_name'] as String? ?? '';
|
||||
|
||||
@@ -171,12 +171,6 @@ class _ExtensionDetailsScreenState
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.extensionsAuthor(ext.author),
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -24,6 +24,7 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||
|
||||
final _log = AppLogger('TrackMetadata');
|
||||
@@ -41,12 +42,35 @@ class _EmbeddedCoverPreviewCacheEntry {
|
||||
class TrackMetadataScreen extends ConsumerStatefulWidget {
|
||||
final DownloadHistoryItem? item;
|
||||
final LocalLibraryItem? localItem;
|
||||
final List<DownloadHistoryItem>? historyNavigationItems;
|
||||
final List<LocalLibraryItem>? localNavigationItems;
|
||||
final int? navigationIndex;
|
||||
|
||||
const TrackMetadataScreen({super.key, this.item, this.localItem})
|
||||
: assert(
|
||||
item != null || localItem != null,
|
||||
'Either item or localItem must be provided',
|
||||
);
|
||||
const TrackMetadataScreen({
|
||||
super.key,
|
||||
this.item,
|
||||
this.localItem,
|
||||
this.historyNavigationItems,
|
||||
this.localNavigationItems,
|
||||
this.navigationIndex,
|
||||
}) : assert(
|
||||
item != null || localItem != null,
|
||||
'Either item or localItem must be provided',
|
||||
),
|
||||
assert(
|
||||
historyNavigationItems == null || localNavigationItems == null,
|
||||
'Provide only one navigation list type',
|
||||
),
|
||||
assert(
|
||||
navigationIndex == null ||
|
||||
((historyNavigationItems != null &&
|
||||
navigationIndex >= 0 &&
|
||||
navigationIndex < historyNavigationItems.length) ||
|
||||
(localNavigationItems != null &&
|
||||
navigationIndex >= 0 &&
|
||||
navigationIndex < localNavigationItems.length)),
|
||||
'navigationIndex must be within the provided navigation list',
|
||||
);
|
||||
|
||||
@override
|
||||
ConsumerState<TrackMetadataScreen> createState() =>
|
||||
@@ -74,6 +98,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool _isConverting = false;
|
||||
bool _hasMetadataChanges = false;
|
||||
bool _hasLoadedResolvedAudioMetadata = false;
|
||||
bool _isTrackSwipeNavigationInFlight = false;
|
||||
Map<String, dynamic>? _editedMetadata;
|
||||
String? _embeddedCoverPreviewPath;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
@@ -327,15 +352,25 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
// Resolve label/copyright from file when the model doesn't carry them
|
||||
// (e.g. local library items, or download history items without these fields).
|
||||
final resolvedTrackNumber = _readPositiveInt(metadata['track_number']);
|
||||
final resolvedTotalTracks = _readPositiveInt(metadata['total_tracks']);
|
||||
final resolvedDiscNumber = _readPositiveInt(metadata['disc_number']);
|
||||
final resolvedTotalDiscs = _readPositiveInt(metadata['total_discs']);
|
||||
final resolvedComposer = metadata['composer']?.toString();
|
||||
final resolvedLabel = metadata['label']?.toString();
|
||||
final resolvedCopyright = metadata['copyright']?.toString();
|
||||
final needsTrackNumber =
|
||||
resolvedTrackNumber != null &&
|
||||
resolvedTrackNumber > 0 &&
|
||||
trackNumber == null;
|
||||
final needsTotalTracks =
|
||||
resolvedTotalTracks != null &&
|
||||
resolvedTotalTracks > 0 &&
|
||||
totalTracks == null;
|
||||
final needsDiscNumber =
|
||||
resolvedDiscNumber != null &&
|
||||
resolvedDiscNumber > 0 &&
|
||||
discNumber == null;
|
||||
final needsTotalDiscs =
|
||||
resolvedTotalDiscs != null &&
|
||||
resolvedTotalDiscs > 0 &&
|
||||
@@ -357,13 +392,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
!_isLocalItem &&
|
||||
(resolvedBitDepth != null ||
|
||||
resolvedSampleRate != null ||
|
||||
needsTrackNumber ||
|
||||
needsTotalTracks ||
|
||||
needsDiscNumber ||
|
||||
needsTotalDiscs ||
|
||||
needsComposer ||
|
||||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
|
||||
|
||||
if ((resolvedBitDepth != null ||
|
||||
resolvedSampleRate != null ||
|
||||
needsAlbum ||
|
||||
needsDuration ||
|
||||
needsTrackNumber ||
|
||||
needsTotalTracks ||
|
||||
needsDiscNumber ||
|
||||
needsTotalDiscs ||
|
||||
needsComposer ||
|
||||
needsLabel ||
|
||||
@@ -379,7 +421,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
|
||||
if (needsAlbum) 'album': resolvedAlbum,
|
||||
if (needsDuration) 'duration': resolvedDuration,
|
||||
if (needsTrackNumber) 'track_number': resolvedTrackNumber,
|
||||
if (needsTotalTracks) 'total_tracks': resolvedTotalTracks,
|
||||
if (needsDiscNumber) 'disc_number': resolvedDiscNumber,
|
||||
if (needsTotalDiscs) 'total_discs': resolvedTotalDiscs,
|
||||
if (needsComposer) 'composer': resolvedComposer,
|
||||
if (needsLabel) 'label': resolvedLabel,
|
||||
@@ -396,6 +440,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
quality: resolvedQuality,
|
||||
bitDepth: resolvedBitDepth,
|
||||
sampleRate: resolvedSampleRate,
|
||||
trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
|
||||
totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
|
||||
discNumber: needsDiscNumber ? resolvedDiscNumber : null,
|
||||
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
|
||||
composer: needsComposer ? resolvedComposer : null,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -468,6 +517,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool get _isLocalItem => widget.localItem != null;
|
||||
DownloadHistoryItem? get _downloadItem => widget.item;
|
||||
LocalLibraryItem? get _localLibraryItem => widget.localItem;
|
||||
bool get _hasHistoryNavigation =>
|
||||
widget.historyNavigationItems != null && widget.navigationIndex != null;
|
||||
bool get _hasLocalNavigation =>
|
||||
widget.localNavigationItems != null && widget.navigationIndex != null;
|
||||
bool get _hasTrackSwipeNavigation =>
|
||||
_hasHistoryNavigation || _hasLocalNavigation;
|
||||
int? get _navigationIndex => widget.navigationIndex;
|
||||
int get _navigationLength =>
|
||||
widget.historyNavigationItems?.length ??
|
||||
widget.localNavigationItems?.length ??
|
||||
0;
|
||||
|
||||
String get _itemId =>
|
||||
_isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
|
||||
@@ -505,7 +565,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
int? get totalTracks =>
|
||||
_readPositiveInt(_editedMetadata?['total_tracks']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.totalTracks : null);
|
||||
(_isLocalItem
|
||||
? _localLibraryItem!.totalTracks
|
||||
: _downloadItem!.totalTracks);
|
||||
|
||||
int? get discNumber {
|
||||
final edited = _editedMetadata?['disc_number'];
|
||||
@@ -520,7 +582,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
int? get totalDiscs =>
|
||||
_readPositiveInt(_editedMetadata?['total_discs']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.totalDiscs : null);
|
||||
(_isLocalItem
|
||||
? _localLibraryItem!.totalDiscs
|
||||
: _downloadItem!.totalDiscs);
|
||||
|
||||
String? get releaseDate =>
|
||||
_editedMetadata?['date']?.toString() ??
|
||||
@@ -777,118 +841,165 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Navigator.pop(context, _hasMetadataChanges ? true : null);
|
||||
}
|
||||
|
||||
void _handleHorizontalDragEnd(DragEndDetails details) {
|
||||
final velocity = details.primaryVelocity;
|
||||
if (velocity == null || velocity.abs() < 350) return;
|
||||
if (velocity < 0) {
|
||||
unawaited(_navigateToAdjacentTrack(1));
|
||||
} else {
|
||||
unawaited(_navigateToAdjacentTrack(-1));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _navigateToAdjacentTrack(int offset) async {
|
||||
if (_isTrackSwipeNavigationInFlight || !_hasTrackSwipeNavigation) return;
|
||||
final currentIndex = _navigationIndex;
|
||||
if (currentIndex == null) return;
|
||||
final targetIndex = currentIndex + offset;
|
||||
if (targetIndex < 0 || targetIndex >= _navigationLength) return;
|
||||
|
||||
_isTrackSwipeNavigationInFlight = true;
|
||||
final result = await Navigator.of(context).push<bool>(
|
||||
adjacentHorizontalPageRoute<bool>(
|
||||
page: _buildSiblingTrackScreen(targetIndex),
|
||||
fromRight: offset > 0,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, result == true || _hasMetadataChanges ? true : null);
|
||||
}
|
||||
|
||||
TrackMetadataScreen _buildSiblingTrackScreen(int targetIndex) {
|
||||
if (_hasHistoryNavigation) {
|
||||
return TrackMetadataScreen(
|
||||
item: widget.historyNavigationItems![targetIndex],
|
||||
historyNavigationItems: widget.historyNavigationItems,
|
||||
navigationIndex: targetIndex,
|
||||
);
|
||||
}
|
||||
return TrackMetadataScreen(
|
||||
localItem: widget.localNavigationItems![targetIndex],
|
||||
localNavigationItems: widget.localNavigationItems,
|
||||
navigationIndex: targetIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
trackName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio =
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: _buildHeaderBackground(
|
||||
context,
|
||||
colorScheme,
|
||||
expandedHeight,
|
||||
showContent,
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragEnd: _handleHorizontalDragEnd,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
trackName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground],
|
||||
);
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
shape: BoxShape.circle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
),
|
||||
onPressed: _popWithMetadataResult,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio =
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: _buildHeaderBackground(
|
||||
context,
|
||||
colorScheme,
|
||||
expandedHeight,
|
||||
showContent,
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground],
|
||||
);
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.more_vert, color: Colors.white),
|
||||
child: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
),
|
||||
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
|
||||
onPressed: _popWithMetadataResult,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMetadataCard(context, colorScheme, _fileSize),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildFileInfoCard(
|
||||
context,
|
||||
colorScheme,
|
||||
_fileExists,
|
||||
_fileSize,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.more_vert, color: Colors.white),
|
||||
),
|
||||
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMetadataCard(context, colorScheme, _fileSize),
|
||||
|
||||
_buildLyricsCard(context, colorScheme),
|
||||
|
||||
if (_fileExists) ...[
|
||||
const SizedBox(height: 16),
|
||||
AudioAnalysisCard(filePath: _filePath),
|
||||
|
||||
_buildFileInfoCard(
|
||||
context,
|
||||
colorScheme,
|
||||
_fileExists,
|
||||
_fileSize,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildLyricsCard(context, colorScheme),
|
||||
|
||||
if (_fileExists) ...[
|
||||
const SizedBox(height: 16),
|
||||
AudioAnalysisCard(filePath: _filePath),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2767,9 +2878,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
albumArtist: normalizedOrNull(albumArtist),
|
||||
isrc: normalizedOrNull(isrc),
|
||||
trackNumber: trackNumber,
|
||||
totalTracks: totalTracks,
|
||||
discNumber: discNumber,
|
||||
totalDiscs: totalDiscs,
|
||||
releaseDate: normalizedOrNull(releaseDate),
|
||||
genre: normalizedOrNull(genre),
|
||||
composer: normalizedOrNull(composer),
|
||||
label: normalizedOrNull(label),
|
||||
copyright: normalizedOrNull(copyright),
|
||||
);
|
||||
@@ -4156,8 +4270,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
'copyright': val('copyright', copyright),
|
||||
'composer': val('composer', composer),
|
||||
'comment': fileMetadata?['comment']?.toString() ?? '',
|
||||
'lyrics': fileMetadata?['lyrics']?.toString() ?? '',
|
||||
};
|
||||
|
||||
final initialDurationSeconds =
|
||||
_readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0;
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final saved = await showModalBottomSheet<bool>(
|
||||
@@ -4173,6 +4291,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
initialValues: initialValues,
|
||||
filePath: cleanFilePath,
|
||||
sourceTrackId: _spotifyId,
|
||||
durationMs: initialDurationSeconds > 0
|
||||
? initialDurationSeconds * 1000
|
||||
: 0,
|
||||
artistTagMode: ref.read(settingsProvider).artistTagMode,
|
||||
),
|
||||
);
|
||||
@@ -4183,7 +4304,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
try {
|
||||
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
|
||||
setState(() => _editedMetadata = refreshed);
|
||||
final refreshedLyrics = refreshed['lyrics']?.toString().trim() ?? '';
|
||||
setState(() {
|
||||
_editedMetadata = refreshed;
|
||||
_lyricsError = null;
|
||||
_isInstrumental = false;
|
||||
_embeddedLyricsChecked = true;
|
||||
if (refreshedLyrics.isNotEmpty) {
|
||||
_lyrics = _cleanLrcForDisplay(refreshedLyrics);
|
||||
_rawLyrics = refreshedLyrics;
|
||||
_lyricsSource = 'Embedded';
|
||||
_lyricsEmbedded = true;
|
||||
} else {
|
||||
_lyrics = null;
|
||||
_rawLyrics = null;
|
||||
_lyricsSource = null;
|
||||
_lyricsEmbedded = false;
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
setState(() {});
|
||||
}
|
||||
@@ -4400,6 +4538,7 @@ class _EditMetadataSheet extends StatefulWidget {
|
||||
final Map<String, String> initialValues;
|
||||
final String filePath;
|
||||
final String? sourceTrackId;
|
||||
final int durationMs;
|
||||
final String artistTagMode;
|
||||
|
||||
const _EditMetadataSheet({
|
||||
@@ -4407,6 +4546,7 @@ class _EditMetadataSheet extends StatefulWidget {
|
||||
required this.initialValues,
|
||||
required this.filePath,
|
||||
this.sourceTrackId,
|
||||
required this.durationMs,
|
||||
required this.artistTagMode,
|
||||
});
|
||||
|
||||
@@ -4446,6 +4586,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
'total_discs': 'total_discs',
|
||||
'genre': 'genre',
|
||||
'isrc': 'isrc',
|
||||
'lyrics': 'lyrics',
|
||||
'label': 'label',
|
||||
'copyright': 'copyright',
|
||||
'composer': 'composer',
|
||||
@@ -4463,6 +4604,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
late final TextEditingController _discTotalCtrl;
|
||||
late final TextEditingController _genreCtrl;
|
||||
late final TextEditingController _isrcCtrl;
|
||||
late final TextEditingController _lyricsCtrl;
|
||||
late final TextEditingController _labelCtrl;
|
||||
late final TextEditingController _copyrightCtrl;
|
||||
late final TextEditingController _composerCtrl;
|
||||
@@ -4658,6 +4800,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return l10n.editMetadataFieldGenre;
|
||||
case 'isrc':
|
||||
return l10n.editMetadataFieldIsrc;
|
||||
case 'lyrics':
|
||||
return l10n.trackLyrics;
|
||||
case 'label':
|
||||
return l10n.editMetadataFieldLabel;
|
||||
case 'copyright':
|
||||
@@ -4695,6 +4839,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return _genreCtrl;
|
||||
case 'isrc':
|
||||
return _isrcCtrl;
|
||||
case 'lyrics':
|
||||
return _lyricsCtrl;
|
||||
case 'label':
|
||||
return _labelCtrl;
|
||||
case 'copyright':
|
||||
@@ -4993,19 +5139,23 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
final artist = _artistCtrl.text.trim();
|
||||
final album = _albumCtrl.text.trim();
|
||||
final currentIsrc = _isrcCtrl.text.trim().toUpperCase();
|
||||
final shouldFetchLyrics = _autoFillFields.contains('lyrics');
|
||||
final needsTrackLookup = _autoFillFields.any((key) => key != 'lyrics');
|
||||
Map<String, dynamic>? best;
|
||||
String? deezerId;
|
||||
|
||||
try {
|
||||
final resolved = await _resolveAutoFillTrackFromIdentifiers(
|
||||
currentIsrc,
|
||||
);
|
||||
if (resolved != null) {
|
||||
best = resolved.track;
|
||||
deezerId = resolved.deezerId;
|
||||
if (needsTrackLookup) {
|
||||
try {
|
||||
final resolved = await _resolveAutoFillTrackFromIdentifiers(
|
||||
currentIsrc,
|
||||
);
|
||||
if (resolved != null) {
|
||||
best = resolved.track;
|
||||
deezerId = resolved.deezerId;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Identifier-first autofill lookup failed: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Identifier-first autofill lookup failed: $e');
|
||||
}
|
||||
|
||||
final queryParts = <String>[];
|
||||
@@ -5013,7 +5163,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
if (artist.isNotEmpty) queryParts.add(artist);
|
||||
if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album);
|
||||
|
||||
if (best == null && queryParts.isEmpty) {
|
||||
if (needsTrackLookup && best == null && queryParts.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)),
|
||||
@@ -5026,7 +5176,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
final normalizedArtist = _normalizeMetadataText(artist);
|
||||
final normalizedAlbum = _normalizeMetadataText(album);
|
||||
|
||||
if (best == null) {
|
||||
if (needsTrackLookup && best == null) {
|
||||
final query = queryParts.join(' ');
|
||||
final results = await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
@@ -5061,39 +5211,47 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
}
|
||||
|
||||
final selectedBest = best;
|
||||
if (selectedBest == null) {
|
||||
if (needsTrackLookup && selectedBest == null) {
|
||||
throw StateError('No metadata match resolved for auto-fill');
|
||||
}
|
||||
|
||||
final enriched = <String, String>{
|
||||
'title': (selectedBest['name'] ?? '').toString(),
|
||||
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
|
||||
.toString(),
|
||||
'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '')
|
||||
.toString(),
|
||||
'album_artist': (selectedBest['album_artist'] ?? '').toString(),
|
||||
'date': (selectedBest['release_date'] ?? '').toString(),
|
||||
'track_number': (selectedBest['track_number'] ?? '').toString(),
|
||||
'total_tracks': (selectedBest['total_tracks'] ?? '').toString(),
|
||||
'disc_number': (selectedBest['disc_number'] ?? '').toString(),
|
||||
'total_discs': (selectedBest['total_discs'] ?? '').toString(),
|
||||
'isrc': (selectedBest['isrc'] ?? '').toString(),
|
||||
'composer': (selectedBest['composer'] ?? '').toString(),
|
||||
};
|
||||
_mergeOnlineTrackData(enriched, selectedBest);
|
||||
final enriched = <String, String>{};
|
||||
if (selectedBest != null) {
|
||||
enriched.addAll(<String, String>{
|
||||
'title': (selectedBest['name'] ?? '').toString(),
|
||||
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
|
||||
.toString(),
|
||||
'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '')
|
||||
.toString(),
|
||||
'album_artist': (selectedBest['album_artist'] ?? '').toString(),
|
||||
'date': (selectedBest['release_date'] ?? '').toString(),
|
||||
'track_number': (selectedBest['track_number'] ?? '').toString(),
|
||||
'total_tracks': (selectedBest['total_tracks'] ?? '').toString(),
|
||||
'disc_number': (selectedBest['disc_number'] ?? '').toString(),
|
||||
'total_discs': (selectedBest['total_discs'] ?? '').toString(),
|
||||
'isrc': (selectedBest['isrc'] ?? '').toString(),
|
||||
'composer': (selectedBest['composer'] ?? '').toString(),
|
||||
});
|
||||
_mergeOnlineTrackData(enriched, selectedBest);
|
||||
}
|
||||
|
||||
final enrichedIsrc = (enriched['isrc'] ?? '').trim();
|
||||
final needsIsrc =
|
||||
_autoFillFields.contains('isrc') && enriched['isrc']!.isEmpty;
|
||||
_autoFillFields.contains('isrc') && enrichedIsrc.isEmpty;
|
||||
final needsExtended =
|
||||
_autoFillFields.contains('genre') ||
|
||||
_autoFillFields.contains('label') ||
|
||||
_autoFillFields.contains('copyright') ||
|
||||
_autoFillFields.contains('composer');
|
||||
|
||||
final rawSpotifyId = _extractRawSpotifyTrackId(selectedBest);
|
||||
final rawSpotifyId = selectedBest == null
|
||||
? _extractRawSpotifyTrackIdFromValue(widget.sourceTrackId)
|
||||
: _extractRawSpotifyTrackId(selectedBest);
|
||||
|
||||
deezerId ??= _extractRawDeezerTrackId(selectedBest);
|
||||
final candidateIsrc = enriched['isrc']!.trim().toUpperCase();
|
||||
deezerId ??= selectedBest == null
|
||||
? null
|
||||
: _extractRawDeezerTrackId(selectedBest);
|
||||
final candidateIsrc = enrichedIsrc.toUpperCase();
|
||||
final deezerLookupIsrc = _looksLikeIsrc(currentIsrc)
|
||||
? currentIsrc
|
||||
: (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : '');
|
||||
@@ -5129,7 +5287,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
if (!mounted) return;
|
||||
|
||||
// Fetch ISRC from Deezer track metadata if still missing
|
||||
if (needsIsrc && enriched['isrc']!.isEmpty && deezerId != null) {
|
||||
if (needsIsrc &&
|
||||
(enriched['isrc'] ?? '').trim().isEmpty &&
|
||||
deezerId != null) {
|
||||
try {
|
||||
final deezerMeta = await PlatformBridge.getDeezerMetadata(
|
||||
'track',
|
||||
@@ -5161,6 +5321,37 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldFetchLyrics) {
|
||||
final lyricsTitle =
|
||||
((selectedBest?['name'] ?? selectedBest?['title'] ?? title)
|
||||
.toString())
|
||||
.trim();
|
||||
final lyricsArtist =
|
||||
((selectedBest?['artists'] ?? selectedBest?['artist'] ?? artist)
|
||||
.toString())
|
||||
.trim();
|
||||
|
||||
if (lyricsTitle.isNotEmpty && lyricsArtist.isNotEmpty) {
|
||||
try {
|
||||
final lyricsResult = await PlatformBridge.getLyricsLRCWithSource(
|
||||
rawSpotifyId ?? '',
|
||||
lyricsTitle,
|
||||
lyricsArtist,
|
||||
durationMs: widget.durationMs,
|
||||
);
|
||||
final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? '';
|
||||
final instrumental =
|
||||
(lyricsResult['instrumental'] as bool? ?? false) ||
|
||||
lyricsText == '[instrumental:true]';
|
||||
if (!instrumental && lyricsText.isNotEmpty) {
|
||||
enriched['lyrics'] = lyricsText;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Lyrics autofill failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
var filledCount = 0;
|
||||
@@ -5179,7 +5370,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
if (_autoFillFields.contains('cover')) {
|
||||
if (_autoFillFields.contains('cover') && selectedBest != null) {
|
||||
final coverUrl =
|
||||
(selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
|
||||
.toString();
|
||||
@@ -5255,6 +5446,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
_discTotalCtrl = TextEditingController(text: v['total_discs'] ?? '');
|
||||
_genreCtrl = TextEditingController(text: v['genre'] ?? '');
|
||||
_isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
|
||||
_lyricsCtrl = TextEditingController(text: v['lyrics'] ?? '');
|
||||
_labelCtrl = TextEditingController(text: v['label'] ?? '');
|
||||
_copyrightCtrl = TextEditingController(text: v['copyright'] ?? '');
|
||||
_composerCtrl = TextEditingController(text: v['composer'] ?? '');
|
||||
@@ -5277,6 +5469,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
_discTotalCtrl.dispose();
|
||||
_genreCtrl.dispose();
|
||||
_isrcCtrl.dispose();
|
||||
_lyricsCtrl.dispose();
|
||||
_labelCtrl.dispose();
|
||||
_copyrightCtrl.dispose();
|
||||
_composerCtrl.dispose();
|
||||
@@ -5299,6 +5492,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
'disc_total': _discTotalCtrl.text,
|
||||
'genre': _genreCtrl.text,
|
||||
'isrc': _isrcCtrl.text,
|
||||
'lyrics': _lyricsCtrl.text,
|
||||
'label': _labelCtrl.text,
|
||||
'copyright': _copyrightCtrl.text,
|
||||
'composer': _composerCtrl.text,
|
||||
@@ -5363,6 +5557,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
: '',
|
||||
'GENRE': metadata['genre'] ?? '',
|
||||
'ISRC': metadata['isrc'] ?? '',
|
||||
'LYRICS': metadata['lyrics'] ?? '',
|
||||
'UNSYNCEDLYRICS': metadata['lyrics'] ?? '',
|
||||
'ORGANIZATION': metadata['label'] ?? '',
|
||||
'COPYRIGHT': metadata['copyright'] ?? '',
|
||||
'COMPOSER': metadata['composer'] ?? '',
|
||||
@@ -5372,11 +5568,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
final existingMetadata = await PlatformBridge.readFileMetadata(
|
||||
ffmpegTarget,
|
||||
);
|
||||
final existingLyrics = existingMetadata['lyrics']?.toString().trim();
|
||||
if (existingLyrics != null && existingLyrics.isNotEmpty) {
|
||||
vorbisMap['LYRICS'] = existingLyrics;
|
||||
vorbisMap['UNSYNCEDLYRICS'] = existingLyrics;
|
||||
}
|
||||
// Preserve ReplayGain tags if present — these are computed once
|
||||
// during download and should survive manual metadata edits.
|
||||
final rgFields = <String, String>{
|
||||
@@ -5603,6 +5794,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
),
|
||||
_field('Genre', _genreCtrl),
|
||||
_field('ISRC', _isrcCtrl),
|
||||
_field(
|
||||
context.l10n.trackLyrics,
|
||||
_lyricsCtrl,
|
||||
maxLines: 8,
|
||||
keyboard: TextInputType.multiline,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: InkWell(
|
||||
|
||||
@@ -61,32 +61,29 @@ class CsvImportService {
|
||||
if (trackData == null) {
|
||||
try {
|
||||
final query = '${track.artistName} ${track.name}';
|
||||
final searchResult = await PlatformBridge.searchDeezerAll(
|
||||
final searchResult = await PlatformBridge.customSearchWithExtension(
|
||||
'deezer',
|
||||
query,
|
||||
trackLimit: 5,
|
||||
options: {'filter': 'track', 'limit': 5},
|
||||
);
|
||||
|
||||
if (searchResult.containsKey('tracks')) {
|
||||
final tracksList = searchResult['tracks'] as List<dynamic>?;
|
||||
if (tracksList != null && tracksList.isNotEmpty) {
|
||||
for (final result in tracksList) {
|
||||
final resultMap = result as Map<String, dynamic>;
|
||||
final resultName =
|
||||
(resultMap['name'] as String?)?.toLowerCase() ?? '';
|
||||
final trackNameLower = track.name.toLowerCase();
|
||||
if (searchResult.isNotEmpty) {
|
||||
for (final resultMap in searchResult) {
|
||||
final resultName =
|
||||
(resultMap['name'] as String?)?.toLowerCase() ?? '';
|
||||
final trackNameLower = track.name.toLowerCase();
|
||||
|
||||
if (resultName.contains(trackNameLower) ||
|
||||
trackNameLower.contains(resultName)) {
|
||||
trackData = resultMap;
|
||||
_log.d('Text search match for ${track.name}: $resultName');
|
||||
break;
|
||||
}
|
||||
if (resultName.contains(trackNameLower) ||
|
||||
trackNameLower.contains(resultName)) {
|
||||
trackData = resultMap;
|
||||
_log.d('Text search match for ${track.name}: $resultName');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (trackData == null && tracksList.isNotEmpty) {
|
||||
trackData = tracksList.first as Map<String, dynamic>;
|
||||
_log.d('Using first search result for ${track.name}');
|
||||
}
|
||||
if (trackData == null) {
|
||||
trackData = searchResult.first;
|
||||
_log.d('Using first search result for ${track.name}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
+372
-169
@@ -13,6 +13,95 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('FFmpeg');
|
||||
|
||||
class DownloadDecryptionDescriptor {
|
||||
final String strategy;
|
||||
final String key;
|
||||
final String? iv;
|
||||
final String? inputFormat;
|
||||
final String? outputExtension;
|
||||
final Map<String, dynamic> options;
|
||||
|
||||
const DownloadDecryptionDescriptor({
|
||||
required this.strategy,
|
||||
required this.key,
|
||||
this.iv,
|
||||
this.inputFormat,
|
||||
this.outputExtension,
|
||||
this.options = const {},
|
||||
});
|
||||
|
||||
factory DownloadDecryptionDescriptor.fromJson(Map<String, dynamic> json) {
|
||||
final rawOptions = json['options'];
|
||||
return DownloadDecryptionDescriptor(
|
||||
strategy: (json['strategy'] as String? ?? '').trim(),
|
||||
key: (json['key'] as String? ?? '').trim(),
|
||||
iv: (json['iv'] as String?)?.trim(),
|
||||
inputFormat: (json['input_format'] as String?)?.trim(),
|
||||
outputExtension: (json['output_extension'] as String?)?.trim(),
|
||||
options: rawOptions is Map
|
||||
? Map<String, dynamic>.from(rawOptions)
|
||||
: const {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{'strategy': strategy, 'key': key};
|
||||
if (iv != null && iv!.isNotEmpty) {
|
||||
json['iv'] = iv;
|
||||
}
|
||||
if (inputFormat != null && inputFormat!.isNotEmpty) {
|
||||
json['input_format'] = inputFormat;
|
||||
}
|
||||
if (outputExtension != null && outputExtension!.isNotEmpty) {
|
||||
json['output_extension'] = outputExtension;
|
||||
}
|
||||
if (options.isNotEmpty) {
|
||||
json['options'] = options;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
static DownloadDecryptionDescriptor? fromDownloadResult(
|
||||
Map<String, dynamic> result,
|
||||
) {
|
||||
final rawDecryption = result['decryption'];
|
||||
if (rawDecryption is Map) {
|
||||
final descriptor = DownloadDecryptionDescriptor.fromJson(
|
||||
Map<String, dynamic>.from(rawDecryption),
|
||||
);
|
||||
if (descriptor.normalizedStrategy == 'ffmpeg.mov_key' &&
|
||||
descriptor.key.isNotEmpty) {
|
||||
return descriptor;
|
||||
}
|
||||
}
|
||||
|
||||
final legacyKey = (result['decryption_key'] as String?)?.trim() ?? '';
|
||||
if (legacyKey.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DownloadDecryptionDescriptor(
|
||||
strategy: 'ffmpeg.mov_key',
|
||||
key: legacyKey,
|
||||
inputFormat: 'mov',
|
||||
);
|
||||
}
|
||||
|
||||
String get normalizedStrategy {
|
||||
switch (strategy.trim().toLowerCase()) {
|
||||
case '':
|
||||
case 'ffmpeg.mov_key':
|
||||
case 'ffmpeg_mov_key':
|
||||
case 'mov_decryption_key':
|
||||
case 'mp4_decryption_key':
|
||||
case 'ffmpeg.mp4_decryption_key':
|
||||
return 'ffmpeg.mov_key';
|
||||
default:
|
||||
return strategy.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FFmpegService {
|
||||
static const int _commandLogPreviewLength = 300;
|
||||
static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8);
|
||||
@@ -22,6 +111,7 @@ class FFmpegService {
|
||||
static const Duration _liveTunnelStabilizationDelay = Duration(
|
||||
milliseconds: 900,
|
||||
);
|
||||
static const String _genericMovKeyDecryptionStrategy = 'ffmpeg.mov_key';
|
||||
static int _tempEmbedCounter = 0;
|
||||
static FFmpegSession? _activeLiveDecryptSession;
|
||||
static String? _activeLiveDecryptUrl;
|
||||
@@ -216,12 +306,56 @@ class FFmpegService {
|
||||
required String decryptionKey,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final trimmedKey = decryptionKey.trim();
|
||||
if (trimmedKey.isEmpty) return inputPath;
|
||||
return decryptWithDescriptor(
|
||||
inputPath: inputPath,
|
||||
descriptor: DownloadDecryptionDescriptor(
|
||||
strategy: _genericMovKeyDecryptionStrategy,
|
||||
key: decryptionKey,
|
||||
inputFormat: 'mov',
|
||||
),
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
|
||||
// Encrypted streams are commonly MP4 container with FLAC audio.
|
||||
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
|
||||
final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
|
||||
static Future<String?> decryptWithDescriptor({
|
||||
required String inputPath,
|
||||
required DownloadDecryptionDescriptor descriptor,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final key = descriptor.key.trim();
|
||||
|
||||
switch (descriptor.normalizedStrategy) {
|
||||
case _genericMovKeyDecryptionStrategy:
|
||||
if (key.isEmpty) {
|
||||
return inputPath;
|
||||
}
|
||||
return _decryptMovKeyFile(
|
||||
inputPath: inputPath,
|
||||
decryptionKey: key,
|
||||
inputFormat: descriptor.inputFormat,
|
||||
outputExtension: descriptor.outputExtension,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
default:
|
||||
_log.e(
|
||||
'Unsupported download decryption strategy: ${descriptor.strategy}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String _resolvePreferredDecryptionExtension(
|
||||
String inputPath,
|
||||
String? requestedExtension,
|
||||
) {
|
||||
final trimmedRequested = (requestedExtension ?? '').trim();
|
||||
if (trimmedRequested.isNotEmpty) {
|
||||
return trimmedRequested.startsWith('.')
|
||||
? trimmedRequested
|
||||
: '.$trimmedRequested';
|
||||
}
|
||||
|
||||
return inputPath.toLowerCase().endsWith('.m4a')
|
||||
? '.flac'
|
||||
: inputPath.toLowerCase().endsWith('.flac')
|
||||
? '.flac'
|
||||
@@ -230,7 +364,23 @@ class FFmpegService {
|
||||
: inputPath.toLowerCase().endsWith('.opus')
|
||||
? '.opus'
|
||||
: '.flac';
|
||||
}
|
||||
|
||||
static Future<String?> _decryptMovKeyFile({
|
||||
required String inputPath,
|
||||
required String decryptionKey,
|
||||
String? inputFormat,
|
||||
String? outputExtension,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final preferredExt = _resolvePreferredDecryptionExtension(
|
||||
inputPath,
|
||||
outputExtension,
|
||||
);
|
||||
var tempOutput = _buildOutputPath(inputPath, preferredExt);
|
||||
final demuxerFormat = (inputFormat ?? '').trim().isNotEmpty
|
||||
? inputFormat!.trim()
|
||||
: 'mov';
|
||||
|
||||
String buildDecryptCommand(
|
||||
String outputPath, {
|
||||
@@ -241,10 +391,10 @@ class FFmpegService {
|
||||
// Force MOV demuxer: -decryption_key is only supported by the MOV/MP4
|
||||
// demuxer. The input may carry a .flac extension (SAF mode) while actually
|
||||
// containing an encrypted M4A stream, so we must override auto-detection.
|
||||
return '-v error -decryption_key "$key" -f mov -i "$inputPath" $audioMap-c copy "$outputPath" -y';
|
||||
return '-v error -decryption_key "$key" -f $demuxerFormat -i "$inputPath" $audioMap-c copy "$outputPath" -y';
|
||||
}
|
||||
|
||||
final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
|
||||
final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey);
|
||||
if (keyCandidates.isEmpty) {
|
||||
_log.e('No usable decryption key candidates');
|
||||
return null;
|
||||
@@ -989,20 +1139,28 @@ class FFmpegService {
|
||||
: '.tmp';
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, ext);
|
||||
|
||||
final sanitizedGain = albumGain.replaceAll('"', '\\"');
|
||||
final sanitizedPeak = albumPeak.replaceAll('"', '\\"');
|
||||
|
||||
// -map_metadata 0 preserves all existing metadata from the input.
|
||||
// -metadata flags add/overwrite only the specified keys.
|
||||
final command =
|
||||
'-v error -hide_banner -i "$filePath" -map 0 -c copy -map_metadata 0 '
|
||||
'-metadata REPLAYGAIN_ALBUM_GAIN="$sanitizedGain" '
|
||||
'-metadata REPLAYGAIN_ALBUM_PEAK="$sanitizedPeak" '
|
||||
'"$tempOutput" -y';
|
||||
final arguments = <String>[
|
||||
'-v',
|
||||
'error',
|
||||
'-hide_banner',
|
||||
'-i',
|
||||
filePath,
|
||||
'-map',
|
||||
'0',
|
||||
'-c',
|
||||
'copy',
|
||||
'-map_metadata',
|
||||
'0',
|
||||
'-metadata',
|
||||
'REPLAYGAIN_ALBUM_GAIN=$albumGain',
|
||||
'-metadata',
|
||||
'REPLAYGAIN_ALBUM_PEAK=$albumPeak',
|
||||
tempOutput,
|
||||
'-y',
|
||||
];
|
||||
|
||||
_log.d('Writing album ReplayGain tags via FFmpeg');
|
||||
final result = await _execute(command);
|
||||
final result = await _executeWithArguments(arguments);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
@@ -1044,41 +1202,50 @@ class FFmpegService {
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-v error -hide_banner ');
|
||||
cmdBuffer.write('-i "$flacPath" ');
|
||||
final arguments = <String>['-v', 'error', '-hide_banner', '-i', flacPath];
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
arguments
|
||||
..add('-i')
|
||||
..add(coverPath);
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
arguments
|
||||
..add('-map')
|
||||
..add('0:a');
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
cmdBuffer.write('-c:v copy ');
|
||||
cmdBuffer.write('-disposition:v attached_pic ');
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
arguments
|
||||
..add('-map')
|
||||
..add('1:0')
|
||||
..add('-c:v')
|
||||
..add('copy')
|
||||
..add('-disposition:v')
|
||||
..add('attached_pic')
|
||||
..add('-metadata:s:v')
|
||||
..add('title=Album cover')
|
||||
..add('-metadata:s:v')
|
||||
..add('comment=Cover (front)');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
arguments
|
||||
..add('-c:a')
|
||||
..add('copy');
|
||||
|
||||
if (metadata != null) {
|
||||
_appendVorbisMetadataToCommandBuffer(
|
||||
cmdBuffer,
|
||||
_appendVorbisMetadataToArguments(
|
||||
arguments,
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
}
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
arguments
|
||||
..add(tempOutput)
|
||||
..add('-y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg command: ${_previewCommandForLog(command)}');
|
||||
|
||||
final result = await _execute(command);
|
||||
_log.d('Executing FFmpeg FLAC embed command');
|
||||
final result = await _executeWithArguments(arguments);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
@@ -1124,46 +1291,50 @@ class FFmpegService {
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-v error -hide_banner ');
|
||||
cmdBuffer.write('-i "$mp3Path" ');
|
||||
final arguments = <String>['-v', 'error', '-hide_banner', '-i', mp3Path];
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
arguments
|
||||
..add('-i')
|
||||
..add(coverPath);
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
cmdBuffer.write(
|
||||
preserveMetadata ? '-map_metadata 0 ' : '-map_metadata -1 ',
|
||||
);
|
||||
arguments
|
||||
..add('-map')
|
||||
..add('0:a')
|
||||
..add('-map_metadata')
|
||||
..add(preserveMetadata ? '0' : '-1');
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
cmdBuffer.write('-c:v:0 copy ');
|
||||
cmdBuffer.write('-id3v2_version 3 ');
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
arguments
|
||||
..add('-map')
|
||||
..add('1:0')
|
||||
..add('-c:v:0')
|
||||
..add('copy')
|
||||
..add('-id3v2_version')
|
||||
..add('3')
|
||||
..add('-metadata:s:v')
|
||||
..add('title=Album cover')
|
||||
..add('-metadata:s:v')
|
||||
..add('comment=Cover (front)');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
arguments
|
||||
..add('-c:a')
|
||||
..add('copy');
|
||||
|
||||
if (metadata != null) {
|
||||
final id3Metadata = _convertToId3Tags(metadata);
|
||||
id3Metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
_appendMappedMetadataToArguments(arguments, _convertToId3Tags(metadata));
|
||||
}
|
||||
|
||||
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
|
||||
arguments
|
||||
..add('-id3v2_version')
|
||||
..add('3')
|
||||
..add(tempOutput)
|
||||
..add('-y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d(
|
||||
'Executing FFmpeg MP3 embed command: ${_previewCommandForLog(command)}',
|
||||
);
|
||||
|
||||
final result = await _execute(command);
|
||||
_log.d('Executing FFmpeg MP3 embed command');
|
||||
final result = await _executeWithArguments(arguments);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
@@ -1302,14 +1473,11 @@ class FFmpegService {
|
||||
required String m4aPath,
|
||||
String? coverPath,
|
||||
Map<String, String>? metadata,
|
||||
bool preserveMetadata = false,
|
||||
bool preserveMetadata = true,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a');
|
||||
|
||||
final cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-v error -hide_banner ');
|
||||
cmdBuffer.write('-i "$m4aPath" ');
|
||||
final arguments = <String>['-v', 'error', '-hide_banner', '-i', m4aPath];
|
||||
|
||||
final normalizedCoverPath = coverPath?.trim();
|
||||
final hasCover =
|
||||
@@ -1317,48 +1485,61 @@ class FFmpegService {
|
||||
normalizedCoverPath.isNotEmpty &&
|
||||
await File(normalizedCoverPath).exists();
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-i "$normalizedCoverPath" ');
|
||||
arguments
|
||||
..add('-i')
|
||||
..add(normalizedCoverPath);
|
||||
}
|
||||
|
||||
final preserveExistingStreams = preserveMetadata && !hasCover;
|
||||
if (preserveExistingStreams) {
|
||||
// When no replacement cover is provided, preserve all input streams so
|
||||
// the existing attached artwork is not dropped during the metadata rewrite.
|
||||
cmdBuffer.write('-map 0 -c copy ');
|
||||
arguments
|
||||
..add('-map')
|
||||
..add('0')
|
||||
..add('-c')
|
||||
..add('copy');
|
||||
} else {
|
||||
cmdBuffer.write('-map 0:a -c:a copy ');
|
||||
arguments
|
||||
..add('-map')
|
||||
..add('0:a')
|
||||
..add('-c:a')
|
||||
..add('copy');
|
||||
}
|
||||
cmdBuffer.write(
|
||||
preserveMetadata ? '-map_metadata 0 ' : '-map_metadata -1 ',
|
||||
);
|
||||
arguments
|
||||
..add('-map_metadata')
|
||||
..add(preserveMetadata ? '0' : '-1');
|
||||
|
||||
// For M4A cover replacements, mark the image as an attached picture so the
|
||||
// mp4 muxer writes a proper covr atom instead of a generic MJPEG video track.
|
||||
// Force the mp4 muxer because the default ipod muxer (auto-selected for .m4a)
|
||||
// does not register a codec tag for mjpeg on FFmpeg 8.0+.
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
cmdBuffer.write('-f mp4 ');
|
||||
arguments
|
||||
..add('-map')
|
||||
..add('1:v')
|
||||
..add('-c:v')
|
||||
..add('copy')
|
||||
..add('-disposition:v:0')
|
||||
..add('attached_pic')
|
||||
..add('-metadata:s:v')
|
||||
..add('title=Album cover')
|
||||
..add('-metadata:s:v')
|
||||
..add('comment=Cover (front)')
|
||||
..add('-f')
|
||||
..add('mp4');
|
||||
}
|
||||
|
||||
if (metadata != null) {
|
||||
final m4aMetadata = _convertToM4aTags(metadata);
|
||||
for (final entry in m4aMetadata.entries) {
|
||||
final sanitizedValue = entry.value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata ${entry.key}="$sanitizedValue" ');
|
||||
}
|
||||
_appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
|
||||
}
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
arguments
|
||||
..add(tempOutput)
|
||||
..add('-y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d(
|
||||
'Executing FFmpeg M4A embed command: ${_previewCommandForLog(command)}',
|
||||
);
|
||||
|
||||
final result = await _execute(command);
|
||||
_log.d('Executing FFmpeg M4A embed command');
|
||||
final result = await _executeWithArguments(arguments);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
@@ -1617,40 +1798,50 @@ class FFmpegService {
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = _buildOutputPath(inputPath, '.m4a');
|
||||
|
||||
final cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-v error -hide_banner ');
|
||||
cmdBuffer.write('-i "$inputPath" ');
|
||||
final arguments = <String>['-v', 'error', '-hide_banner', '-i', inputPath];
|
||||
|
||||
final hasCover =
|
||||
coverPath != null &&
|
||||
coverPath.trim().isNotEmpty &&
|
||||
await File(coverPath).exists();
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
arguments
|
||||
..add('-i')
|
||||
..add(coverPath);
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
arguments
|
||||
..add('-map')
|
||||
..add('0:a');
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
arguments
|
||||
..add('-map')
|
||||
..add('1:v')
|
||||
..add('-c:v')
|
||||
..add('copy')
|
||||
..add('-disposition:v:0')
|
||||
..add('attached_pic')
|
||||
..add('-metadata:s:v')
|
||||
..add('title=Album cover')
|
||||
..add('-metadata:s:v')
|
||||
..add('comment=Cover (front)');
|
||||
}
|
||||
cmdBuffer.write('-c:a alac ');
|
||||
cmdBuffer.write('-map_metadata -1 ');
|
||||
arguments
|
||||
..add('-c:a')
|
||||
..add('alac')
|
||||
..add('-map_metadata')
|
||||
..add('-1');
|
||||
|
||||
final m4aTags = _convertToM4aTags(metadata);
|
||||
for (final entry in m4aTags.entries) {
|
||||
final sanitized = entry.value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
|
||||
}
|
||||
_appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
|
||||
|
||||
cmdBuffer.write('"$outputPath" -y');
|
||||
arguments
|
||||
..add(outputPath)
|
||||
..add('-y');
|
||||
|
||||
_log.i(
|
||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC',
|
||||
);
|
||||
final result = await _execute(cmdBuffer.toString());
|
||||
final result = await _executeWithArguments(arguments);
|
||||
|
||||
if (!result.success) {
|
||||
_log.e('ALAC conversion failed: ${result.output}');
|
||||
@@ -1680,40 +1871,56 @@ class FFmpegService {
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||
|
||||
final cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-v error -hide_banner ');
|
||||
cmdBuffer.write('-i "$inputPath" ');
|
||||
final arguments = <String>['-v', 'error', '-hide_banner', '-i', inputPath];
|
||||
|
||||
final hasCover =
|
||||
coverPath != null &&
|
||||
coverPath.trim().isNotEmpty &&
|
||||
await File(coverPath).exists();
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
arguments
|
||||
..add('-i')
|
||||
..add(coverPath);
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
arguments
|
||||
..add('-map')
|
||||
..add('0:a');
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
arguments
|
||||
..add('-map')
|
||||
..add('1:v')
|
||||
..add('-c:v')
|
||||
..add('copy')
|
||||
..add('-disposition:v:0')
|
||||
..add('attached_pic')
|
||||
..add('-metadata:s:v')
|
||||
..add('title=Album cover')
|
||||
..add('-metadata:s:v')
|
||||
..add('comment=Cover (front)');
|
||||
}
|
||||
cmdBuffer.write('-c:a flac -compression_level 8 ');
|
||||
cmdBuffer.write('-map_metadata 0 ');
|
||||
arguments
|
||||
..add('-c:a')
|
||||
..add('flac')
|
||||
..add('-compression_level')
|
||||
..add('8')
|
||||
..add('-map_metadata')
|
||||
..add('0');
|
||||
|
||||
_appendVorbisMetadataToCommandBuffer(
|
||||
cmdBuffer,
|
||||
_appendVorbisMetadataToArguments(
|
||||
arguments,
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
|
||||
cmdBuffer.write('"$outputPath" -y');
|
||||
arguments
|
||||
..add(outputPath)
|
||||
..add('-y');
|
||||
|
||||
_log.i(
|
||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC',
|
||||
);
|
||||
final result = await _execute(cmdBuffer.toString());
|
||||
final result = await _executeWithArguments(arguments);
|
||||
|
||||
if (!result.success) {
|
||||
_log.e('FLAC conversion failed: ${result.output}');
|
||||
@@ -1819,20 +2026,6 @@ class FFmpegService {
|
||||
return vorbis;
|
||||
}
|
||||
|
||||
static void _appendVorbisMetadataToCommandBuffer(
|
||||
StringBuffer cmdBuffer,
|
||||
Map<String, String> metadata, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
for (final entry in _buildVorbisMetadataEntries(
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
)) {
|
||||
final sanitized = entry.value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
|
||||
}
|
||||
}
|
||||
|
||||
static void _appendVorbisMetadataToArguments(
|
||||
List<String> arguments,
|
||||
Map<String, String> metadata, {
|
||||
@@ -1848,6 +2041,17 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
static void _appendMappedMetadataToArguments(
|
||||
List<String> arguments,
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
for (final entry in metadata.entries) {
|
||||
arguments
|
||||
..add('-metadata')
|
||||
..add('${entry.key}=${entry.value}');
|
||||
}
|
||||
}
|
||||
|
||||
static List<MapEntry<String, String>> _buildVorbisMetadataEntries(
|
||||
Map<String, String> metadata, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
@@ -1965,19 +2169,6 @@ class FFmpegService {
|
||||
case 'UNSYNCEDLYRICS':
|
||||
m4aMap['lyrics'] = value;
|
||||
break;
|
||||
// ReplayGain as iTunes freeform atoms (com.apple.iTunes:replaygain_*)
|
||||
case 'REPLAYGAINTRACKGAIN':
|
||||
m4aMap['REPLAYGAIN_TRACK_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINTRACKPEAK':
|
||||
m4aMap['REPLAYGAIN_TRACK_PEAK'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMGAIN':
|
||||
m4aMap['REPLAYGAIN_ALBUM_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMPEAK':
|
||||
m4aMap['REPLAYGAIN_ALBUM_PEAK'] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2105,23 +2296,36 @@ class FFmpegService {
|
||||
final trackNumStr = track.number.toString().padLeft(2, '0');
|
||||
final outputFileName = '$trackNumStr - $sanitizedTitle.$outputExt';
|
||||
final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName';
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-v error -hide_banner ');
|
||||
cmdBuffer.write('-i "$audioPath" ');
|
||||
final arguments = <String>[
|
||||
'-v',
|
||||
'error',
|
||||
'-hide_banner',
|
||||
'-i',
|
||||
audioPath,
|
||||
];
|
||||
|
||||
final startTime = _formatSecondsForFFmpeg(track.startSec);
|
||||
cmdBuffer.write('-ss $startTime ');
|
||||
arguments
|
||||
..add('-ss')
|
||||
..add(startTime);
|
||||
|
||||
if (track.endSec > 0) {
|
||||
final endTime = _formatSecondsForFFmpeg(track.endSec);
|
||||
cmdBuffer.write('-to $endTime ');
|
||||
arguments
|
||||
..add('-to')
|
||||
..add(endTime);
|
||||
}
|
||||
|
||||
if (outputExt == 'flac') {
|
||||
cmdBuffer.write('-c:a flac -compression_level 8 ');
|
||||
arguments
|
||||
..add('-c:a')
|
||||
..add('flac')
|
||||
..add('-compression_level')
|
||||
..add('8');
|
||||
} else {
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
arguments
|
||||
..add('-c:a')
|
||||
..add('copy');
|
||||
}
|
||||
|
||||
final artist = track.artist.isNotEmpty
|
||||
@@ -2130,11 +2334,11 @@ class FFmpegService {
|
||||
final album = albumMetadata['album'] ?? '';
|
||||
final genre = albumMetadata['genre'] ?? '';
|
||||
final date = albumMetadata['date'] ?? '';
|
||||
final cueMetadata = <String, String>{};
|
||||
|
||||
void addMeta(String key, String value) {
|
||||
if (value.isNotEmpty) {
|
||||
final sanitized = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitized" ');
|
||||
cueMetadata[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2148,14 +2352,13 @@ class FFmpegService {
|
||||
if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc);
|
||||
if (track.composer.isNotEmpty) addMeta('COMPOSER', track.composer);
|
||||
|
||||
cmdBuffer.write('"$outputPath" -y');
|
||||
_appendMappedMetadataToArguments(arguments, cueMetadata);
|
||||
arguments
|
||||
..add(outputPath)
|
||||
..add('-y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d(
|
||||
'CUE split track ${track.number}: ${_previewCommandForLog(command)}',
|
||||
);
|
||||
|
||||
final result = await _execute(command);
|
||||
_log.d('CUE split track ${track.number}');
|
||||
final result = await _executeWithArguments(arguments);
|
||||
if (!result.success) {
|
||||
_log.e('CUE split failed for track ${track.number}: ${result.output}');
|
||||
continue;
|
||||
|
||||
@@ -31,7 +31,7 @@ class HistoryDatabase {
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 3,
|
||||
version: 5,
|
||||
onConfigure: (db) async {
|
||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||
await db.execute('PRAGMA synchronous = NORMAL');
|
||||
@@ -63,13 +63,16 @@ class HistoryDatabase {
|
||||
isrc TEXT,
|
||||
spotify_id TEXT,
|
||||
track_number INTEGER,
|
||||
total_tracks INTEGER,
|
||||
disc_number INTEGER,
|
||||
total_discs INTEGER,
|
||||
duration INTEGER,
|
||||
release_date TEXT,
|
||||
quality TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
genre TEXT,
|
||||
composer TEXT,
|
||||
label TEXT,
|
||||
copyright TEXT
|
||||
)
|
||||
@@ -98,6 +101,31 @@ class HistoryDatabase {
|
||||
if (oldVersion < 3) {
|
||||
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
|
||||
}
|
||||
if (oldVersion < 4) {
|
||||
final columns = await db.rawQuery('PRAGMA table_info(history)');
|
||||
final hasComposer = columns.any(
|
||||
(row) => (row['name']?.toString().toLowerCase() ?? '') == 'composer',
|
||||
);
|
||||
if (!hasComposer) {
|
||||
await db.execute('ALTER TABLE history ADD COLUMN composer TEXT');
|
||||
}
|
||||
}
|
||||
if (oldVersion < 5) {
|
||||
final columns = await db.rawQuery('PRAGMA table_info(history)');
|
||||
final hasTotalTracks = columns.any(
|
||||
(row) =>
|
||||
(row['name']?.toString().toLowerCase() ?? '') == 'total_tracks',
|
||||
);
|
||||
final hasTotalDiscs = columns.any(
|
||||
(row) => (row['name']?.toString().toLowerCase() ?? '') == 'total_discs',
|
||||
);
|
||||
if (!hasTotalTracks) {
|
||||
await db.execute('ALTER TABLE history ADD COLUMN total_tracks INTEGER');
|
||||
}
|
||||
if (!hasTotalDiscs) {
|
||||
await db.execute('ALTER TABLE history ADD COLUMN total_discs INTEGER');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static final _iosContainerPattern = RegExp(
|
||||
@@ -258,13 +286,16 @@ class HistoryDatabase {
|
||||
'isrc': json['isrc'],
|
||||
'spotify_id': json['spotifyId'],
|
||||
'track_number': json['trackNumber'],
|
||||
'total_tracks': json['totalTracks'],
|
||||
'disc_number': json['discNumber'],
|
||||
'total_discs': json['totalDiscs'],
|
||||
'duration': json['duration'],
|
||||
'release_date': json['releaseDate'],
|
||||
'quality': json['quality'],
|
||||
'bit_depth': json['bitDepth'],
|
||||
'sample_rate': json['sampleRate'],
|
||||
'genre': json['genre'],
|
||||
'composer': json['composer'],
|
||||
'label': json['label'],
|
||||
'copyright': json['copyright'],
|
||||
};
|
||||
@@ -289,13 +320,16 @@ class HistoryDatabase {
|
||||
'isrc': row['isrc'],
|
||||
'spotifyId': row['spotify_id'],
|
||||
'trackNumber': row['track_number'],
|
||||
'totalTracks': row['total_tracks'],
|
||||
'discNumber': row['disc_number'],
|
||||
'totalDiscs': row['total_discs'],
|
||||
'duration': row['duration'],
|
||||
'releaseDate': row['release_date'],
|
||||
'quality': row['quality'],
|
||||
'bitDepth': row['bit_depth'],
|
||||
'sampleRate': row['sample_rate'],
|
||||
'genre': row['genre'],
|
||||
'composer': row['composer'],
|
||||
'label': row['label'],
|
||||
'copyright': row['copyright'],
|
||||
};
|
||||
|
||||
@@ -496,21 +496,6 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('clearTrackCache');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchDeezerAll(
|
||||
String query, {
|
||||
int trackLimit = 15,
|
||||
int artistLimit = 2,
|
||||
String? filter,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('searchDeezerAll', {
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
'artist_limit': artistLimit,
|
||||
'filter': filter ?? '',
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchTidalAll(
|
||||
String query, {
|
||||
int trackLimit = 15,
|
||||
@@ -781,6 +766,15 @@ class PlatformBridge {
|
||||
return list.map((e) => e as String).toList();
|
||||
}
|
||||
|
||||
static Future<void> setDownloadFallbackExtensionIds(
|
||||
List<String>? extensionIds,
|
||||
) async {
|
||||
_log.d('setDownloadFallbackExtensionIds: $extensionIds');
|
||||
await _channel.invokeMethod('setDownloadFallbackExtensionIds', {
|
||||
'extension_ids': extensionIds == null ? '' : jsonEncode(extensionIds),
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> setMetadataProviderPriority(
|
||||
List<String> providerIds,
|
||||
) async {
|
||||
|
||||
@@ -13,27 +13,9 @@ class ShareIntentService {
|
||||
static final RegExp _spotifyUriPattern = RegExp(
|
||||
r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+',
|
||||
);
|
||||
static final RegExp _spotifyUrlPattern = RegExp(
|
||||
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
|
||||
);
|
||||
|
||||
static final RegExp _deezerUrlPattern = RegExp(
|
||||
r'https?://(www\.)?deezer\.com/(track|album|playlist|artist)/\d+(\?[^\s]*)?',
|
||||
);
|
||||
static final RegExp _deezerShortLinkPattern = RegExp(
|
||||
r'https?://deezer\.page\.link/[a-zA-Z0-9]+',
|
||||
);
|
||||
|
||||
static final RegExp _tidalUrlPattern = RegExp(
|
||||
r'https?://(listen\.)?tidal\.com/(track|album|playlist|artist)/[a-zA-Z0-9-]+(\?[^\s]*)?',
|
||||
);
|
||||
|
||||
static final RegExp _ytMusicUrlPattern = RegExp(
|
||||
r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/|browse/)[a-zA-Z0-9_-]+([?&][^\s]*)?',
|
||||
);
|
||||
|
||||
static final RegExp _youtubeUrlPattern = RegExp(
|
||||
r'https?://(youtu\.be/[a-zA-Z0-9_-]+|www\.youtube\.com/watch\?v=[a-zA-Z0-9_-]+)([?&][^\s]*)?',
|
||||
static final RegExp _genericHttpUrlPattern = RegExp(
|
||||
"https?://[^\\s<>\\\"']+",
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final _sharedUrlController = StreamController<String>.broadcast();
|
||||
@@ -99,24 +81,17 @@ class ShareIntentService {
|
||||
return uriMatch.group(0);
|
||||
}
|
||||
|
||||
final patterns = [
|
||||
_spotifyUrlPattern,
|
||||
_deezerUrlPattern,
|
||||
_deezerShortLinkPattern,
|
||||
_tidalUrlPattern,
|
||||
_ytMusicUrlPattern,
|
||||
_youtubeUrlPattern,
|
||||
];
|
||||
// Keep share parsing generic and let manifest-based URL handlers decide
|
||||
// which installed extension can handle the incoming link.
|
||||
for (final match in _genericHttpUrlPattern.allMatches(text)) {
|
||||
final rawUrl = match.group(0);
|
||||
if (rawUrl == null || rawUrl.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final pattern in patterns) {
|
||||
final match = pattern.firstMatch(text);
|
||||
if (match != null) {
|
||||
final fullUrl = match.group(0)!;
|
||||
if (pattern == _ytMusicUrlPattern || pattern == _youtubeUrlPattern) {
|
||||
return fullUrl;
|
||||
}
|
||||
final queryIndex = fullUrl.indexOf('?');
|
||||
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
|
||||
final sanitizedUrl = rawUrl.replaceFirst(RegExp(r'[.,;:!?)\]}]+$'), '');
|
||||
if (sanitizedUrl.isNotEmpty) {
|
||||
return sanitizedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user