mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 580e2b6ab8 | |||
| 298b89acf1 | |||
| b6e2675b86 | |||
| 7786501cd1 | |||
| bc4b5a5b17 | |||
| 5d160f71f1 | |||
| 20cf7d49e5 | |||
| 88d22477d5 | |||
| b77def62f4 | |||
| a15313e573 | |||
| 4a90d3f38a | |||
| d4e56567a2 | |||
| 277a7f24fa | |||
| 3735aaf3bd | |||
| 3bbe8553ab | |||
| ca0cfa4524 | |||
| 8b185e964a | |||
| c104a5d8a3 |
@@ -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")
|
||||
@@ -947,6 +1016,21 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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)
|
||||
@@ -1934,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() {
|
||||
@@ -1952,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),
|
||||
@@ -2136,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
|
||||
@@ -2144,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") ?: ""
|
||||
@@ -2219,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()
|
||||
@@ -2717,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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
+233
-59
@@ -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"`
|
||||
@@ -127,6 +238,12 @@ type DownloadResult struct {
|
||||
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"`
|
||||
@@ -679,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
|
||||
@@ -688,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) {
|
||||
@@ -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)
|
||||
@@ -2298,7 +2487,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
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 {
|
||||
@@ -2327,23 +2515,9 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
@@ -161,6 +164,92 @@ func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -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"
|
||||
@@ -616,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 {
|
||||
@@ -806,9 +809,6 @@ func sanitizeDownloadProviderPriority(providerIDs []string) []string {
|
||||
}
|
||||
|
||||
normalizedBuiltIn := strings.ToLower(providerID)
|
||||
if normalizedBuiltIn == "deezer" {
|
||||
continue
|
||||
}
|
||||
if isBuiltInDownloadProvider(normalizedBuiltIn) {
|
||||
providerID = normalizedBuiltIn
|
||||
}
|
||||
@@ -895,7 +895,7 @@ 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)
|
||||
@@ -908,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
|
||||
}
|
||||
@@ -925,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))
|
||||
@@ -935,7 +935,7 @@ func GetMetadataProviderPriority() []string {
|
||||
|
||||
func isBuiltInProvider(providerID string) bool {
|
||||
switch providerID {
|
||||
case "tidal", "qobuz", "deezer":
|
||||
case "tidal", "qobuz":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1006,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":
|
||||
@@ -1327,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1520,27 +1492,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
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)
|
||||
|
||||
@@ -12,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)
|
||||
}
|
||||
@@ -208,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) {
|
||||
@@ -223,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
|
||||
}
|
||||
@@ -237,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.
|
||||
@@ -413,6 +426,10 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.2';
|
||||
static const String buildNumber = '123';
|
||||
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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -3707,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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => '問題を報告する';
|
||||
|
||||
|
||||
@@ -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 => '문제 신고';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -3707,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 => 'Сообщить о проблеме';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -3689,6 +3699,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
@override
|
||||
String get aboutPCSource => '桌面版本源代码';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => '报告一个问题';
|
||||
|
||||
@@ -6083,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -35,6 +35,7 @@ class AppSettings {
|
||||
final bool useExtensionProviders;
|
||||
final List<String>? downloadFallbackExtensionIds;
|
||||
final String? searchProvider;
|
||||
final String defaultSearchTab;
|
||||
final String? homeFeedProvider;
|
||||
final bool separateSingles;
|
||||
final String singleFilenameFormat;
|
||||
@@ -111,6 +112,7 @@ class AppSettings {
|
||||
this.useExtensionProviders = true,
|
||||
this.downloadFallbackExtensionIds,
|
||||
this.searchProvider,
|
||||
this.defaultSearchTab = 'all',
|
||||
this.homeFeedProvider,
|
||||
this.separateSingles = false,
|
||||
this.singleFilenameFormat = '{title} - {artist}',
|
||||
@@ -176,6 +178,7 @@ class AppSettings {
|
||||
bool clearDownloadFallbackExtensionIds = false,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false,
|
||||
String? defaultSearchTab,
|
||||
String? homeFeedProvider,
|
||||
bool clearHomeFeedProvider = false,
|
||||
bool? separateSingles,
|
||||
@@ -242,6 +245,7 @@ class AppSettings {
|
||||
searchProvider: clearSearchProvider
|
||||
? null
|
||||
: (searchProvider ?? this.searchProvider),
|
||||
defaultSearchTab: defaultSearchTab ?? this.defaultSearchTab,
|
||||
homeFeedProvider: clearHomeFeedProvider
|
||||
? null
|
||||
: (homeFeedProvider ?? this.homeFeedProvider),
|
||||
|
||||
@@ -40,6 +40,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
?.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:
|
||||
@@ -111,6 +112,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'defaultSearchTab': instance.defaultSearchTab,
|
||||
'homeFeedProvider': instance.homeFeedProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'singleFilenameFormat': instance.singleFilenameFormat,
|
||||
|
||||
@@ -26,7 +26,10 @@ final _log = AppLogger('DownloadQueue');
|
||||
final _historyLog = AppLogger('DownloadHistory');
|
||||
|
||||
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
|
||||
final _trailingDotsRegex = RegExp(r'\.+$');
|
||||
final _trimDotsAndSpacesRegex = RegExp(r'^[. ]+|[. ]+$');
|
||||
final _trimUnderscoresAndSpacesRegex = RegExp(r'^[_ ]+|[_ ]+$');
|
||||
final _multiWhitespaceRegex = RegExp(r'\s+');
|
||||
final _multiUnderscoreRegex = RegExp(r'_+');
|
||||
|
||||
/// log10 helper using dart:math's natural log.
|
||||
double _log10(num x) => log(x) / ln10;
|
||||
@@ -2165,10 +2168,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
String _sanitizeFolderName(String name) {
|
||||
return name
|
||||
.replaceAll(_invalidFolderChars, '_')
|
||||
.replaceAll(_trailingDotsRegex, '')
|
||||
.trim();
|
||||
final buffer = StringBuffer();
|
||||
for (final rune in name.runes) {
|
||||
if (rune < 0x20 || rune == 0x7f) {
|
||||
continue;
|
||||
}
|
||||
final char = String.fromCharCode(rune);
|
||||
if (_invalidFolderChars.hasMatch(char)) {
|
||||
buffer.write(' ');
|
||||
continue;
|
||||
}
|
||||
buffer.write(char);
|
||||
}
|
||||
|
||||
var sanitized = buffer.toString().trim();
|
||||
sanitized = sanitized.replaceAll(_trimDotsAndSpacesRegex, '');
|
||||
sanitized = sanitized.replaceAll(_multiWhitespaceRegex, ' ');
|
||||
sanitized = sanitized.replaceAll(_multiUnderscoreRegex, '_');
|
||||
sanitized = sanitized.replaceAll(_trimUnderscoresAndSpacesRegex, '');
|
||||
|
||||
if (sanitized.isEmpty) {
|
||||
return 'Unknown';
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
static final _featuredArtistPattern = RegExp(
|
||||
@@ -2322,11 +2344,41 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return '$prefix/$suffix';
|
||||
}
|
||||
|
||||
String? _extensionPreferredOutputExt(String service) {
|
||||
final normalizedService = service.trim().toLowerCase();
|
||||
if (normalizedService.isEmpty) return null;
|
||||
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
for (final ext in extensionState.extensions) {
|
||||
if (!ext.enabled || !ext.hasDownloadProvider) continue;
|
||||
if (ext.id.toLowerCase() != normalizedService) continue;
|
||||
|
||||
final preferred = ext.preferredDownloadOutputExtension;
|
||||
if (preferred == null) return null;
|
||||
|
||||
final normalized = preferred.startsWith('.')
|
||||
? preferred.toLowerCase()
|
||||
: '.${preferred.toLowerCase()}';
|
||||
const allowed = <String>{'.flac', '.m4a', '.mp3', '.opus'};
|
||||
if (allowed.contains(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String _determineOutputExt(String quality, String service) {
|
||||
final extensionPreferred = _extensionPreferredOutputExt(service);
|
||||
if (extensionPreferred != null) {
|
||||
return extensionPreferred;
|
||||
}
|
||||
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
||||
return '.m4a';
|
||||
}
|
||||
final q = quality.toLowerCase();
|
||||
if (q == 'alac' || q.startsWith('aac')) return '.m4a';
|
||||
if (q.startsWith('opus')) return '.opus';
|
||||
if (q.startsWith('mp3')) return '.mp3';
|
||||
return '.flac';
|
||||
@@ -3696,8 +3748,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
/// Unified metadata, cover, lyrics, and ReplayGain embedding for all formats.
|
||||
///
|
||||
/// [format] must be one of `'flac'`, `'mp3'`, or `'opus'`.
|
||||
/// [writeExternalLrc] only applies to FLAC (non-SAF paths handle LRC separately).
|
||||
/// [format] must be one of `'flac'`, `'m4a'`, `'mp3'`, or `'opus'`.
|
||||
/// [writeExternalLrc] only applies to FLAC and M4A (non-SAF paths handle LRC separately).
|
||||
Future<void> _embedMetadataToFile(
|
||||
String filePath,
|
||||
Track track, {
|
||||
@@ -3717,6 +3769,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
final isFlac = format == 'flac';
|
||||
final isM4a = format == 'm4a';
|
||||
final isMp3 = format == 'mp3';
|
||||
|
||||
// ── Cover download ──────────────────────────────────────────────
|
||||
@@ -3840,9 +3893,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (shouldEmbedLyrics && lrcContent != null) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
if (isFlac || isMp3) metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||
} else if (isFlac && !shouldEmbedLyrics) {
|
||||
} else if ((isFlac || isM4a) && !shouldEmbedLyrics) {
|
||||
metadata['LYRICS'] = '';
|
||||
metadata['UNSYNCEDLYRICS'] = '';
|
||||
if (isFlac) {
|
||||
metadata['UNSYNCEDLYRICS'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) {
|
||||
@@ -3856,11 +3911,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ReplayGain (MP3/Opus: scan before FFmpeg, add to metadata) ─
|
||||
ReplayGainResult? scannedReplayGain;
|
||||
|
||||
// ── ReplayGain (MP3/Opus/M4A: scan before FFmpeg, add to metadata) ─
|
||||
if (settings.embedReplayGain && !isFlac) {
|
||||
try {
|
||||
final rgResult = await FFmpegService.scanReplayGain(filePath);
|
||||
if (rgResult != null) {
|
||||
scannedReplayGain = rgResult;
|
||||
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
|
||||
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
|
||||
_log.d(
|
||||
@@ -3886,6 +3944,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
metadata: metadata,
|
||||
artistTagMode: settings.artistTagMode,
|
||||
);
|
||||
} else if (isM4a) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: filePath,
|
||||
coverPath: validCover,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (isMp3) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
||||
mp3Path: filePath,
|
||||
@@ -3907,6 +3971,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.w('FFmpeg $format metadata embed failed');
|
||||
}
|
||||
|
||||
if (isM4a && settings.embedReplayGain && scannedReplayGain != null) {
|
||||
try {
|
||||
await PlatformBridge.editFileMetadata(filePath, {
|
||||
'replaygain_track_gain': scannedReplayGain.trackGain,
|
||||
'replaygain_track_peak': scannedReplayGain.trackPeak,
|
||||
});
|
||||
_log.d(
|
||||
'ReplayGain compatibility tags written for $format: gain=${scannedReplayGain.trackGain}, peak=${scannedReplayGain.trackPeak}',
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to write native ReplayGain tags for $format: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── FLAC post-processing ────────────────────────────────────────
|
||||
if (isFlac) {
|
||||
if (settings.artistTagMode == artistTagModeSplitVorbis) {
|
||||
@@ -4935,7 +5013,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
if (shouldForceTidalSafM4aHandling) {
|
||||
_log.w(
|
||||
'Tidal SAF file is labeled FLAC but backend returned DASH/M4A stream; forcing FFmpeg conversion to FLAC.',
|
||||
'Tidal SAF file is labeled FLAC but backend returned DASH/M4A stream; preserving it as M4A instead.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5053,82 +5131,61 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_log.d('M4A file detected (SAF), converting to FLAC...');
|
||||
_log.d('M4A file detected (SAF), preserving native container...');
|
||||
final tempPath = await _copySafToTemp(currentFilePath);
|
||||
if (tempPath != null) {
|
||||
String? flacPath;
|
||||
try {
|
||||
final length = await File(tempPath).length();
|
||||
if (length < 1024) {
|
||||
_log.w('Temp M4A is too small (<1KB), skipping conversion');
|
||||
} else {
|
||||
if (metadataEmbeddingEnabled) {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.finalizing,
|
||||
progress: 0.95,
|
||||
progress: 0.99,
|
||||
);
|
||||
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
|
||||
if (flacPath != null) {
|
||||
_log.d('Converted to FLAC (temp): $flacPath');
|
||||
_log.d(
|
||||
'Embedding metadata and cover to converted FLAC...',
|
||||
);
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
await _embedMetadataToFile(
|
||||
tempPath,
|
||||
finalTrack,
|
||||
format: 'm4a',
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
downloadService: item.service,
|
||||
writeExternalLrc: false,
|
||||
);
|
||||
}
|
||||
|
||||
await _embedMetadataToFile(
|
||||
flacPath,
|
||||
finalTrack,
|
||||
format: 'flac',
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
downloadService: item.service,
|
||||
writeExternalLrc: false,
|
||||
);
|
||||
final newFileName = '${safBaseName ?? 'track'}.m4a';
|
||||
final newUri = await _writeTempToSaf(
|
||||
treeUri: settings.downloadTreeUri,
|
||||
relativeDir: effectiveOutputDir,
|
||||
fileName: newFileName,
|
||||
mimeType: _mimeTypeForExt('.m4a'),
|
||||
srcPath: tempPath,
|
||||
);
|
||||
|
||||
final newFileName = '${safBaseName ?? 'track'}.flac';
|
||||
final newUri = await _writeTempToSaf(
|
||||
treeUri: settings.downloadTreeUri,
|
||||
relativeDir: effectiveOutputDir,
|
||||
fileName: newFileName,
|
||||
mimeType: _mimeTypeForExt('.flac'),
|
||||
srcPath: flacPath,
|
||||
);
|
||||
|
||||
if (newUri != null) {
|
||||
if (newUri != currentFilePath) {
|
||||
await _deleteSafFile(currentFilePath);
|
||||
}
|
||||
filePath = newUri;
|
||||
finalSafFileName = newFileName;
|
||||
} else {
|
||||
_log.w('Failed to write FLAC to SAF, keeping M4A');
|
||||
}
|
||||
} else {
|
||||
_log.w(
|
||||
'FFmpeg conversion returned null, keeping M4A file',
|
||||
);
|
||||
if (newUri != null) {
|
||||
if (newUri != currentFilePath) {
|
||||
await _deleteSafFile(currentFilePath);
|
||||
}
|
||||
filePath = newUri;
|
||||
finalSafFileName = newFileName;
|
||||
} else {
|
||||
_log.w('Failed to write M4A to SAF, keeping original');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('SAF M4A->FLAC conversion failed: $e');
|
||||
_log.w('SAF native M4A handling failed: $e');
|
||||
} finally {
|
||||
try {
|
||||
await File(tempPath).delete();
|
||||
} catch (_) {}
|
||||
if (flacPath != null) {
|
||||
try {
|
||||
await File(flacPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5208,82 +5265,58 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} else {
|
||||
_log.d(
|
||||
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||
);
|
||||
_log.d('M4A file detected, preserving native container...');
|
||||
|
||||
try {
|
||||
final file = File(currentFilePath);
|
||||
var targetPath = currentFilePath;
|
||||
final file = File(targetPath);
|
||||
if (!await file.exists()) {
|
||||
_log.e('File does not exist at path: $filePath');
|
||||
} else {
|
||||
final length = await file.length();
|
||||
_log.i('File size before conversion: ${length / 1024} KB');
|
||||
|
||||
if (length < 1024) {
|
||||
_log.w(
|
||||
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
|
||||
if (!targetPath.toLowerCase().endsWith('.m4a')) {
|
||||
final renamedPath = targetPath.replaceAll(
|
||||
RegExp(r'\.[^.]+$'),
|
||||
'.m4a',
|
||||
);
|
||||
final finalRenamedPath = renamedPath == targetPath
|
||||
? '$targetPath.m4a'
|
||||
: renamedPath;
|
||||
await file.rename(finalRenamedPath);
|
||||
targetPath = finalRenamedPath;
|
||||
filePath = finalRenamedPath;
|
||||
} else {
|
||||
filePath = targetPath;
|
||||
}
|
||||
|
||||
if (metadataEmbeddingEnabled) {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.finalizing,
|
||||
progress: 0.95,
|
||||
progress: 0.99,
|
||||
);
|
||||
final flacPath = await FFmpegService.convertM4aToFlac(
|
||||
currentFilePath,
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
|
||||
if (flacPath != null) {
|
||||
filePath = flacPath;
|
||||
_log.d('Converted to FLAC: $flacPath');
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
_log.d(
|
||||
'Embedding metadata and cover to converted FLAC...',
|
||||
);
|
||||
try {
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
if (backendGenre != null ||
|
||||
backendLabel != null ||
|
||||
backendCopyright != null) {
|
||||
_log.d(
|
||||
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
|
||||
);
|
||||
}
|
||||
|
||||
await _embedMetadataToFile(
|
||||
flacPath,
|
||||
finalTrack,
|
||||
format: 'flac',
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
downloadService: item.service,
|
||||
);
|
||||
_log.d('Metadata and cover embedded successfully');
|
||||
} catch (e) {
|
||||
_log.w('Warning: Failed to embed metadata/cover: $e');
|
||||
}
|
||||
} else {
|
||||
_log.w(
|
||||
'FFmpeg conversion returned null, keeping M4A file',
|
||||
);
|
||||
}
|
||||
await _embedMetadataToFile(
|
||||
targetPath,
|
||||
finalTrack,
|
||||
format: 'm4a',
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
downloadService: item.service,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w(
|
||||
'FFmpeg conversion process failed: $e, keeping M4A file',
|
||||
);
|
||||
_log.w('Native M4A handling failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']) {
|
||||
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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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();
|
||||
@@ -42,11 +43,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_sanitizeDownloadFallbackExtensionIds(
|
||||
loaded.downloadFallbackExtensionIds,
|
||||
);
|
||||
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
|
||||
loaded.defaultSearchTab,
|
||||
);
|
||||
state = loaded.copyWith(
|
||||
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
|
||||
clearDownloadFallbackExtensionIds:
|
||||
loaded.downloadFallbackExtensionIds != null &&
|
||||
sanitizedDownloadFallbackExtensionIds == null,
|
||||
defaultSearchTab: sanitizedDefaultSearchTab,
|
||||
);
|
||||
|
||||
await _runMigrations(prefs);
|
||||
@@ -187,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;
|
||||
@@ -408,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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+287
-34
@@ -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,
|
||||
),
|
||||
);
|
||||
@@ -2092,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>(
|
||||
@@ -2139,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>(
|
||||
@@ -2189,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>(
|
||||
@@ -3111,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...';
|
||||
@@ -3154,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,
|
||||
),
|
||||
@@ -3313,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));
|
||||
@@ -3324,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) {
|
||||
@@ -3343,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;
|
||||
}
|
||||
@@ -3397,7 +3650,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Deezer',
|
||||
defaultProviderLabel,
|
||||
style: TextStyle(
|
||||
fontWeight:
|
||||
currentProvider == null || currentProvider.isEmpty
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -425,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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4270,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>(
|
||||
@@ -4287,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,
|
||||
),
|
||||
);
|
||||
@@ -4297,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(() {});
|
||||
}
|
||||
@@ -4514,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({
|
||||
@@ -4521,6 +4546,7 @@ class _EditMetadataSheet extends StatefulWidget {
|
||||
required this.initialValues,
|
||||
required this.filePath,
|
||||
this.sourceTrackId,
|
||||
required this.durationMs,
|
||||
required this.artistTagMode,
|
||||
});
|
||||
|
||||
@@ -4560,6 +4586,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
'total_discs': 'total_discs',
|
||||
'genre': 'genre',
|
||||
'isrc': 'isrc',
|
||||
'lyrics': 'lyrics',
|
||||
'label': 'label',
|
||||
'copyright': 'copyright',
|
||||
'composer': 'composer',
|
||||
@@ -4577,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;
|
||||
@@ -4772,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':
|
||||
@@ -4809,6 +4839,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return _genreCtrl;
|
||||
case 'isrc':
|
||||
return _isrcCtrl;
|
||||
case 'lyrics':
|
||||
return _lyricsCtrl;
|
||||
case 'label':
|
||||
return _labelCtrl;
|
||||
case 'copyright':
|
||||
@@ -5107,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>[];
|
||||
@@ -5127,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)),
|
||||
@@ -5140,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,
|
||||
@@ -5175,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 : '');
|
||||
@@ -5243,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',
|
||||
@@ -5275,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;
|
||||
@@ -5293,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();
|
||||
@@ -5369,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'] ?? '');
|
||||
@@ -5391,6 +5469,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
_discTotalCtrl.dispose();
|
||||
_genreCtrl.dispose();
|
||||
_isrcCtrl.dispose();
|
||||
_lyricsCtrl.dispose();
|
||||
_labelCtrl.dispose();
|
||||
_copyrightCtrl.dispose();
|
||||
_composerCtrl.dispose();
|
||||
@@ -5413,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,
|
||||
@@ -5477,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'] ?? '',
|
||||
@@ -5486,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>{
|
||||
@@ -5717,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) {
|
||||
|
||||
+215
-162
@@ -1139,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 {
|
||||
@@ -1194,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 {
|
||||
@@ -1274,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 {
|
||||
@@ -1452,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 =
|
||||
@@ -1467,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 {
|
||||
@@ -1767,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}');
|
||||
@@ -1830,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}');
|
||||
@@ -1969,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, {
|
||||
@@ -1998,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,
|
||||
@@ -2115,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2255,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
|
||||
@@ -2280,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2298,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,19 @@ import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('ClickableMetadata');
|
||||
const _deezerExtensionId = 'deezer';
|
||||
|
||||
Future<List<Map<String, dynamic>>> _searchDeezerExtension(
|
||||
String query, {
|
||||
required String filter,
|
||||
int limit = 5,
|
||||
}) {
|
||||
return PlatformBridge.customSearchWithExtension(
|
||||
_deezerExtensionId,
|
||||
query,
|
||||
options: {'filter': filter, 'limit': limit},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> navigateToArtist(
|
||||
BuildContext context, {
|
||||
@@ -39,15 +52,14 @@ Future<void> navigateToArtist(
|
||||
|
||||
_showLoadingSnackBar(context, 'Looking up artist...');
|
||||
try {
|
||||
final results = await PlatformBridge.searchDeezerAll(
|
||||
final artistList = await _searchDeezerExtension(
|
||||
artistName,
|
||||
trackLimit: 0,
|
||||
artistLimit: 3,
|
||||
filter: 'artist',
|
||||
limit: 3,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
if (artistList.isEmpty) {
|
||||
_showUnavailable(context, 'Artist');
|
||||
return;
|
||||
@@ -56,15 +68,13 @@ Future<void> navigateToArtist(
|
||||
Map<String, dynamic>? bestMatch;
|
||||
final lowerName = artistName.toLowerCase().trim();
|
||||
for (final a in artistList) {
|
||||
if (a is Map<String, dynamic>) {
|
||||
final name = (a['name'] as String? ?? '').toLowerCase().trim();
|
||||
if (name == lowerName) {
|
||||
bestMatch = a;
|
||||
break;
|
||||
}
|
||||
final name = (a['name'] as String? ?? '').toLowerCase().trim();
|
||||
if (name == lowerName) {
|
||||
bestMatch = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
bestMatch ??= artistList.first as Map<String, dynamic>;
|
||||
bestMatch ??= artistList.first;
|
||||
|
||||
final resolvedId = bestMatch['id'] as String? ?? '';
|
||||
final resolvedName = bestMatch['name'] as String? ?? artistName;
|
||||
@@ -81,6 +91,7 @@ Future<void> navigateToArtist(
|
||||
artistId: resolvedId,
|
||||
artistName: resolvedName,
|
||||
coverUrl: resolvedImage ?? coverUrl,
|
||||
extensionId: _deezerExtensionId,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('Failed to look up artist "$artistName": $e', e);
|
||||
@@ -125,15 +136,14 @@ Future<void> navigateToAlbum(
|
||||
? '$albumName $artistName'
|
||||
: albumName;
|
||||
|
||||
final results = await PlatformBridge.searchDeezerAll(
|
||||
final albumList = await _searchDeezerExtension(
|
||||
query,
|
||||
trackLimit: 0,
|
||||
artistLimit: 0,
|
||||
filter: 'album',
|
||||
limit: 5,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
|
||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||
if (albumList.isEmpty) {
|
||||
_showUnavailable(context, 'Album');
|
||||
return;
|
||||
@@ -142,15 +152,13 @@ Future<void> navigateToAlbum(
|
||||
Map<String, dynamic>? bestMatch;
|
||||
final lowerName = albumName.toLowerCase().trim();
|
||||
for (final a in albumList) {
|
||||
if (a is Map<String, dynamic>) {
|
||||
final name = (a['name'] as String? ?? '').toLowerCase().trim();
|
||||
if (name == lowerName) {
|
||||
bestMatch = a;
|
||||
break;
|
||||
}
|
||||
final name = (a['name'] as String? ?? '').toLowerCase().trim();
|
||||
if (name == lowerName) {
|
||||
bestMatch = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
bestMatch ??= albumList.first as Map<String, dynamic>;
|
||||
bestMatch ??= albumList.first;
|
||||
|
||||
final resolvedId = bestMatch['id'] as String? ?? '';
|
||||
final resolvedName = bestMatch['name'] as String? ?? albumName;
|
||||
@@ -167,6 +175,7 @@ Future<void> navigateToAlbum(
|
||||
albumId: resolvedId,
|
||||
albumName: resolvedName,
|
||||
coverUrl: resolvedImage ?? coverUrl,
|
||||
extensionId: _deezerExtensionId,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('Failed to look up album "$albumName": $e', e);
|
||||
@@ -207,7 +216,7 @@ void _pushAlbumScreen(
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) {
|
||||
const builtInProviders = {'tidal', 'qobuz', 'deezer'};
|
||||
const builtInProviders = {'tidal', 'qobuz'};
|
||||
final isExtension =
|
||||
extensionId != null && !builtInProviders.contains(extensionId);
|
||||
final resolvedExtensionId = extensionId;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
||||
publish_to: "none"
|
||||
version: 4.2.2+123
|
||||
version: 4.3.0+125
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user