Compare commits

...

18 Commits

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

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

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

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

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

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

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

(cherry picked from commit 3fc371b8c4)
2026-04-13 15:51:48 +07:00
zarzet bc4b5a5b17 feat: native M4A ReplayGain tag writing and SAF picker error handling 2026-04-13 05:01:02 +07:00
zarzet 5d160f71f1 refactor: remove author field from extension manifest and UI 2026-04-13 04:09:01 +07:00
zarzet 20cf7d49e5 fix: align default search tab layout with primary provider selector using Row+Expanded 2026-04-13 03:50:31 +07:00
zarzet 88d22477d5 fix: preserve existing M4A metadata during embed and enable BuildConfig generation 2026-04-13 02:47:59 +07:00
zarzet b77def62f4 feat: expose extension utils, preserve M4A native container, and bump to v4.2.3+124 2026-04-13 02:04:11 +07:00
zarzet a15313e573 feat: add artist search filter and normalize search filter handling 2026-04-13 00:44:35 +07:00
zarzet 4a90d3f38a fix: improve ALAC M4A quality parsing 2026-04-12 04:53:37 +07:00
zarzet d4e56567a2 refactor: move deezer search flow to extension 2026-04-12 04:24:23 +07:00
zarzet 277a7f24fa fix: stabilize shared extension link handling 2026-04-11 21:18:23 +07:00
zarzet 3735aaf3bd feat: add default search tab preference 2026-04-11 16:42:29 +07:00
zarzet 3bbe8553ab fix: fallback extra metadata genre 2026-04-11 16:42:22 +07:00
zarzet ca0cfa4524 chore: thank Ldav Nico and Feuerstern on donate page 2026-04-09 16:59:46 +07:00
zarzet 8b185e964a feat: add keep android open link 2026-04-09 16:55:03 +07:00
zarzet c104a5d8a3 fix: align metadata sanitization and lyrics editing 2026-04-09 16:53:08 +07:00
89 changed files with 2994 additions and 823 deletions
+4
View File
@@ -20,6 +20,10 @@ android {
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
buildFeatures {
buildConfig = true
}
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
+14
View File
@@ -86,6 +86,20 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="music.youtube.com" /> <data android:scheme="https" android:host="music.youtube.com" />
</intent-filter> </intent-filter>
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="spotify-callback" />
</intent-filter>
</activity> </activity>
<!-- Download Service --> <!-- Download Service -->
@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
@@ -307,8 +308,40 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
private fun forceFilenameExt(name: String, outputExt: String): String {
val normalizedExt = normalizeExt(outputExt)
if (normalizedExt.isBlank()) return sanitizeFilename(name)
val safeName = sanitizeFilename(name)
val lower = safeName.lowercase(Locale.ROOT)
val knownExts = listOf(".flac", ".m4a", ".mp3", ".opus", ".lrc")
for (knownExt in knownExts) {
if (lower.endsWith(knownExt)) {
return safeName.dropLast(knownExt.length) + normalizedExt
}
}
return safeName + normalizedExt
}
private fun sanitizeFilename(name: String): String { private fun sanitizeFilename(name: String): String {
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim() var sanitized = name
.replace("/", " ")
.replace(Regex("[\\\\:*?\"<>|]"), " ")
.filter { ch ->
val code = ch.code
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
code == 0x7F ||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
}
.trim()
.trim('.', ' ')
sanitized = sanitized
.replace(Regex("\\s+"), " ")
.replace(Regex("_+"), "_")
.trim('_', ' ')
return if (sanitized.isBlank()) "Unknown" else sanitized
} }
private fun sanitizeRelativeDir(relativeDir: String): String { private fun sanitizeRelativeDir(relativeDir: String): String {
@@ -368,6 +401,43 @@ class MainActivity: FlutterFragmentActivity() {
return current return current
} }
private fun createOrReuseDocumentFile(
parent: DocumentFile,
mimeType: String,
fileName: String
): DocumentFile? {
val safeFileName = sanitizeFilename(fileName)
if (safeFileName.isBlank()) return null
synchronized(safDirLock) {
val existing = parent.findFile(safeFileName)
if (existing != null && existing.isFile) {
return existing
}
val created = parent.createFile(mimeType, safeFileName) ?: return null
val createdName = created.name ?: safeFileName
if (createdName == safeFileName) {
return created
}
// SAF can auto-rename to "name (1)" when another writer wins the race
// between findFile() and createFile(). Prefer the exact sibling if it
// appeared, and discard the duplicate document we just created.
val winner = parent.findFile(safeFileName)
if (winner != null && winner.isFile) {
if (winner.uri != created.uri) {
try {
created.delete()
} catch (_: Exception) {}
}
return winner
}
return created
}
}
private fun resetSafScanProgress() { private fun resetSafScanProgress() {
synchronized(safScanLock) { synchronized(safScanLock) {
safScanProgress = SafScanProgress() safScanProgress = SafScanProgress()
@@ -599,12 +669,12 @@ class MainActivity: FlutterFragmentActivity() {
private fun buildSafFileName(req: JSONObject, outputExt: String): String { private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "") val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return sanitizeFilename(provided) if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
val trackName = req.optString("track_name", "track") val trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "") val artistName = req.optString("artist_name", "")
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
return sanitizeFilename(baseName) + outputExt return forceFilenameExt(baseName, outputExt)
} }
private fun errorJson(message: String): String { private fun errorJson(message: String): String {
@@ -918,8 +988,7 @@ class MainActivity: FlutterFragmentActivity() {
val targetDir = ensureDocumentDir(treeUri, relativeDir) val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory") ?: return errorJson("Failed to access SAF directory")
val existingFile = targetDir.findFile(fileName) var document = createOrReuseDocumentFile(targetDir, mimeType, fileName)
val document = existingFile ?: targetDir.createFile(mimeType, fileName)
?: return errorJson("Failed to create SAF file") ?: return errorJson("Failed to create SAF file")
val pfd = contentResolver.openFileDescriptor(document.uri, "rw") val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
@@ -947,6 +1016,21 @@ class MainActivity: FlutterFragmentActivity() {
if (!srcFile.exists() || srcFile.length() <= 0) { if (!srcFile.exists() || srcFile.length() <= 0) {
throw IllegalStateException("extension output missing or empty: $goFilePath") 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 -> contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input -> srcFile.inputStream().use { input ->
input.copyTo(output) input.copyTo(output)
@@ -1934,9 +2018,54 @@ class MainActivity: FlutterFragmentActivity() {
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService. // We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
override fun shouldHandleDeeplinking(): Boolean = false override fun shouldHandleDeeplinking(): Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleExtensionOAuthIntent(intent)
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
handleExtensionOAuthIntent(intent)
}
/**
* Deliver Spotify (or other) OAuth authorization code to the extension runtime
* and run its token exchange (e.g. completeSpotifyLogin). State must be the extension id.
*/
private fun handleExtensionOAuthIntent(intent: Intent?) {
val uri = intent?.data ?: return
if (!uri.scheme.equals("spotiflac", ignoreCase = true)) {
return
}
val host = (uri.host ?: "").lowercase(Locale.US)
val path = (uri.path ?: "").lowercase(Locale.US)
val isCallback =
host == "callback" ||
host == "spotify-callback" ||
path.contains("callback")
if (!isCallback) {
return
}
val code = uri.getQueryParameter("code")?.trim().orEmpty()
if (code.isEmpty()) {
return
}
val extId = uri.getQueryParameter("state")?.trim().orEmpty()
if (extId.isEmpty()) {
android.util.Log.w("SpotiFLAC", "Extension OAuth redirect missing state (extension id)")
return
}
intent.data = null
scope.launch(Dispatchers.IO) {
try {
Gobackend.setExtensionAuthCodeByID(extId, code)
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
}
}
} }
override fun onDestroy() { override fun onDestroy() {
@@ -1952,6 +2081,7 @@ class MainActivity: FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
Gobackend.setAppVersion(BuildConfig.VERSION_NAME)
// Always-enabled back callback to ensure back presses reach Flutter. // Always-enabled back callback to ensure back presses reach Flutter.
// Nested tab navigators can incorrectly set frameworkHandlesBack(false), // Nested tab navigators can incorrectly set frameworkHandlesBack(false),
@@ -2136,7 +2266,6 @@ class MainActivity: FlutterFragmentActivity() {
result.error("saf_pending", "SAF picker already active", null) result.error("saf_pending", "SAF picker already active", null)
return@launch return@launch
} }
pendingSafTreeResult = result
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags( intent.addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION or
@@ -2144,7 +2273,24 @@ class MainActivity: FlutterFragmentActivity() {
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
) )
safTreeLauncher.launch(intent) val resolver = intent.resolveActivity(packageManager)
if (resolver == null) {
result.error("saf_unavailable", "No folder picker available on this device", null)
return@launch
}
pendingSafTreeResult = result
try {
android.util.Log.i("SpotiFLAC", "Launching SAF picker via $resolver")
safTreeLauncher.launch(intent)
} catch (e: Exception) {
pendingSafTreeResult = null
android.util.Log.e("SpotiFLAC", "Failed to launch SAF picker: ${e.message}", e)
result.error(
"saf_launch_failed",
e.message ?: "Failed to launch folder picker",
null
)
}
} }
"safExists" -> { "safExists" -> {
val uriStr = call.argument<String>("uri") ?: "" val uriStr = call.argument<String>("uri") ?: ""
@@ -2219,7 +2365,8 @@ class MainActivity: FlutterFragmentActivity() {
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
val existing = dir.findFile(fileName) val existing = dir.findFile(fileName)
val createdNew = existing == null val createdNew = existing == null
val doc = existing ?: dir.createFile(mimeType, fileName) ?: return@withContext null val doc = createOrReuseDocumentFile(dir, mimeType, fileName)
?: return@withContext null
if (!writeUriFromPath(doc.uri, srcPath)) { if (!writeUriFromPath(doc.uri, srcPath)) {
if (createdNew) { if (createdNew) {
doc.delete() doc.delete()
@@ -2717,16 +2864,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(null) result.success(null)
} }
"searchDeezerAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"searchTidalAll" -> { "searchTidalAll" -> {
val query = call.argument<String>("query") ?: "" val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15 val trackLimit = call.argument<Int>("track_limit") ?: 15
+14
View File
@@ -10,6 +10,7 @@ import (
var ErrDownloadCancelled = errors.New("download cancelled") var ErrDownloadCancelled = errors.New("download cancelled")
type cancelEntry struct { type cancelEntry struct {
ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
canceled bool canceled bool
} }
@@ -27,8 +28,21 @@ func initDownloadCancel(itemID string) context.Context {
cancelMu.Lock() cancelMu.Lock()
defer cancelMu.Unlock() defer cancelMu.Unlock()
if entry, ok := cancelMap[itemID]; ok {
if entry.ctx == nil {
ctx, cancel := context.WithCancel(context.Background())
entry.ctx = ctx
entry.cancel = cancel
if entry.canceled && entry.cancel != nil {
entry.cancel()
}
}
return entry.ctx
}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{ cancelMap[itemID] = &cancelEntry{
ctx: ctx,
cancel: cancel, cancel: cancel,
canceled: false, canceled: false,
} }
+233 -59
View File
@@ -5,12 +5,16 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/dop251/goja" "github.com/dop251/goja"
"golang.org/x/text/cases"
"golang.org/x/text/language"
) )
func CheckAvailability(spotifyID, isrc string) (string, error) { func CheckAvailability(spotifyID, isrc string) (string, error) {
@@ -33,6 +37,113 @@ func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS) 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 { type DownloadRequest struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Service string `json:"service"` Service string `json:"service"`
@@ -127,6 +238,12 @@ type DownloadResult struct {
Decryption *DownloadDecryptionInfo Decryption *DownloadDecryptionInfo
} }
var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return GetDeezerClient().GetExtendedMetadataByISRC(ctx, isrc)
}
var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC
type reEnrichRequest struct { type reEnrichRequest struct {
FilePath string `json:"file_path"` FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"` 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) 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) { func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req == nil { if req == nil {
return return
@@ -688,30 +874,13 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) enrichExtraMetadataByISRC(
defer cancel() "DownloadWithFallback",
req.ISRC,
deezerClient := GetDeezerClient() &req.Genre,
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC) &req.Label,
if err != nil || extMeta == nil { &req.Copyright,
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)
}
} }
func applySongLinkRegionFromRequest(req *DownloadRequest) { func applySongLinkRegionFromRequest(req *DownloadRequest) {
@@ -1316,6 +1485,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
lower := strings.ToLower(filePath) lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac") isFlac := strings.HasSuffix(lower, ".flac")
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc") 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"]) coverPath := strings.TrimSpace(fields["cover_path"])
if isFlac { if isFlac {
@@ -1361,6 +1531,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
DiscNumber: discNum, DiscNumber: discNum,
TotalDiscs: totalDiscs, TotalDiscs: totalDiscs,
ISRC: fields["isrc"], ISRC: fields["isrc"],
Lyrics: fields["lyrics"],
Genre: fields["genre"], Genre: fields["genre"],
Label: fields["label"], Label: fields["label"],
Copyright: fields["copyright"], Copyright: fields["copyright"],
@@ -1427,6 +1598,19 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil 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{ resp := map[string]any{
"success": true, "success": true,
"method": "ffmpeg", "method": "ffmpeg",
@@ -1436,6 +1620,29 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil 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 { func SetDownloadDirectory(path string) error {
return setDownloadDir(path) return setDownloadDir(path)
} }
@@ -1672,24 +1879,6 @@ func ClearTrackIDCache() {
ClearTrackCache() 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) { func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewTidalDownloader() downloader := NewTidalDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter) results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
@@ -2298,7 +2487,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
if req.SearchOnline { if req.SearchOnline {
found := false found := false
deezerClient := GetDeezerClient()
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n") GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
manager := getExtensionManager() manager := getExtensionManager()
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil { 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") 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 == "") { if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
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)
}
} }
if !found { if !found {
+90 -1
View File
@@ -1,6 +1,9 @@
package gobackend package gobackend
import "testing" import (
"context"
"testing"
)
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) { func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
original := GetExtensionFallbackProviderIDs() 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, &copyright)
if genre != "Alternative Rock" {
t.Fatalf("genre = %q, want fallback genre", genre)
}
if label != "" {
t.Fatalf("label = %q, want empty", label)
}
if copyright != "" {
t.Fatalf("copyright = %q, want empty", copyright)
}
}
func TestEnrichExtraMetadataByISRCPrefersDeezerGenre(t *testing.T) {
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
defer func() {
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
}()
musicBrainzCalled := false
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return &AlbumExtendedMetadata{
Genre: "Synthpop",
Label: "EMI",
Copyright: "(C) Test",
}, nil
}
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
musicBrainzCalled = true
return "Rock", nil
}
genre := ""
label := ""
copyright := ""
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST456", &genre, &label, &copyright)
if genre != "Synthpop" {
t.Fatalf("genre = %q, want Deezer genre", genre)
}
if label != "EMI" {
t.Fatalf("label = %q, want Deezer label", label)
}
if copyright != "(C) Test" {
t.Fatalf("copyright = %q, want Deezer copyright", copyright)
}
if musicBrainzCalled {
t.Fatal("expected MusicBrainz not to be called when Deezer already provides genre")
}
}
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) { func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
req := reEnrichRequest{ req := reEnrichRequest{
SpotifyID: "spotify-track-id", SpotifyID: "spotify-track-id",
+15 -3
View File
@@ -893,7 +893,6 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"` IconPath string `json:"icon_path,omitempty"`
@@ -951,7 +950,6 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
Name: ext.Manifest.Name, Name: ext.Manifest.Name,
DisplayName: ext.Manifest.DisplayName, DisplayName: ext.Manifest.DisplayName,
Version: ext.Manifest.Version, Version: ext.Manifest.Version,
Author: ext.Manifest.Author,
Description: ext.Manifest.Description, Description: ext.Manifest.Description,
Homepage: ext.Manifest.Homepage, Homepage: ext.Manifest.Homepage,
IconPath: iconPath, IconPath: iconPath,
@@ -1055,15 +1053,29 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
} }
defer ext.VMMu.Unlock() defer ext.VMMu.Unlock()
// Merge extension return values onto the top-level JSON object so Flutter can read
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try { try {
var result = extension.%s(); var result = extension.%s();
if (result && typeof result.then === 'function') { if (result && typeof result.then === 'function') {
// Handle promise - return pending status
return { success: true, pending: true, message: 'Action started' }; return { success: true, pending: true, message: 'Action started' };
} }
if (result !== null && result !== undefined && typeof result === 'object') {
var isArr = false;
if (typeof Array !== 'undefined' && Array.isArray) {
isArr = Array.isArray(result);
}
if (!isArr) {
var out = { success: true };
for (var k in result) {
out[k] = result[k];
}
return out;
}
}
return { success: true, result: result }; return { success: true, result: result };
} catch (e) { } catch (e) {
return { success: false, error: e.toString() }; return { success: false, error: e.toString() };
-5
View File
@@ -105,7 +105,6 @@ type ExtensionManifest struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
@@ -155,10 +154,6 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "version", Message: "version is required"} return &ManifestValidationError{Field: "version", Message: "version is required"}
} }
if strings.TrimSpace(m.Author) == "" {
return &ManifestValidationError{Field: "author", Message: "author is required"}
}
if strings.TrimSpace(m.Description) == "" { if strings.TrimSpace(m.Description) == "" {
return &ManifestValidationError{Field: "description", Message: "description is required"} return &ManifestValidationError{Field: "description", Message: "description is required"}
} }
+11 -58
View File
@@ -1,7 +1,6 @@
package gobackend package gobackend
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -616,6 +615,10 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
p.extension.runtime.setActiveDownloadItemID(itemID) p.extension.runtime.setActiveDownloadItemID(itemID)
defer p.extension.runtime.clearActiveDownloadItemID() defer p.extension.runtime.clearActiveDownloadItemID()
} }
if itemID != "" {
initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 { if len(call.Arguments) > 0 {
@@ -806,9 +809,6 @@ func sanitizeDownloadProviderPriority(providerIDs []string) []string {
} }
normalizedBuiltIn := strings.ToLower(providerID) normalizedBuiltIn := strings.ToLower(providerID)
if normalizedBuiltIn == "deezer" {
continue
}
if isBuiltInDownloadProvider(normalizedBuiltIn) { if isBuiltInDownloadProvider(normalizedBuiltIn) {
providerID = normalizedBuiltIn providerID = normalizedBuiltIn
} }
@@ -895,7 +895,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock() metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock() defer metadataProviderPriorityMu.Unlock()
sanitized := make([]string, 0, len(providerIDs)+3) sanitized := make([]string, 0, len(providerIDs)+2)
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, providerID := range providerIDs { for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID) providerID = strings.TrimSpace(providerID)
@@ -908,7 +908,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
seen[providerID] = struct{}{} seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID) sanitized = append(sanitized, providerID)
} }
for _, providerID := range []string{"deezer", "qobuz", "tidal"} { for _, providerID := range []string{"qobuz", "tidal"} {
if _, exists := seen[providerID]; exists { if _, exists := seen[providerID]; exists {
continue continue
} }
@@ -925,7 +925,7 @@ func GetMetadataProviderPriority() []string {
defer metadataProviderPriorityMu.RUnlock() defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 { if len(metadataProviderPriority) == 0 {
return []string{"deezer", "qobuz", "tidal"} return []string{"qobuz", "tidal"}
} }
result := make([]string, len(metadataProviderPriority)) result := make([]string, len(metadataProviderPriority))
@@ -935,7 +935,7 @@ func GetMetadataProviderPriority() []string {
func isBuiltInProvider(providerID string) bool { func isBuiltInProvider(providerID string) bool {
switch providerID { switch providerID {
case "tidal", "qobuz", "deezer": case "tidal", "qobuz":
return true return true
default: default:
return false return false
@@ -1006,20 +1006,6 @@ func metadataTrackDedupKey(track ExtTrackMetadata) string {
func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) { func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
switch providerID { switch providerID {
case "deezer":
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
results, err := GetDeezerClient().SearchAll(ctx, query, limit, 0, "track")
if err != nil {
return nil, err
}
tracks := make([]ExtTrackMetadata, 0, len(results.Tracks))
for _, track := range results.Tracks {
tracks = append(tracks, normalizeBuiltInMetadataTrack(track, "deezer"))
}
return tracks, nil
case "qobuz": case "qobuz":
return NewQobuzDownloader().SearchTracks(query, limit) return NewQobuzDownloader().SearchTracks(query, limit)
case "tidal": case "tidal":
@@ -1327,21 +1313,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.ISRC != "" && if req.ISRC != "" &&
(req.Genre == "" || req.Label == "" || req.Copyright == "") { (req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
} }
} }
@@ -1520,27 +1492,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if isBuiltInDownloadProvider(providerIDNormalized) { if isBuiltInDownloadProvider(providerIDNormalized) {
if (req.Genre == "" || req.Label == "" || req.Copyright == "") && if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
req.ISRC != "" { req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) GoLog("[DownloadWithExtensionFallback] Enriching extra metadata from ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
deezerClient := GetDeezerClient()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
GoLog("[DownloadWithExtensionFallback] Genre from Deezer: %s\n", req.Genre)
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
}
} else if err != nil {
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
} }
result, err := tryBuiltInProvider(providerIDNormalized, req) result, err := tryBuiltInProvider(providerIDNormalized, req)
+6 -10
View File
@@ -12,7 +12,7 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
SetMetadataProviderPriority([]string{"tidal"}) SetMetadataProviderPriority([]string{"tidal"})
got := GetMetadataProviderPriority() got := GetMetadataProviderPriority()
want := []string{"tidal", "deezer", "qobuz"} want := []string{"tidal", "qobuz"}
if len(got) != len(want) { if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want) t.Fatalf("unexpected priority length: got %v want %v", got, want)
} }
@@ -208,7 +208,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
searchBuiltInMetadataTracksFunc = originalSearch searchBuiltInMetadataTracksFunc = originalSearch
}() }()
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"}) SetMetadataProviderPriority([]string{"qobuz", "tidal"})
var calls []string var calls []string
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) { searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
@@ -223,10 +223,6 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"}, {ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"}, {ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
}, nil }, nil
case "deezer":
return []ExtTrackMetadata{
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
}, nil
default: default:
return nil, nil return nil, nil
} }
@@ -237,13 +233,13 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err) t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
} }
if len(tracks) != 3 { if len(tracks) != 2 {
t.Fatalf("unexpected track count: got %d want 3", len(tracks)) t.Fatalf("unexpected track count: got %d want 2", len(tracks))
} }
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" { if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" {
t.Fatalf("unexpected track provider order: %+v", tracks) t.Fatalf("unexpected track provider order: %+v", tracks)
} }
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" { if len(calls) != 2 || calls[0] != "qobuz" || calls[1] != "tidal" {
t.Fatalf("unexpected provider call order: %v", calls) t.Fatalf("unexpected provider call order: %v", calls)
} }
} }
+17
View File
@@ -160,6 +160,19 @@ func (r *extensionRuntime) getActiveDownloadItemID() string {
return r.activeDownloadItemID 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 { func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global // Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g. // allow_http scheme downgrade here, because some extension APIs (e.g.
@@ -413,6 +426,10 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher) utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
utilsObj.Set("generateKey", r.cryptoGenerateKey) utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent) utilsObj.Set("randomUserAgent", r.randomUserAgent)
utilsObj.Set("appVersion", r.appVersion)
utilsObj.Set("appUserAgent", r.appUserAgent)
utilsObj.Set("sleep", r.sleep)
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
vm.Set("utils", utilsObj) vm.Set("utils", utilsObj)
logObj := vm.NewObject() logObj := vm.NewObject()
+1
View File
@@ -458,6 +458,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
+1
View File
@@ -166,6 +166,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
+4
View File
@@ -81,6 +81,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
@@ -175,6 +176,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
@@ -284,6 +286,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
@@ -410,6 +413,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
"error": err.Error(), "error": err.Error(),
}) })
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
@@ -69,6 +69,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if err != nil { if err != nil {
return r.createFetchError(err.Error()) return r.createFetchError(err.Error())
} }
req = r.bindDownloadCancelContext(req)
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
+63
View File
@@ -249,6 +249,69 @@ func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent()) 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 { func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments) msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg) GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
+19 -7
View File
@@ -26,7 +26,6 @@ type storeExtension struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"` IconURL string `json:"icon_url,omitempty"`
@@ -83,7 +82,6 @@ type storeExtensionResponse struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
DownloadURL string `json:"download_url"` DownloadURL string `json:"download_url"`
IconURL string `json:"icon_url,omitempty"` IconURL string `json:"icon_url,omitempty"`
@@ -103,7 +101,6 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
Name: e.Name, Name: e.Name,
DisplayName: e.getDisplayName(), DisplayName: e.getDisplayName(),
Version: e.Version, Version: e.Version,
Author: e.Author,
Description: e.Description, Description: e.Description,
DownloadURL: e.getDownloadURL(), DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(), IconURL: e.getIconURL(),
@@ -253,7 +250,17 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL) LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
client := NewHTTPClientWithTimeout(30 * time.Second) client := NewHTTPClientWithTimeout(30 * time.Second)
resp, err := client.Get(s.registryURL) req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionStore", "Failed to build registry request, using cached registry: %v", err)
return s.cache, nil
}
return nil, fmt.Errorf("failed to build registry request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
if err != nil { if err != nil {
if s.cache != nil { if s.cache != nil {
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err) LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
@@ -348,7 +355,13 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL()) LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := NewHTTPClientWithTimeout(5 * time.Minute) client := NewHTTPClientWithTimeout(5 * time.Minute)
resp, err := client.Get(ext.getDownloadURL()) req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
if err != nil {
return fmt.Errorf("failed to build download request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to download: %w", err) return fmt.Errorf("failed to download: %w", err)
} }
@@ -481,8 +494,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
if query != "" { if query != "" {
if !containsIgnoreCase(ext.Name, queryLower) && if !containsIgnoreCase(ext.Name, queryLower) &&
!containsIgnoreCase(ext.DisplayName, queryLower) && !containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) && !containsIgnoreCase(ext.Description, queryLower) {
!containsIgnoreCase(ext.Author, queryLower) {
found := false found := false
for _, tag := range ext.Tags { for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) { if containsIgnoreCase(tag, queryLower) {
+124 -3
View File
@@ -1,8 +1,10 @@
package gobackend package gobackend
import ( import (
"net/http"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
@@ -12,7 +14,6 @@ func TestParseManifest_Valid(t *testing.T) {
"name": "test-provider", "name": "test-provider",
"displayName": "Test Provider", "displayName": "Test Provider",
"version": "1.0.0", "version": "1.0.0",
"author": "Test Author",
"description": "A test extension", "description": "A test extension",
"type": ["metadata_provider"], "type": ["metadata_provider"],
"permissions": { "permissions": {
@@ -46,7 +47,6 @@ func TestParseManifest_Valid(t *testing.T) {
func TestParseManifest_MissingName(t *testing.T) { func TestParseManifest_MissingName(t *testing.T) {
invalidManifest := `{ invalidManifest := `{
"version": "1.0.0", "version": "1.0.0",
"author": "Test Author",
"description": "A test extension", "description": "A test extension",
"type": ["metadata_provider"] "type": ["metadata_provider"]
}` }`
@@ -61,7 +61,6 @@ func TestParseManifest_MissingType(t *testing.T) {
invalidManifest := `{ invalidManifest := `{
"name": "test-provider", "name": "test-provider",
"version": "1.0.0", "version": "1.0.0",
"author": "Test Author",
"description": "A test extension" "description": "A test extension"
}` }`
@@ -239,6 +238,128 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
if result.String() == "" { if result.String() == "" {
t.Error("Expected non-empty JSON string") t.Error("Expected non-empty JSON string")
} }
result, err = vm.RunString(`utils.sleep(1)`)
if err != nil {
t.Fatalf("sleep failed: %v", err)
}
if !result.ToBoolean() {
t.Error("Expected sleep to complete successfully")
}
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
result, err = vm.RunString(`utils.isDownloadCancelled()`)
if err != nil {
t.Fatalf("isDownloadCancelled failed: %v", err)
}
if !result.ToBoolean() {
t.Error("Expected active download cancellation to be visible to JS")
}
SetAppVersion("4.2.2")
t.Cleanup(func() {
SetAppVersion("")
})
result, err = vm.RunString(`utils.appVersion()`)
if err != nil {
t.Fatalf("appVersion failed: %v", err)
}
if got := result.String(); got != "4.2.2" {
t.Fatalf("Expected appVersion 4.2.2, got %q", got)
}
result, err = vm.RunString(`utils.appUserAgent()`)
if err != nil {
t.Fatalf("appUserAgent failed: %v", err)
}
if got := result.String(); got != "SpotiFLAC-Mobile/4.2.2" {
t.Fatalf("Expected appUserAgent SpotiFLAC-Mobile/4.2.2, got %q", got)
}
result, err = vm.RunString(`utils.sleep(50)`)
if err != nil {
t.Fatalf("cancel-aware sleep failed: %v", err)
}
if result.ToBoolean() {
t.Error("Expected sleep to abort when download is cancelled")
}
}
func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
cancelDownload("test-item")
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected bound request context to be cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error after cancellation")
}
}
func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected pre-cancelled request context to stay cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error for pre-cancelled item")
}
} }
func TestExtensionRuntime_SSRFProtection(t *testing.T) { func TestExtensionRuntime_SSRFProtection(t *testing.T) {
+29 -4
View File
@@ -6,6 +6,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"unicode/utf8"
) )
var ( var (
@@ -17,19 +19,42 @@ var (
) )
func sanitizeFilename(filename string) string { func sanitizeFilename(filename string) string {
sanitized := invalidChars.ReplaceAllString(filename, "_") sanitized := strings.ReplaceAll(filename, "/", " ")
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
var builder strings.Builder
for _, r := range sanitized {
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
builder.WriteRune(r)
}
sanitized = builder.String()
sanitized = strings.TrimSpace(sanitized) sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".") sanitized = strings.Trim(sanitized, ". ")
sanitized = strings.Join(strings.Fields(sanitized), " ")
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_") sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
sanitized = strings.Trim(sanitized, "_ ")
if !utf8.ValidString(sanitized) {
sanitized = strings.ToValidUTF8(sanitized, "_")
}
if len(sanitized) > 200 { if len(sanitized) > 200 {
sanitized = sanitized[:200] sanitized = sanitized[:200]
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
sanitized = strings.Trim(sanitized, "_ ")
} }
if sanitized == "" { if sanitized == "" {
sanitized = "untitled" return "Unknown"
} }
return sanitized return sanitized
+15
View File
@@ -83,3 +83,18 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing
t.Fatalf("expected %q, got %q", expected, formatted) t.Fatalf("expected %q, got %q", expected, formatted)
} }
} }
func TestSanitizeFilenameMatchesDesktopSpacingBehavior(t *testing.T) {
got := sanitizeFilename(` "Text In Quotes"?%* / Demo `)
want := "Text In Quotes % Demo"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
got := sanitizeFilename(`<>:"/\|?*`)
if got != "Unknown" {
t.Fatalf("expected %q, got %q", "Unknown", got)
}
}
+15 -2
View File
@@ -16,6 +16,19 @@ import (
"time" "time"
) )
func userAgentForURL(u *url.URL) string {
if u == nil {
return getRandomUserAgent()
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
if host == "api.zarz.moe" {
return appUserAgent()
}
return getRandomUserAgent()
}
func getRandomUserAgent() string { func getRandomUserAgent() string {
chromeVersion := rand.Intn(26) + 120 chromeVersion := rand.Intn(26) + 120
chromeBuild := rand.Intn(1500) + 6000 chromeBuild := rand.Intn(1500) + 6000
@@ -225,7 +238,7 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
} }
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
@@ -255,7 +268,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
for attempt := 0; attempt <= config.MaxRetries; attempt++ { for attempt := 0; attempt <= config.MaxRetries; attempt++ {
reqCopy := req.Clone(req.Context()) reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent()) reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
resp, err := client.Do(reqCopy) resp, err := client.Do(reqCopy)
if err != nil { if err != nil {
+1 -1
View File
@@ -11,7 +11,7 @@ func GetCloudflareBypassClient() *http.Client {
} }
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := sharedClient.Do(req) resp, err := sharedClient.Do(req)
if err != nil { if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
+3 -3
View File
@@ -101,7 +101,7 @@ func GetCloudflareBypassClient() *http.Client {
} }
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := sharedClient.Do(req) resp, err := sharedClient.Do(req)
if err == nil { if err == nil {
@@ -129,7 +129,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...") LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
reqCopy := req.Clone(req.Context()) reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent()) reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
return cloudflareBypassClient.Do(reqCopy) return cloudflareBypassClient.Do(reqCopy)
} }
@@ -155,7 +155,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err) LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context()) reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent()) reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
return cloudflareBypassClient.Do(reqCopy) return cloudflareBypassClient.Do(reqCopy)
} }
+26
View File
@@ -39,8 +39,34 @@ var DefaultLyricsProviders = []string{
var ( var (
lyricsProvidersMu sync.RWMutex lyricsProvidersMu sync.RWMutex
lyricsProviders []string // ordered list of enabled providers lyricsProviders []string // ordered list of enabled providers
appVersionMu sync.RWMutex
appVersion string
) )
func SetAppVersion(version string) {
normalized := strings.TrimSpace(version)
appVersionMu.Lock()
defer appVersionMu.Unlock()
appVersion = normalized
}
func GetAppVersion() string {
appVersionMu.RLock()
defer appVersionMu.RUnlock()
return appVersion
}
func appUserAgent() string {
version := GetAppVersion()
if version == "" {
return "SpotiFLAC-Mobile"
}
return "SpotiFLAC-Mobile/" + version
}
type LyricsFetchOptions struct { type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"` IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"` IncludeRomanizationNetease bool `json:"include_romanization_netease"`
+3 -2
View File
@@ -114,7 +114,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
@@ -147,7 +147,8 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
+1 -1
View File
@@ -72,7 +72,7 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
+2 -2
View File
@@ -70,7 +70,7 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
for k, v := range neteaseHeaders { for k, v := range neteaseHeaders {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -109,7 +109,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
for k, v := range neteaseHeaders { for k, v := range neteaseHeaders {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
+1 -1
View File
@@ -54,7 +54,7 @@ func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, dura
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
+346 -4
View File
@@ -9,6 +9,7 @@ import (
_ "image/jpeg" _ "image/jpeg"
_ "image/png" _ "image/png"
"io" "io"
"math"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -1244,6 +1245,281 @@ func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string
return nameValue, dataValue, nil return nameValue, dataValue, nil
} }
type m4aMetadataPath struct {
moov atomHeader
udta *atomHeader
meta atomHeader
ilst atomHeader
}
func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return m4aMetadataPath{}, fmt.Errorf("moov not found")
}
moovBodyStart := moov.offset + moov.headerSize
moovBodySize := moov.size - moov.headerSize
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
udtaCopy := udta
return m4aMetadataPath{
moov: moov,
udta: &udtaCopy,
meta: meta,
ilst: ilst,
}, nil
}
}
}
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
return m4aMetadataPath{
moov: moov,
meta: meta,
ilst: ilst,
}, nil
}
}
return m4aMetadataPath{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
func buildM4AAtom(typ string, payload []byte) []byte {
size := int64(8 + len(payload))
buf := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(buf[0:4], uint32(size))
copy(buf[4:8], []byte(typ))
copy(buf[8:], payload)
return buf
}
func buildM4AFreeformAtom(name, value string) []byte {
meanPayload := append([]byte{0, 0, 0, 0}, []byte("com.apple.iTunes")...)
namePayload := append([]byte{0, 0, 0, 0}, []byte(name)...)
dataPayload := make([]byte, 8+len(value))
binary.BigEndian.PutUint32(dataPayload[0:4], 1) // UTF-8 text
copy(dataPayload[8:], []byte(value))
payload := append([]byte{}, buildM4AAtom("mean", meanPayload)...)
payload = append(payload, buildM4AAtom("name", namePayload)...)
payload = append(payload, buildM4AAtom("data", dataPayload)...)
return buildM4AAtom("----", payload)
}
func buildITunNORMTag(trackGain, trackPeak string) string {
gainDb, ok := parseReplayGainDb(trackGain)
if !ok {
return ""
}
peakLinear, ok := parseReplayGainPeak(trackPeak)
if !ok {
return ""
}
clamp := func(v int64) int64 {
if v < 0 {
return 0
}
if v > 65534 {
return 65534
}
return v
}
g1 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 1000.0)))
g2 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 2500.0)))
peak := clamp(int64(math.Round(peakLinear * 32768.0)))
values := []int64{g1, g1, g2, g2, 0, 0, peak, peak, 0, 0}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strings.ToUpper(fmt.Sprintf("%08x", value)))
}
return strings.Join(parts, " ")
}
func parseReplayGainDb(value string) (float64, bool) {
match := regexp.MustCompile(`([+-]?\d+(?:\.\d+)?)`).FindStringSubmatch(strings.TrimSpace(value))
if len(match) < 2 {
return 0, false
}
parsed, err := strconv.ParseFloat(match[1], 64)
if err != nil {
return 0, false
}
return parsed, true
}
func parseReplayGainPeak(value string) (float64, bool) {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || parsed <= 0 {
return 0, false
}
return parsed, true
}
func collectM4AReplayGainFields(fields map[string]string) map[string]string {
result := map[string]string{}
if value := strings.TrimSpace(fields["replaygain_track_gain"]); value != "" {
result["replaygain_track_gain"] = value
}
if value := strings.TrimSpace(fields["replaygain_track_peak"]); value != "" {
result["replaygain_track_peak"] = value
}
if value := strings.TrimSpace(fields["replaygain_album_gain"]); value != "" {
result["replaygain_album_gain"] = value
}
if value := strings.TrimSpace(fields["replaygain_album_peak"]); value != "" {
result["replaygain_album_peak"] = value
}
if norm := buildITunNORMTag(result["replaygain_track_gain"], result["replaygain_track_peak"]); norm != "" {
result["iTunNORM"] = norm
}
return result
}
func writeAtomSize(buf []byte, header atomHeader, newSize int64) error {
if newSize <= 0 {
return fmt.Errorf("invalid size for %s", header.typ)
}
if header.headerSize == 16 {
if int(header.offset)+16 > len(buf) {
return io.ErrUnexpectedEOF
}
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], 1)
binary.BigEndian.PutUint64(buf[header.offset+8:header.offset+16], uint64(newSize))
return nil
}
if newSize > math.MaxUint32 {
return fmt.Errorf("atom %s too large for 32-bit header", header.typ)
}
if int(header.offset)+8 > len(buf) {
return io.ErrUnexpectedEOF
}
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], uint32(newSize))
return nil
}
func EditM4AReplayGain(filePath string, fields map[string]string) error {
replayGainFields := collectM4AReplayGainFields(fields)
if len(replayGainFields) == 0 {
return nil
}
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
path, err := findM4AMetadataPath(f, info.Size())
if err != nil {
return err
}
data, err := os.ReadFile(filePath)
if err != nil {
return err
}
bodyStart := path.ilst.offset + path.ilst.headerSize
bodyEnd := path.ilst.offset + path.ilst.size
newBody := make([]byte, 0, int(path.ilst.size))
targets := map[string]struct{}{
"REPLAYGAIN_TRACK_GAIN": {},
"REPLAYGAIN_TRACK_PEAK": {},
"REPLAYGAIN_ALBUM_GAIN": {},
"REPLAYGAIN_ALBUM_PEAK": {},
"ITUNNORM": {},
}
for pos := bodyStart; pos+8 <= bodyEnd; {
header, readErr := readAtomHeaderAt(f, pos, info.Size())
if readErr != nil {
return readErr
}
if header.size == 0 {
header.size = bodyEnd - pos
}
if header.size < header.headerSize {
return fmt.Errorf("invalid atom size for %s", header.typ)
}
keep := true
if header.typ == "----" {
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
if freeformErr == nil {
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
keep = false
}
}
}
if keep {
newBody = append(newBody, data[pos:pos+header.size]...)
}
pos += header.size
}
order := []string{
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
"iTunNORM",
}
for _, key := range order {
value := strings.TrimSpace(replayGainFields[key])
if value == "" {
continue
}
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
}
newIlst := buildM4AAtom("ilst", newBody)
updated := append([]byte{}, data[:path.ilst.offset]...)
updated = append(updated, newIlst...)
updated = append(updated, data[path.ilst.offset+path.ilst.size:]...)
delta := int64(len(newIlst)) - path.ilst.size
if err := writeAtomSize(updated, path.ilst, path.ilst.size+delta); err != nil {
return err
}
if err := writeAtomSize(updated, path.meta, path.meta.size+delta); err != nil {
return err
}
if path.udta != nil {
if err := writeAtomSize(updated, *path.udta, path.udta.size+delta); err != nil {
return err
}
}
if err := writeAtomSize(updated, path.moov, path.moov.size+delta); err != nil {
return err
}
return os.WriteFile(filePath, updated, 0o644)
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) { func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath) ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext) base := strings.TrimSuffix(filePath, ext)
@@ -1423,16 +1699,82 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
// [28:32] samplerate (16.16 fixed-point) // [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29]) sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := int(buf[22])<<8 | int(buf[23]) bitDepth := int(buf[22])<<8 | int(buf[23])
if bitDepth <= 0 {
bitDepth = 16 if atomType == "alac" {
if atomType == "alac" { if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
bitDepth = 24 if alacBitDepth > 0 {
bitDepth = alacBitDepth
}
if alacSampleRate > 0 {
sampleRate = alacSampleRate
}
} }
} }
if bitDepth <= 0 {
bitDepth = 16
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
} }
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
if sampleOffset < 4 {
return 0, 0, false
}
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
if err != nil {
return 0, 0, false
}
childStart := sampleOffset + 32
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
if childStart >= childEnd {
return 0, 0, false
}
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "alac", fileSize)
if err != nil || !found {
return 0, 0, false
}
payloadSize := configHeader.size - configHeader.headerSize
if payloadSize <= 0 {
return 0, 0, false
}
payload := make([]byte, payloadSize)
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
return 0, 0, false
}
return parseALACSpecificConfig(payload)
}
func parseALACSpecificConfig(payload []byte) (int, int, bool) {
if len(payload) < 24 {
return 0, 0, false
}
bitDepth := int(payload[5])
sampleRate := int(binary.BigEndian.Uint32(payload[20:24]))
if bitDepth > 0 && sampleRate > 0 {
return bitDepth, sampleRate, true
}
// Some encoders prepend 4 bytes before the ALACSpecificConfig payload.
if len(payload) >= 28 {
bitDepth = int(payload[9])
sampleRate = int(binary.BigEndian.Uint32(payload[24:28]))
if bitDepth > 0 && sampleRate > 0 {
return bitDepth, sampleRate, true
}
}
return 0, 0, false
}
type atomHeader struct { type atomHeader struct {
offset int64 offset int64
size int64 size int64
+49
View File
@@ -0,0 +1,49 @@
package gobackend
import "testing"
func TestParseALACSpecificConfigStandardPayload(t *testing.T) {
payload := make([]byte, 24)
payload[5] = 24
payload[20] = 0x00
payload[21] = 0x00
payload[22] = 0xac
payload[23] = 0x44
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
if !ok {
t.Fatal("expected standard ALAC payload to parse")
}
if bitDepth != 24 {
t.Fatalf("bitDepth = %d, want 24", bitDepth)
}
if sampleRate != 44100 {
t.Fatalf("sampleRate = %d, want 44100", sampleRate)
}
}
func TestParseALACSpecificConfigPayloadWithLeadingFourBytes(t *testing.T) {
payload := make([]byte, 28)
payload[9] = 16
payload[24] = 0x00
payload[25] = 0x00
payload[26] = 0xbb
payload[27] = 0x80
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
if !ok {
t.Fatal("expected offset ALAC payload to parse")
}
if bitDepth != 16 {
t.Fatalf("bitDepth = %d, want 16", bitDepth)
}
if sampleRate != 48000 {
t.Fatalf("sampleRate = %d, want 48000", sampleRate)
}
}
func TestParseALACSpecificConfigRejectsShortPayload(t *testing.T) {
if _, _, ok := parseALACSpecificConfig(make([]byte, 12)); ok {
t.Fatal("expected short ALAC payload to be rejected")
}
}
+4 -3
View File
@@ -147,6 +147,7 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
return nil, fmt.Errorf("failed to create resolve request: %w", err) return nil, fmt.Errorf("failed to create resolve request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err != nil { if err != nil {
@@ -164,9 +165,9 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
} }
var resolveResp struct { var resolveResp struct {
Success bool `json:"success"` Success bool `json:"success"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
SongUrls map[string]json.RawMessage `json:"songUrls"` SongUrls map[string]json.RawMessage `json:"songUrls"`
} }
if err := json.Unmarshal(body, &resolveResp); err != nil { if err := json.Unmarshal(body, &resolveResp); err != nil {
return nil, fmt.Errorf("failed to decode resolve response: %w", err) return nil, fmt.Errorf("failed to decode resolve response: %w", err)
+53 -10
View File
@@ -22,6 +22,9 @@ import Gobackend // Import Go framework
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
GobackendSetAppVersion(version)
}
let controller = window?.rootViewController as! FlutterViewController let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel( let channel = FlutterMethodChannel(
@@ -66,9 +69,59 @@ import Gobackend // Import Go framework
) )
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
if let url = launchOptions?[.url] as? URL {
_ = handleExtensionOAuthRedirect(url: url)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
@discardableResult
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
let host = (url.host ?? "").lowercased()
let path = url.path.lowercased()
let ok =
host == "callback" || host == "spotify-callback" || path.contains("callback")
guard ok else { return false }
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return false
}
let q = components.queryItems ?? []
let code =
q.first { $0.name == "code" }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
let state =
q.first { $0.name == "state" }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
if code.isEmpty { return false }
if state.isEmpty {
NSLog("SpotiFLAC: Extension OAuth redirect missing state (extension id)")
return false
}
streamQueue.async {
var err: NSError?
GobackendSetExtensionAuthCodeByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
if let err = err {
NSLog(
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
}
}
return true
}
override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if handleExtensionOAuthRedirect(url: url) {
return true
}
return super.application(app, open: url, options: options)
}
deinit { deinit {
stopDownloadProgressStream() stopDownloadProgressStream()
stopLibraryScanProgressStream() stopLibraryScanProgressStream()
@@ -371,16 +424,6 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "searchDeezerAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let filter = args["filter"] as? String ?? ""
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error }
return response
case "searchTidalAll": case "searchTidalAll":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let query = args["query"] as! String let query = args["query"] as! String
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '4.2.2'; static const String version = '4.3.0';
static const String buildNumber = '123'; static const String buildNumber = '125';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release. /// Shows "Internal" in debug builds, actual version in release.
+18
View File
@@ -352,6 +352,18 @@ abstract class AppLocalizations {
/// **'Using extension: {extensionName}'** /// **'Using extension: {extensionName}'**
String optionsUsingExtension(String extensionName); String optionsUsingExtension(String extensionName);
/// Title for the preferred default search tab setting
///
/// In en, this message translates to:
/// **'Default Search Tab'**
String get optionsDefaultSearchTab;
/// Subtitle for the preferred default search tab setting
///
/// In en, this message translates to:
/// **'Choose which tab opens first for new search results.'**
String get optionsDefaultSearchTabSubtitle;
/// Hint to switch back to built-in providers /// Hint to switch back to built-in providers
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -718,6 +730,12 @@ abstract class AppLocalizations {
/// **'PC source code'** /// **'PC source code'**
String get aboutPCSource; String get aboutPCSource;
/// Link to Keep Android Open campaign website
///
/// In en, this message translates to:
/// **'Keep Android Open'**
String get aboutKeepAndroidOpen;
/// Link to report bugs /// Link to report bugs
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+10
View File
@@ -129,6 +129,13 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Erweiterung verwenden: $extensionName'; return 'Erweiterung verwenden: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tippe auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln'; 'Tippe auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln';
@@ -341,6 +348,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC Quellcode'; String get aboutPCSource => 'PC Quellcode';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Problem melden'; String get aboutReportIssue => 'Problem melden';
+10
View File
@@ -127,6 +127,13 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Using extension: $extensionName'; return 'Using extension: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
+13
View File
@@ -127,6 +127,13 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Using extension: $extensionName'; return 'Using extension: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
@@ -3707,6 +3717,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override @override
String get aboutPCSource => 'Código fuente de PC'; String get aboutPCSource => 'Código fuente de PC';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Reportar un problema'; String get aboutReportIssue => 'Reportar un problema';
+10
View File
@@ -128,6 +128,13 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Utilisation de l\'extension: $extensionName'; return 'Utilisation de l\'extension: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension'; 'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
@@ -336,6 +343,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
+10
View File
@@ -127,6 +127,13 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Using extension: $extensionName'; return 'Using extension: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
+10
View File
@@ -129,6 +129,13 @@ class AppLocalizationsId extends AppLocalizations {
return 'Menggunakan ekstensi: $extensionName'; return 'Menggunakan ekstensi: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Tab Pencarian Default';
@override
String get optionsDefaultSearchTabSubtitle =>
'Pilih tab yang dibuka lebih dulu untuk hasil pencarian baru.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi'; 'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
@@ -337,6 +344,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get aboutPCSource => 'Kode sumber PC'; String get aboutPCSource => 'Kode sumber PC';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Laporkan masalah'; String get aboutReportIssue => 'Laporkan masalah';
+10
View File
@@ -127,6 +127,13 @@ class AppLocalizationsJa extends AppLocalizations {
return '拡張の使用: $extensionName'; return '拡張の使用: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -330,6 +337,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC 版のソースコード'; String get aboutPCSource => 'PC 版のソースコード';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => '問題を報告する'; String get aboutReportIssue => '問題を報告する';
+10
View File
@@ -125,6 +125,13 @@ class AppLocalizationsKo extends AppLocalizations {
return '확장 기능을 사용: $extensionName'; return '확장 기능을 사용: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.'; String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
@@ -323,6 +330,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC 소스 코드'; String get aboutPCSource => 'PC 소스 코드';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => '문제 신고'; String get aboutReportIssue => '문제 신고';
+10
View File
@@ -127,6 +127,13 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Using extension: $extensionName'; return 'Using extension: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
+13
View File
@@ -127,6 +127,13 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Using extension: $extensionName'; return 'Using extension: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
@@ -3707,6 +3717,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override @override
String get aboutPCSource => 'Código-fonte do app desktop'; String get aboutPCSource => 'Código-fonte do app desktop';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Reportar um problema'; String get aboutReportIssue => 'Reportar um problema';
+10
View File
@@ -129,6 +129,13 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Используется расширение: $extensionName'; return 'Используется расширение: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Нажмите Deezer или Spotify для возврата с расширения'; 'Нажмите Deezer или Spotify для возврата с расширения';
@@ -340,6 +347,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get aboutPCSource => 'Исходный код ПК версии'; String get aboutPCSource => 'Исходный код ПК версии';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Сообщить о проблеме'; String get aboutReportIssue => 'Сообщить о проблеме';
+10
View File
@@ -129,6 +129,13 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Kullanılan eklenti: $extensionName'; return 'Kullanılan eklenti: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Dahili kaynaklara dönmek için Deezer veya Spotify\'a tıkla'; 'Dahili kaynaklara dönmek için Deezer veya Spotify\'a tıkla';
@@ -337,6 +344,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC kaynak kodu'; String get aboutPCSource => 'PC kaynak kodu';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Sorun bildir'; String get aboutReportIssue => 'Sorun bildir';
+16
View File
@@ -127,6 +127,13 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Using extension: $extensionName'; return 'Using extension: $extensionName';
} }
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override @override
String get optionsSwitchBack => String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension'; 'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
@@ -3689,6 +3699,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override @override
String get aboutPCSource => '桌面版本源代码'; String get aboutPCSource => '桌面版本源代码';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => '报告一个问题'; String get aboutReportIssue => '报告一个问题';
@@ -6083,6 +6096,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Report an issue';
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Problem melden", "aboutReportIssue": "Problem melden",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+12
View File
@@ -158,6 +158,14 @@
} }
} }
}, },
"optionsDefaultSearchTab": "Default Search Tab",
"@optionsDefaultSearchTab": {
"description": "Title for the preferred default search tab setting"
},
"optionsDefaultSearchTabSubtitle": "Choose which tab opens first for new search results.",
"@optionsDefaultSearchTabSubtitle": {
"description": "Subtitle for the preferred default search tab setting"
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension", "optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"@optionsSwitchBack": { "@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers" "description": "Hint to switch back to built-in providers"
@@ -422,6 +430,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -362,6 +362,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Reportar un problema", "aboutReportIssue": "Reportar un problema",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+12
View File
@@ -150,6 +150,14 @@
} }
} }
}, },
"optionsDefaultSearchTab": "Tab Pencarian Default",
"@optionsDefaultSearchTab": {
"description": "Title for the preferred default search tab setting"
},
"optionsDefaultSearchTabSubtitle": "Pilih tab yang dibuka lebih dulu untuk hasil pencarian baru.",
"@optionsDefaultSearchTabSubtitle": {
"description": "Subtitle for the preferred default search tab setting"
},
"optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi", "optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi",
"@optionsSwitchBack": { "@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers" "description": "Hint to switch back to built-in providers"
@@ -382,6 +390,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Laporkan masalah", "aboutReportIssue": "Laporkan masalah",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "問題を報告する", "aboutReportIssue": "問題を報告する",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "문제 신고", "aboutReportIssue": "문제 신고",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -362,6 +362,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Reportar um problema", "aboutReportIssue": "Reportar um problema",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Сообщить о проблеме", "aboutReportIssue": "Сообщить о проблеме",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Sorun bildir", "aboutReportIssue": "Sorun bildir",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -362,6 +362,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "报告一个问题", "aboutReportIssue": "报告一个问题",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -378,6 +378,10 @@
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Report an issue",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
+4
View File
@@ -35,6 +35,7 @@ class AppSettings {
final bool useExtensionProviders; final bool useExtensionProviders;
final List<String>? downloadFallbackExtensionIds; final List<String>? downloadFallbackExtensionIds;
final String? searchProvider; final String? searchProvider;
final String defaultSearchTab;
final String? homeFeedProvider; final String? homeFeedProvider;
final bool separateSingles; final bool separateSingles;
final String singleFilenameFormat; final String singleFilenameFormat;
@@ -111,6 +112,7 @@ class AppSettings {
this.useExtensionProviders = true, this.useExtensionProviders = true,
this.downloadFallbackExtensionIds, this.downloadFallbackExtensionIds,
this.searchProvider, this.searchProvider,
this.defaultSearchTab = 'all',
this.homeFeedProvider, this.homeFeedProvider,
this.separateSingles = false, this.separateSingles = false,
this.singleFilenameFormat = '{title} - {artist}', this.singleFilenameFormat = '{title} - {artist}',
@@ -176,6 +178,7 @@ class AppSettings {
bool clearDownloadFallbackExtensionIds = false, bool clearDownloadFallbackExtensionIds = false,
String? searchProvider, String? searchProvider,
bool clearSearchProvider = false, bool clearSearchProvider = false,
String? defaultSearchTab,
String? homeFeedProvider, String? homeFeedProvider,
bool clearHomeFeedProvider = false, bool clearHomeFeedProvider = false,
bool? separateSingles, bool? separateSingles,
@@ -242,6 +245,7 @@ class AppSettings {
searchProvider: clearSearchProvider searchProvider: clearSearchProvider
? null ? null
: (searchProvider ?? this.searchProvider), : (searchProvider ?? this.searchProvider),
defaultSearchTab: defaultSearchTab ?? this.defaultSearchTab,
homeFeedProvider: clearHomeFeedProvider homeFeedProvider: clearHomeFeedProvider
? null ? null
: (homeFeedProvider ?? this.homeFeedProvider), : (homeFeedProvider ?? this.homeFeedProvider),
+2
View File
@@ -40,6 +40,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
?.map((e) => e as String) ?.map((e) => e as String)
.toList(), .toList(),
searchProvider: json['searchProvider'] as String?, searchProvider: json['searchProvider'] as String?,
defaultSearchTab: json['defaultSearchTab'] as String? ?? 'all',
homeFeedProvider: json['homeFeedProvider'] as String?, homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false, separateSingles: json['separateSingles'] as bool? ?? false,
singleFilenameFormat: singleFilenameFormat:
@@ -111,6 +112,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'useExtensionProviders': instance.useExtensionProviders, 'useExtensionProviders': instance.useExtensionProviders,
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds, 'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
'searchProvider': instance.searchProvider, 'searchProvider': instance.searchProvider,
'defaultSearchTab': instance.defaultSearchTab,
'homeFeedProvider': instance.homeFeedProvider, 'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles, 'separateSingles': instance.separateSingles,
'singleFilenameFormat': instance.singleFilenameFormat, 'singleFilenameFormat': instance.singleFilenameFormat,
+162 -129
View File
@@ -26,7 +26,10 @@ final _log = AppLogger('DownloadQueue');
final _historyLog = AppLogger('DownloadHistory'); final _historyLog = AppLogger('DownloadHistory');
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]'); 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. /// log10 helper using dart:math's natural log.
double _log10(num x) => log(x) / ln10; double _log10(num x) => log(x) / ln10;
@@ -2165,10 +2168,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
String _sanitizeFolderName(String name) { String _sanitizeFolderName(String name) {
return name final buffer = StringBuffer();
.replaceAll(_invalidFolderChars, '_') for (final rune in name.runes) {
.replaceAll(_trailingDotsRegex, '') if (rune < 0x20 || rune == 0x7f) {
.trim(); 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( static final _featuredArtistPattern = RegExp(
@@ -2322,11 +2344,41 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return '$prefix/$suffix'; 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) { String _determineOutputExt(String quality, String service) {
final extensionPreferred = _extensionPreferredOutputExt(service);
if (extensionPreferred != null) {
return extensionPreferred;
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a'; return '.m4a';
} }
final q = quality.toLowerCase(); final q = quality.toLowerCase();
if (q == 'alac' || q.startsWith('aac')) return '.m4a';
if (q.startsWith('opus')) return '.opus'; if (q.startsWith('opus')) return '.opus';
if (q.startsWith('mp3')) return '.mp3'; if (q.startsWith('mp3')) return '.mp3';
return '.flac'; return '.flac';
@@ -3696,8 +3748,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
/// Unified metadata, cover, lyrics, and ReplayGain embedding for all formats. /// Unified metadata, cover, lyrics, and ReplayGain embedding for all formats.
/// ///
/// [format] must be one of `'flac'`, `'mp3'`, or `'opus'`. /// [format] must be one of `'flac'`, `'m4a'`, `'mp3'`, or `'opus'`.
/// [writeExternalLrc] only applies to FLAC (non-SAF paths handle LRC separately). /// [writeExternalLrc] only applies to FLAC and M4A (non-SAF paths handle LRC separately).
Future<void> _embedMetadataToFile( Future<void> _embedMetadataToFile(
String filePath, String filePath,
Track track, { Track track, {
@@ -3717,6 +3769,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
final isFlac = format == 'flac'; final isFlac = format == 'flac';
final isM4a = format == 'm4a';
final isMp3 = format == 'mp3'; final isMp3 = format == 'mp3';
// Cover download // Cover download
@@ -3840,9 +3893,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (shouldEmbedLyrics && lrcContent != null) { if (shouldEmbedLyrics && lrcContent != null) {
metadata['LYRICS'] = lrcContent; metadata['LYRICS'] = lrcContent;
if (isFlac || isMp3) metadata['UNSYNCEDLYRICS'] = lrcContent; if (isFlac || isMp3) metadata['UNSYNCEDLYRICS'] = lrcContent;
} else if (isFlac && !shouldEmbedLyrics) { } else if ((isFlac || isM4a) && !shouldEmbedLyrics) {
metadata['LYRICS'] = ''; metadata['LYRICS'] = '';
metadata['UNSYNCEDLYRICS'] = ''; if (isFlac) {
metadata['UNSYNCEDLYRICS'] = '';
}
} }
if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) { 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) { if (settings.embedReplayGain && !isFlac) {
try { try {
final rgResult = await FFmpegService.scanReplayGain(filePath); final rgResult = await FFmpegService.scanReplayGain(filePath);
if (rgResult != null) { if (rgResult != null) {
scannedReplayGain = rgResult;
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain; metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak; metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
_log.d( _log.d(
@@ -3886,6 +3944,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
metadata: metadata, metadata: metadata,
artistTagMode: settings.artistTagMode, artistTagMode: settings.artistTagMode,
); );
} else if (isM4a) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: filePath,
coverPath: validCover,
metadata: metadata,
);
} else if (isMp3) { } else if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3( ffmpegResult = await FFmpegService.embedMetadataToMp3(
mp3Path: filePath, mp3Path: filePath,
@@ -3907,6 +3971,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.w('FFmpeg $format metadata embed failed'); _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 // FLAC post-processing
if (isFlac) { if (isFlac) {
if (settings.artistTagMode == artistTagModeSplitVorbis) { if (settings.artistTagMode == artistTagModeSplitVorbis) {
@@ -4935,7 +5013,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (shouldForceTidalSafM4aHandling) { if (shouldForceTidalSafM4aHandling) {
_log.w( _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 { } else {
_log.d('M4A file detected (SAF), converting to FLAC...'); _log.d('M4A file detected (SAF), preserving native container...');
final tempPath = await _copySafToTemp(currentFilePath); final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) { if (tempPath != null) {
String? flacPath;
try { try {
final length = await File(tempPath).length(); if (metadataEmbeddingEnabled) {
if (length < 1024) {
_log.w('Temp M4A is too small (<1KB), skipping conversion');
} else {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.finalizing, DownloadStatus.finalizing,
progress: 0.95, progress: 0.99,
); );
flacPath = await FFmpegService.convertM4aToFlac(tempPath); final finalTrack = _buildTrackForMetadataEmbedding(
if (flacPath != null) { trackToDownload,
_log.d('Converted to FLAC (temp): $flacPath'); result,
_log.d( resolvedAlbumArtist,
'Embedding metadata and cover to converted FLAC...', );
); final backendGenre = result['genre'] as String?;
final finalTrack = _buildTrackForMetadataEmbedding( final backendLabel = result['label'] as String?;
trackToDownload, final backendCopyright = result['copyright'] as String?;
result,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?; await _embedMetadataToFile(
final backendLabel = result['label'] as String?; tempPath,
final backendCopyright = result['copyright'] as String?; finalTrack,
format: 'm4a',
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
downloadService: item.service,
writeExternalLrc: false,
);
}
await _embedMetadataToFile( final newFileName = '${safBaseName ?? 'track'}.m4a';
flacPath, final newUri = await _writeTempToSaf(
finalTrack, treeUri: settings.downloadTreeUri,
format: 'flac', relativeDir: effectiveOutputDir,
genre: backendGenre ?? genre, fileName: newFileName,
label: backendLabel ?? label, mimeType: _mimeTypeForExt('.m4a'),
copyright: backendCopyright, srcPath: tempPath,
downloadService: item.service, );
writeExternalLrc: false,
);
final newFileName = '${safBaseName ?? 'track'}.flac'; if (newUri != null) {
final newUri = await _writeTempToSaf( if (newUri != currentFilePath) {
treeUri: settings.downloadTreeUri, await _deleteSafFile(currentFilePath);
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',
);
} }
filePath = newUri;
finalSafFileName = newFileName;
} else {
_log.w('Failed to write M4A to SAF, keeping original');
} }
} catch (e) { } catch (e) {
_log.w('SAF M4A->FLAC conversion failed: $e'); _log.w('SAF native M4A handling failed: $e');
} finally { } finally {
try { try {
await File(tempPath).delete(); await File(tempPath).delete();
} catch (_) {} } catch (_) {}
if (flacPath != null) {
try {
await File(flacPath).delete();
} catch (_) {}
}
} }
} }
} }
@@ -5208,82 +5265,58 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
actualQuality = 'AAC 320kbps'; actualQuality = 'AAC 320kbps';
} }
} else { } else {
_log.d( _log.d('M4A file detected, preserving native container...');
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
);
try { try {
final file = File(currentFilePath); var targetPath = currentFilePath;
final file = File(targetPath);
if (!await file.exists()) { if (!await file.exists()) {
_log.e('File does not exist at path: $filePath'); _log.e('File does not exist at path: $filePath');
} else { } else {
final length = await file.length(); if (!targetPath.toLowerCase().endsWith('.m4a')) {
_log.i('File size before conversion: ${length / 1024} KB'); final renamedPath = targetPath.replaceAll(
RegExp(r'\.[^.]+$'),
if (length < 1024) { '.m4a',
_log.w(
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
); );
final finalRenamedPath = renamedPath == targetPath
? '$targetPath.m4a'
: renamedPath;
await file.rename(finalRenamedPath);
targetPath = finalRenamedPath;
filePath = finalRenamedPath;
} else { } else {
filePath = targetPath;
}
if (metadataEmbeddingEnabled) {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.finalizing, DownloadStatus.finalizing,
progress: 0.95, progress: 0.99,
); );
final flacPath = await FFmpegService.convertM4aToFlac( final finalTrack = _buildTrackForMetadataEmbedding(
currentFilePath, trackToDownload,
result,
resolvedAlbumArtist,
); );
if (flacPath != null) { final backendGenre = result['genre'] as String?;
filePath = flacPath; final backendLabel = result['label'] as String?;
_log.d('Converted to FLAC: $flacPath'); final backendCopyright = result['copyright'] as String?;
_log.d( await _embedMetadataToFile(
'Embedding metadata and cover to converted FLAC...', targetPath,
); finalTrack,
try { format: 'm4a',
final finalTrack = _buildTrackForMetadataEmbedding( genre: backendGenre ?? genre,
trackToDownload, label: backendLabel ?? label,
result, copyright: backendCopyright,
resolvedAlbumArtist, downloadService: item.service,
); );
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',
);
}
} }
} }
} catch (e) { } catch (e) {
_log.w( _log.w('Native M4A handling failed: $e');
'FFmpeg conversion process failed: $e, keeping M4A file',
);
} }
} }
} }
+100 -17
View File
@@ -20,7 +20,6 @@ class Extension {
final String name; final String name;
final String displayName; final String displayName;
final String version; final String version;
final String author;
final String description; final String description;
final bool enabled; final bool enabled;
final String status; final String status;
@@ -45,7 +44,6 @@ class Extension {
required this.name, required this.name,
required this.displayName, required this.displayName,
required this.version, required this.version,
required this.author,
required this.description, required this.description,
required this.enabled, required this.enabled,
required this.status, required this.status,
@@ -73,7 +71,6 @@ class Extension {
displayName: displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '', json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0', version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '', description: json['description'] as String? ?? '',
enabled: json['enabled'] as bool? ?? false, enabled: json['enabled'] as bool? ?? false,
status: json['status'] as String? ?? 'loaded', status: json['status'] as String? ?? 'loaded',
@@ -124,7 +121,6 @@ class Extension {
String? name, String? name,
String? displayName, String? displayName,
String? version, String? version,
String? author,
String? description, String? description,
bool? enabled, bool? enabled,
String? status, String? status,
@@ -149,7 +145,6 @@ class Extension {
name: name ?? this.name, name: name ?? this.name,
displayName: displayName ?? this.displayName, displayName: displayName ?? this.displayName,
version: version ?? this.version, version: version ?? this.version,
author: author ?? this.author,
description: description ?? this.description, description: description ?? this.description,
enabled: enabled ?? this.enabled, enabled: enabled ?? this.enabled,
status: status ?? this.status, status: status ?? this.status,
@@ -178,6 +173,12 @@ class Extension {
bool get hasPostProcessing => postProcessing?.enabled ?? false; bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasHomeFeed => capabilities['homeFeed'] == true; bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == true; bool get hasBrowseCategories => capabilities['browseCategories'] == true;
String? get preferredDownloadOutputExtension {
final value = capabilities['downloadOutputExtension'];
if (value is! String) return null;
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
} }
class SearchFilter { class SearchFilter {
@@ -481,8 +482,10 @@ class ExtensionState {
} }
class ExtensionNotifier extends Notifier<ExtensionState> { class ExtensionNotifier extends Notifier<ExtensionState> {
static const _builtInMetadataProviders = ['qobuz', 'tidal'];
AppLifecycleListener? _appLifecycleListener; AppLifecycleListener? _appLifecycleListener;
bool _cleanupInFlight = false; bool _cleanupInFlight = false;
Completer<void>? _initializationCompleter;
@override @override
ExtensionState build() { ExtensionState build() {
@@ -520,6 +523,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> initialize(String extensionsDir, String dataDir) async { Future<void> initialize(String extensionsDir, String dataDir) async {
if (state.isInitialized) return; if (state.isInitialized) return;
if (_initializationCompleter != null) {
await _initializationCompleter!.future;
return;
}
final completer = Completer<void>();
_initializationCompleter = completer;
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
@@ -531,6 +541,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
error: null, error: null,
); );
_log.i('Extension system disabled on this platform'); _log.i('Extension system disabled on this platform');
completer.complete();
_initializationCompleter = null;
return; return;
} }
@@ -544,6 +556,32 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} catch (e) { } catch (e) {
_log.e('Failed to initialize extension system: $e'); _log.e('Failed to initialize extension system: $e');
state = state.copyWith(isLoading: false, error: e.toString()); state = state.copyWith(isLoading: false, error: e.toString());
} finally {
if (!completer.isCompleted) {
completer.complete();
}
if (identical(_initializationCompleter, completer)) {
_initializationCompleter = null;
}
}
}
Future<void> waitForInitialization({
Duration timeout = const Duration(seconds: 30),
}) async {
if (state.isInitialized || !PlatformBridge.supportsExtensionSystem) {
return;
}
final future = _initializationCompleter?.future;
if (future == null) {
return;
}
try {
await future.timeout(timeout);
} on TimeoutException {
_log.w('Timed out waiting for extension initialization after $timeout');
} }
} }
@@ -566,6 +604,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
final list = await PlatformBridge.getInstalledExtensions(); final list = await PlatformBridge.getInstalledExtensions();
final extensions = list.map((e) => Extension.fromJson(e)).toList(); final extensions = list.map((e) => Extension.fromJson(e)).toList();
state = state.copyWith(extensions: extensions); state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
_log.d('Loaded ${extensions.length} extensions'); _log.d('Loaded ${extensions.length} extensions');
for (final ext in extensions) { for (final ext in extensions) {
@@ -661,6 +700,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}).toList(); }).toList();
state = state.copyWith(extensions: extensions); state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
if (!enabled && ext != null) { if (!enabled && ext != null) {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
@@ -685,6 +725,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
Future<void> _reconcileDownloadProviderPriority() async {
if (state.providerPriority.isEmpty) {
return;
}
final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority);
if (jsonEncode(sanitized) == jsonEncode(state.providerPriority)) {
return;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
await PlatformBridge.setProviderPriority(sanitized);
state = state.copyWith(providerPriority: sanitized);
_log.d('Reconciled provider priority after extension update: $sanitized');
}
Future<bool> ensureSpotifyWebExtensionReady({ Future<bool> ensureSpotifyWebExtensionReady({
bool setAsSearchProvider = true, bool setAsSearchProvider = true,
}) async { }) async {
@@ -812,6 +869,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> _sanitizeDownloadProviderPriority(List<String> input) { List<String> _sanitizeDownloadProviderPriority(List<String> input) {
final allowed = getAllDownloadProviders().toSet(); final allowed = getAllDownloadProviders().toSet();
final preferredOrder = getAllDownloadProviders();
final result = <String>[]; final result = <String>[];
for (final provider in input) { for (final provider in input) {
@@ -820,7 +878,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
for (final provider in const ['tidal', 'qobuz']) { for (final provider in preferredOrder) {
if (!result.contains(provider)) { if (!result.contains(provider)) {
result.add(provider); result.add(provider);
} }
@@ -847,10 +905,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
); );
await PlatformBridge.setMetadataProviderPriority(priority); await PlatformBridge.setMetadataProviderPriority(priority);
} else { } else {
priority = _sanitizeMetadataProviderPriority( final backendPriority =
await PlatformBridge.getMetadataProviderPriority(), await PlatformBridge.getMetadataProviderPriority();
); priority = _sanitizeMetadataProviderPriority(backendPriority);
_log.d('Using default metadata provider priority: $priority'); _log.d('Using default metadata provider priority: $priority');
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(priority),
);
await PlatformBridge.setMetadataProviderPriority(priority);
} }
state = state.copyWith(metadataProviderPriority: priority); state = state.copyWith(metadataProviderPriority: priority);
@@ -906,17 +969,26 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
List<String> getAllMetadataProviders() { List<String> getAllMetadataProviders() {
final providers = ['deezer', 'qobuz', 'tidal']; final metadataExtensions = state.extensions
for (final ext in state.extensions) { .where((ext) => ext.enabled && ext.hasMetadataProvider)
if (ext.enabled && ext.hasMetadataProvider) { .toList();
providers.add(ext.id); final primarySearchMetadataExtensions = metadataExtensions
} .where((ext) => ext.searchBehavior?.primary == true)
} .map((ext) => ext.id);
return providers; final otherMetadataExtensions = metadataExtensions
.where((ext) => ext.searchBehavior?.primary != true)
.map((ext) => ext.id);
return [
...primarySearchMetadataExtensions,
..._builtInMetadataProviders,
...otherMetadataExtensions,
];
} }
List<String> _sanitizeMetadataProviderPriority(List<String> input) { List<String> _sanitizeMetadataProviderPriority(List<String> input) {
final allowed = getAllMetadataProviders().toSet(); final allowed = getAllMetadataProviders().toSet();
final preferredOrder = getAllMetadataProviders();
final result = <String>[]; final result = <String>[];
for (final provider in input) { for (final provider in input) {
@@ -925,7 +997,18 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
for (final provider in const ['deezer', 'qobuz', 'tidal']) { final hasPreferredExtension = preferredOrder.any(
(provider) => !_builtInMetadataProviders.contains(provider),
);
final hasSavedExtension = result.any(
(provider) => !_builtInMetadataProviders.contains(provider),
);
if (!hasSavedExtension && hasPreferredExtension) {
return List<String>.from(preferredOrder);
}
for (final provider in preferredOrder) {
if (!result.contains(provider)) { if (!result.contains(provider)) {
result.add(provider); result.add(provider);
} }
+16
View File
@@ -18,6 +18,7 @@ final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> { class SettingsNotifier extends Notifier<AppSettings> {
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
static const Set<String> _searchTabValues = {'all', 'track', 'artist', 'album'};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance(); final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -42,11 +43,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
_sanitizeDownloadFallbackExtensionIds( _sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds, loaded.downloadFallbackExtensionIds,
); );
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
loaded.defaultSearchTab,
);
state = loaded.copyWith( state = loaded.copyWith(
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds, downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds: clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null && loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null, sanitizedDownloadFallbackExtensionIds == null,
defaultSearchTab: sanitizedDefaultSearchTab,
); );
await _runMigrations(prefs); await _runMigrations(prefs);
@@ -187,6 +192,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
return 'US'; return 'US';
} }
String _normalizeDefaultSearchTab(String value) {
final normalized = value.trim().toLowerCase();
if (_searchTabValues.contains(normalized)) return normalized;
return 'all';
}
Future<void> _normalizeSongLinkRegionIfNeeded() async { Future<void> _normalizeSongLinkRegionIfNeeded() async {
final normalized = _normalizeSongLinkRegion(state.songLinkRegion); final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
if (normalized == state.songLinkRegion) return; if (normalized == state.songLinkRegion) return;
@@ -408,6 +419,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setDefaultSearchTab(String tab) {
state = state.copyWith(defaultSearchTab: _normalizeDefaultSearchTab(tab));
_saveSettings();
}
void setHomeFeedProvider(String? provider) { void setHomeFeedProvider(String? provider) {
if (provider == null || provider.isEmpty) { if (provider == null || provider.isEmpty) {
state = state.copyWith(clearHomeFeedProvider: true); state = state.copyWith(clearHomeFeedProvider: true);
-4
View File
@@ -63,7 +63,6 @@ class StoreExtension {
final String name; final String name;
final String displayName; final String displayName;
final String version; final String version;
final String author;
final String description; final String description;
final String downloadUrl; final String downloadUrl;
final String? iconUrl; final String? iconUrl;
@@ -81,7 +80,6 @@ class StoreExtension {
required this.name, required this.name,
required this.displayName, required this.displayName,
required this.version, required this.version,
required this.author,
required this.description, required this.description,
required this.downloadUrl, required this.downloadUrl,
this.iconUrl, this.iconUrl,
@@ -102,7 +100,6 @@ class StoreExtension {
displayName: displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '', json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0', version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '', description: json['description'] as String? ?? '',
downloadUrl: json['download_url'] as String? ?? '', downloadUrl: json['download_url'] as String? ?? '',
iconUrl: json['icon_url'] as String?, iconUrl: json['icon_url'] as String?,
@@ -194,7 +191,6 @@ class StoreState {
e.name.toLowerCase().contains(query) || e.name.toLowerCase().contains(query) ||
e.displayName.toLowerCase().contains(query) || e.displayName.toLowerCase().contains(query) ||
e.description.toLowerCase().contains(query) || e.description.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query)), e.tags.any((t) => t.toLowerCase().contains(query)),
) )
.toList(); .toList();
+138 -36
View File
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('TrackProvider'); final _log = AppLogger('TrackProvider');
const _extensionInitRetryTimeout = Duration(seconds: 30);
class TrackState { class TrackState {
final List<Track> tracks; final List<Track> tracks;
@@ -203,13 +204,36 @@ class TrackNotifier extends Notifier<TrackState> {
bool _isRequestValid(int requestId) => requestId == _currentRequestId; bool _isRequestValid(int requestId) => requestId == _currentRequestId;
bool _usesBuiltInUrlResolver(String url) {
final normalized = url.toLowerCase();
return normalized.contains('deezer.com') ||
normalized.contains('deezer.page.link') ||
normalized.contains('qobuz.com') ||
normalized.startsWith('qobuzapp://') ||
normalized.contains('tidal.com');
}
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async { Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try { try {
final extensionHandler = await PlatformBridge.findURLHandler(url); var extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler == null && !_usesBuiltInUrlResolver(url)) {
final extensionState = ref.read(extensionProvider);
if (!extensionState.isInitialized && extensionState.isLoading) {
_log.i(
'Extension URL handlers not ready yet, waiting for initialization...',
);
await ref
.read(extensionProvider.notifier)
.waitForInitialization(timeout: _extensionInitRetryTimeout);
if (!_isRequestValid(requestId)) return;
extensionHandler = await PlatformBridge.findURLHandler(url);
}
}
if (extensionHandler != null) { if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url'); _log.i('Found extension URL handler: $extensionHandler for URL: $url');
@@ -559,8 +583,98 @@ class TrackNotifier extends Notifier<TrackState> {
String? builtInSearchProvider, String? builtInSearchProvider,
}) async { }) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
final currentFilter = filterOverride ?? state.selectedSearchFilter; final currentFilter = filterOverride ?? state.selectedSearchFilter;
final requestFilter = currentFilter == 'all' ? null : currentFilter;
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
String? resolvedProvider = builtInSearchProvider;
if (resolvedProvider == null || resolvedProvider.isEmpty) {
final explicitProvider = settings.searchProvider?.trim();
if (explicitProvider != null && explicitProvider.isNotEmpty) {
resolvedProvider = explicitProvider;
} else {
resolvedProvider =
extensionState.extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.map((ext) => ext.id)
.firstOrNull ??
extensionState.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.map((ext) => ext.id)
.firstOrNull;
}
}
final isEnabledExtensionProvider =
resolvedProvider != null &&
resolvedProvider.isNotEmpty &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.id == resolvedProvider,
);
if (resolvedProvider != null &&
resolvedProvider.isNotEmpty &&
resolvedProvider != 'tidal' &&
resolvedProvider != 'qobuz' &&
!isEnabledExtensionProvider &&
settings.searchProvider?.trim() == resolvedProvider) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
resolvedProvider =
extensionState.extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.map((ext) => ext.id)
.firstOrNull ??
extensionState.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.map((ext) => ext.id)
.firstOrNull;
}
if (resolvedProvider != null &&
resolvedProvider.isNotEmpty &&
resolvedProvider != 'tidal' &&
resolvedProvider != 'qobuz' &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.id == resolvedProvider,
)) {
final resolvedFilter = requestFilter ?? 'track';
Map<String, dynamic>? options;
options = {'filter': resolvedFilter};
await customSearch(
resolvedProvider,
query,
options: options,
selectedFilter: resolvedFilter,
);
return;
}
final effectiveBuiltInProvider =
resolvedProvider == 'tidal' || resolvedProvider == 'qobuz'
? resolvedProvider
: builtInSearchProvider;
if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) {
state = TrackState(
isLoading: false,
error: 'No active search provider available',
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
);
return;
}
state = TrackState( state = TrackState(
isLoading: true, isLoading: true,
@@ -570,42 +684,21 @@ class TrackNotifier extends Notifier<TrackState> {
); );
try { try {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final hasActiveMetadataExtensions = extensionState.extensions.any( final hasActiveMetadataExtensions = extensionState.extensions.any(
(e) => e.enabled && e.hasMetadataProvider, (e) => e.enabled && e.hasMetadataProvider,
); );
final includeExtensions = final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions; settings.useExtensionProviders && hasActiveMetadataExtensions;
final effectiveProvider = builtInSearchProvider ?? 'deezer'; final effectiveProvider = effectiveBuiltInProvider;
_log.i( _log.i(
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter', 'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter',
); );
Map<String, dynamic> results; Map<String, dynamic> results;
List<Map<String, dynamic>> metadataTrackResults = []; List<Map<String, dynamic>> metadataTrackResults = [];
if (effectiveProvider == 'deezer') {
try {
_log.d('Calling metadata provider search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
_log.i(
'Metadata providers returned ${metadataTrackResults.length} tracks',
);
} catch (e) {
_log.w(
'Metadata provider search failed, falling back to Deezer tracks: $e',
);
}
}
switch (effectiveProvider) { switch (effectiveProvider) {
case 'tidal': case 'tidal':
_log.d('Calling Tidal search API...'); _log.d('Calling Tidal search API...');
@@ -613,7 +706,7 @@ class TrackNotifier extends Notifier<TrackState> {
query, query,
trackLimit: 20, trackLimit: 20,
artistLimit: 2, artistLimit: 2,
filter: currentFilter, filter: requestFilter,
); );
break; break;
case 'qobuz': case 'qobuz':
@@ -622,17 +715,23 @@ class TrackNotifier extends Notifier<TrackState> {
query, query,
trackLimit: 20, trackLimit: 20,
artistLimit: 2, artistLimit: 2,
filter: currentFilter, filter: requestFilter,
); );
break; break;
default: default:
_log.d('Calling Deezer search API...'); _log.d('Calling metadata provider track search API...');
results = await PlatformBridge.searchDeezerAll( metadataTrackResults =
query, await PlatformBridge.searchTracksWithMetadataProviders(
trackLimit: 20, query,
artistLimit: 2, limit: 20,
filter: currentFilter, includeExtensions: includeExtensions,
); );
results = const <String, List<dynamic>>{
'tracks': <dynamic>[],
'artists': <dynamic>[],
'albums': <dynamic>[],
'playlists': <dynamic>[],
};
break; break;
} }
_log.i( _log.i(
@@ -741,14 +840,16 @@ class TrackNotifier extends Notifier<TrackState> {
String extensionId, String extensionId,
String query, { String query, {
Map<String, dynamic>? options, Map<String, dynamic>? options,
String? selectedFilter,
}) async { }) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
final currentFilter = selectedFilter ?? state.selectedSearchFilter;
state = TrackState( state = TrackState(
isLoading: true, isLoading: true,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: state.selectedSearchFilter, selectedSearchFilter: currentFilter,
); );
try { try {
@@ -788,7 +889,7 @@ class TrackNotifier extends Notifier<TrackState> {
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
searchExtensionId: extensionId, searchExtensionId: extensionId,
selectedSearchFilter: state.selectedSearchFilter, selectedSearchFilter: currentFilter,
); );
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
@@ -798,6 +899,7 @@ class TrackNotifier extends Notifier<TrackState> {
error: e.toString(), error: e.toString(),
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
); );
} }
} }
+287 -34
View File
@@ -426,14 +426,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
String? currentSearchProvider, String? currentSearchProvider,
List<Extension> extensions, List<Extension> extensions,
) { ) {
final resolvedSearchProvider = _resolveSearchProvider(
currentSearchProvider,
extensions,
);
final isUsingExtensionSearch = final isUsingExtensionSearch =
currentSearchProvider != null && resolvedSearchProvider != null &&
currentSearchProvider.isNotEmpty && resolvedSearchProvider.isNotEmpty &&
extensions.any((e) => e.id == currentSearchProvider && e.enabled); extensions.any((e) => e.id == resolvedSearchProvider && e.enabled);
if (isUsingExtensionSearch) { if (isUsingExtensionSearch) {
final currentSearchExtension = extensions final currentSearchExtension = extensions
.where((e) => e.id == currentSearchProvider && e.enabled) .where((e) => e.id == resolvedSearchProvider && e.enabled)
.firstOrNull; .firstOrNull;
final filters = currentSearchExtension?.searchBehavior?.filters; final filters = currentSearchExtension?.searchBehavior?.filters;
if (filters != null && filters.isNotEmpty) { if (filters != null && filters.isNotEmpty) {
@@ -449,6 +453,154 @@ class _HomeTabState extends ConsumerState<HomeTab>
]; ];
} }
Extension? _defaultSearchExtension(List<Extension> extensions) {
return extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.firstOrNull ??
extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.firstOrNull;
}
String? _resolveSearchProvider(
String? explicitSearchProvider,
List<Extension> extensions,
) {
final explicit = explicitSearchProvider?.trim();
if (explicit != null &&
explicit.isNotEmpty &&
(_builtInSearchProviders.contains(explicit) ||
extensions.any(
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
))) {
return explicit;
}
return _defaultSearchExtension(extensions)?.id;
}
String? _sanitizeSearchFilterForProvider(
String? filter,
String? currentSearchProvider,
List<Extension> extensions,
) {
if (filter == null || filter.isEmpty) {
return null;
}
final canonicalFilter = _canonicalSearchFilterId(filter);
if (currentSearchProvider == null ||
currentSearchProvider.isEmpty ||
_builtInSearchProviders.contains(currentSearchProvider)) {
switch (canonicalFilter) {
case 'track':
case 'artist':
case 'album':
case 'playlist':
return canonicalFilter;
default:
return null;
}
}
final extension = extensions
.where((e) => e.id == currentSearchProvider && e.enabled)
.firstOrNull;
final filters = extension?.searchBehavior?.filters;
if (filters == null || filters.isEmpty) {
return null;
}
final match = filters
.where(
(candidate) =>
_canonicalSearchFilterId(candidate.id) == canonicalFilter ||
(candidate.label != null &&
_canonicalSearchFilterId(candidate.label!) ==
canonicalFilter) ||
(candidate.icon != null &&
_canonicalSearchFilterId(candidate.icon!) ==
canonicalFilter),
)
.firstOrNull;
return match?.id;
}
String _canonicalSearchFilterId(String value) {
final normalized = value.trim().toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]+'),
'',
);
switch (normalized) {
case 'track':
case 'tracks':
case 'song':
case 'songs':
case 'music':
return 'track';
case 'artist':
case 'artists':
return 'artist';
case 'album':
case 'albums':
return 'album';
case 'playlist':
case 'playlists':
return 'playlist';
default:
return normalized;
}
}
String? _preferredSearchFilter(
String preferredSearchTab,
String? currentSearchProvider,
List<Extension> extensions,
) {
final preferred = switch (preferredSearchTab) {
'track' => 'track',
'artist' => 'artist',
'album' => 'album',
_ => null,
};
return _sanitizeSearchFilterForProvider(
preferred,
currentSearchProvider,
extensions,
);
}
String _displaySearchFilterSelection(
String? selectedSearchFilter,
String preferredSearchTab,
String? currentSearchProvider,
List<Extension> extensions,
) {
if (selectedSearchFilter == 'all') {
return 'all';
}
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
return _sanitizeSearchFilterForProvider(
selectedSearchFilter,
currentSearchProvider,
extensions,
) ??
'all';
}
return _preferredSearchFilter(
preferredSearchTab,
currentSearchProvider,
extensions,
) ??
'all';
}
_SearchResultBuckets _getSearchResultBuckets(List<Track> tracks) { _SearchResultBuckets _getSearchResultBuckets(List<Track> tracks) {
final cached = _searchBucketsCache; final cached = _searchBucketsCache;
if (cached != null && identical(tracks, _searchBucketsSourceTracks)) { if (cached != null && identical(tracks, _searchBucketsSourceTracks)) {
@@ -530,7 +682,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
bool _isLiveSearchEnabled() { bool _isLiveSearchEnabled() {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider); final extState = ref.read(extensionProvider);
final searchProvider = settings.searchProvider; final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
);
if (searchProvider == null || searchProvider.isEmpty) return false; if (searchProvider == null || searchProvider.isEmpty) return false;
@@ -599,9 +754,32 @@ class _HomeTabState extends ConsumerState<HomeTab>
Future<void> _performSearch(String query, {String? filterOverride}) async { Future<void> _performSearch(String query, {String? filterOverride}) async {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider); final extState = ref.read(extensionProvider);
final searchProvider = settings.searchProvider; final searchProvider = _resolveSearchProvider(
final selectedFilter = settings.searchProvider,
filterOverride ?? ref.read(trackProvider).selectedSearchFilter; extState.extensions,
);
final storedFilter = ref.read(trackProvider).selectedSearchFilter;
final selectedFilter = switch (filterOverride) {
'all' => null,
final explicit? => _sanitizeSearchFilterForProvider(
explicit,
searchProvider,
extState.extensions,
),
null => switch (storedFilter) {
'all' => null,
final stored? => _sanitizeSearchFilterForProvider(
stored,
searchProvider,
extState.extensions,
),
null => _preferredSearchFilter(
settings.defaultSearchTab,
searchProvider,
extState.extensions,
),
},
};
final searchKey = final searchKey =
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}'; '${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
@@ -627,7 +805,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
} }
await ref await ref
.read(trackProvider.notifier) .read(trackProvider.notifier)
.customSearch(searchProvider, query, options: options); .customSearch(
searchProvider,
query,
options: options,
selectedFilter: selectedFilter,
);
} else if (isBuiltInProvider) { } else if (isBuiltInProvider) {
await ref await ref
.read(trackProvider.notifier) .read(trackProvider.notifier)
@@ -1062,6 +1245,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
final hasSearchedBefore = ref.watch( final hasSearchedBefore = ref.watch(
settingsProvider.select((s) => s.hasSearchedBefore), settingsProvider.select((s) => s.hasSearchedBefore),
); );
final defaultSearchTab = ref.watch(
settingsProvider.select((s) => s.defaultSearchTab),
);
final hasExploreContent = ref.watch( final hasExploreContent = ref.watch(
exploreProvider.select((s) => s.sections.isNotEmpty), exploreProvider.select((s) => s.sections.isNotEmpty),
@@ -1103,6 +1289,29 @@ class _HomeTabState extends ConsumerState<HomeTab>
(hasHomeFeedExtension || hasExploreContent) && (hasHomeFeedExtension || hasExploreContent) &&
hasExploreContent; hasExploreContent;
ref.listen<String>(
settingsProvider.select((s) => s.defaultSearchTab),
(previous, next) {
if (previous == next) return;
final selectedSearchFilter = ref.read(
trackProvider.select((s) => s.selectedSearchFilter),
);
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
return;
}
final text = _urlController.text.trim();
if (text.isEmpty || text.length < _minLiveSearchChars) return;
if (text.startsWith('http') || text.startsWith('spotify:')) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_lastSearchQuery = null;
_performSearch(text);
});
},
);
if (hasActualResults && if (hasActualResults &&
isShowingRecentAccess && isShowingRecentAccess &&
hasSearchInput && hasSearchInput &&
@@ -1246,7 +1455,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: _buildSearchFilterBar( child: _buildSearchFilterBar(
searchFilters, searchFilters,
selectedSearchFilter, _displaySearchFilterSelection(
selectedSearchFilter,
defaultSearchTab,
currentSearchProvider,
extensions,
),
colorScheme, colorScheme,
), ),
); );
@@ -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) { void _navigateToRecentItem(RecentAccessItem item) {
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
switch (item.type) { switch (item.type) {
case RecentAccessType.artist: case RecentAccessType.artist:
if (item.providerId != null && if (_isEnabledMetadataExtension(item.providerId)) {
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute<void>( MaterialPageRoute<void>(
@@ -2139,12 +2361,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
), ),
), ),
); );
} else if (item.providerId != null && } else if (_isEnabledMetadataExtension(item.providerId)) {
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute<void>( MaterialPageRoute<void>(
@@ -2189,12 +2406,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return; return;
} }
if (item.providerId != null && if (_isEnabledMetadataExtension(item.providerId)) {
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute<void>( MaterialPageRoute<void>(
@@ -3111,8 +3323,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
String _getSearchHint() { String _getSearchHint() {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final searchProvider = settings.searchProvider;
final extState = ref.read(extensionProvider); final extState = ref.read(extensionProvider);
final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
);
if (!extState.isInitialized) { if (!extState.isInitialized) {
return 'Paste supported URL or search...'; return 'Paste supported URL or search...';
@@ -3154,10 +3369,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: FilterChip( child: FilterChip(
label: Text(context.l10n.historyFilterAll), label: Text(context.l10n.historyFilterAll),
selected: selectedFilter == null, selected: selectedFilter == 'all',
onSelected: (_) { onSelected: (_) {
ref.read(trackProvider.notifier).setSearchFilter(null); ref.read(trackProvider.notifier).setSearchFilter('all');
_triggerSearchWithFilter(null); _triggerSearchWithFilter('all');
}, },
showCheckmark: false, showCheckmark: false,
), ),
@@ -3313,9 +3528,23 @@ class _SearchProviderDropdown extends ConsumerWidget {
const _SearchProviderDropdown({this.onProviderChanged}); const _SearchProviderDropdown({this.onProviderChanged});
Extension? _defaultSearchExtension(List<Extension> extensions) {
return extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.firstOrNull ??
extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.firstOrNull;
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentProvider = ref.watch( final rawCurrentProvider = ref.watch(
settingsProvider.select((s) => s.searchProvider), settingsProvider.select((s) => s.searchProvider),
); );
final extensions = ref.watch(extensionProvider.select((s) => s.extensions)); final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
@@ -3324,6 +3553,17 @@ class _SearchProviderDropdown extends ConsumerWidget {
final searchProviders = extensions final searchProviders = extensions
.where((ext) => ext.enabled && ext.hasCustomSearch) .where((ext) => ext.enabled && ext.hasCustomSearch)
.toList(); .toList();
final primarySearchExtension = _defaultSearchExtension(searchProviders);
final defaultProviderLabel =
primarySearchExtension?.displayName ?? 'Deezer';
final defaultProviderIconPath = primarySearchExtension?.iconPath;
final currentProvider =
rawCurrentProvider != null &&
rawCurrentProvider.isNotEmpty &&
({'tidal', 'qobuz'}.contains(rawCurrentProvider) ||
searchProviders.any((e) => e.id == rawCurrentProvider))
? rawCurrentProvider
: null;
Extension? currentExt; Extension? currentExt;
if (currentProvider != null && currentProvider.isNotEmpty) { if (currentProvider != null && currentProvider.isNotEmpty) {
@@ -3343,6 +3583,19 @@ class _SearchProviderDropdown extends ConsumerWidget {
if (currentExt.searchBehavior?.icon != null) { if (currentExt.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
} }
} else if (primarySearchExtension?.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(
primarySearchExtension!.searchBehavior!.icon!,
);
iconPath = defaultProviderIconPath;
} else if (defaultProviderIconPath != null &&
defaultProviderIconPath.isNotEmpty) {
iconPath = defaultProviderIconPath;
if (primarySearchExtension?.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(
primarySearchExtension!.searchBehavior!.icon!,
);
}
} else if (isBuiltInProvider) { } else if (isBuiltInProvider) {
displayIcon = Icons.music_note; displayIcon = Icons.music_note;
} }
@@ -3397,7 +3650,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
'Deezer', defaultProviderLabel,
style: TextStyle( style: TextStyle(
fontWeight: fontWeight:
currentProvider == null || currentProvider.isEmpty currentProvider == null || currentProvider.isEmpty
-15
View File
@@ -7,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart';
@@ -94,20 +93,6 @@ class _MainShellState extends ConsumerState<MainShell>
} }
Future<void> _handleSharedUrl(String url) async { Future<void> _handleSharedUrl(String url) async {
// Wait for extensions to be initialized before handling URL
final extState = ref.read(extensionProvider);
if (!extState.isInitialized) {
_log.d('Waiting for extensions to initialize before handling URL...');
for (int i = 0; i < 50; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
if (!mounted) return;
if (ref.read(extensionProvider).isInitialized) {
_log.d('Extensions initialized, proceeding with URL handling');
break;
}
}
}
if (!mounted) return; if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
-7
View File
@@ -789,13 +789,6 @@ class _ExtensionItem extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 2),
Text(
'by ${extension.author}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (extension.requiresNewerApp) ...[ if (extension.requiresNewerApp) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Container( Container(
+7
View File
@@ -182,6 +182,13 @@ class AboutPage extends StatelessWidget {
onTap: () => _launchUrl(AppInfo.originalGithubUrl), onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true, showDivider: true,
), ),
_AboutSettingsItem(
icon: Icons.campaign_outlined,
title: context.l10n.aboutKeepAndroidOpen,
subtitle: 'keepandroidopen.org',
onTap: () => _launchUrl('https://keepandroidopen.org/'),
showDivider: true,
),
_AboutSettingsItem( _AboutSettingsItem(
icon: Icons.bug_report_outlined, icon: Icons.bug_report_outlined,
title: context.l10n.aboutReportIssue, title: context.l10n.aboutReportIssue,
+9 -1
View File
@@ -164,7 +164,15 @@ class _RecentDonorsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; 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 // Match SettingsGroup color logic
final cardColor = isDark final cardColor = isDark
+105 -14
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:url_launcher/url_launcher.dart';
class ExtensionDetailPage extends ConsumerStatefulWidget { class ExtensionDetailPage extends ConsumerStatefulWidget {
final String extensionId; final String extensionId;
@@ -49,7 +50,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
name: '', name: '',
displayName: 'Unknown', displayName: 'Unknown',
version: '0.0.0', version: '0.0.0',
author: 'Unknown',
description: '', description: '',
enabled: false, enabled: false,
status: 'error', status: 'error',
@@ -205,10 +205,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
), ),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
_InfoRow(
label: context.l10n.extensionAuthor,
value: extension.author,
),
_InfoRow( _InfoRow(
label: context.l10n.extensionId, label: context.l10n.extensionId,
value: extension.id, value: extension.id,
@@ -404,6 +400,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
onChanged: (value) => onChanged: (value) =>
_updateSetting(setting.key, value), _updateSetting(setting.key, value),
extensionId: widget.extensionId, extensionId: widget.extensionId,
onActionPayload: _handleExtensionActionPayload,
); );
}).toList(), }).toList(),
), ),
@@ -445,6 +442,27 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
.setExtensionSettings(widget.extensionId, _settings); .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 { Future<void> _confirmRemove(BuildContext context) async {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>( 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 { class _InfoRow extends StatelessWidget {
final String label; final String label;
final String value; final String value;
@@ -645,12 +698,14 @@ class _SettingItem extends StatefulWidget {
final bool showDivider; final bool showDivider;
final ValueChanged<dynamic> onChanged; final ValueChanged<dynamic> onChanged;
final String extensionId; final String extensionId;
final Future<void> Function(Map<String, dynamic> payload)? onActionPayload;
const _SettingItem({ const _SettingItem({
required this.setting, required this.setting,
required this.value, required this.value,
required this.onChanged, required this.onChanged,
required this.extensionId, required this.extensionId,
this.onActionPayload,
this.showDivider = true, this.showDivider = true,
}); });
@@ -772,11 +827,17 @@ class _SettingItemState extends State<_SettingItem> {
if (widget.setting.type == 'string' || if (widget.setting.type == 'string' ||
widget.setting.type == 'number') ...[ widget.setting.type == 'number') ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( if (widget.setting.key == 'oauth_login_url')
widget.value?.toString() ?? 'Not set', _OauthLoginLinkPreview(
style: Theme.of(context).textTheme.bodySmall value: widget.value?.toString(),
?.copyWith(color: colorScheme.primary), 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) { 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) { if (!success) {
final error = result['error'] as String? ?? 'Action failed'; final error =
payload['error'] as String? ??
result['error'] as String? ??
'Action failed';
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(error))); ).showSnackBar(SnackBar(content: Text(error)));
} else { } else {
final message = result['message'] as String?; if (widget.onActionPayload != null) {
if (message != 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( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(message))); ).showSnackBar(SnackBar(content: Text(message)));
+1 -1
View File
@@ -425,7 +425,7 @@ class _ExtensionItem extends StatelessWidget {
hasError hasError
? extension.errorMessage ?? ? extension.errorMessage ??
context.l10n.extensionsErrorLoading context.l10n.extensionsErrorLoading
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}', : 'v${extension.version}',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasError color: hasError
? colorScheme.error ? colorScheme.error
@@ -225,8 +225,8 @@ class _MetadataProviderItem extends StatelessWidget {
return _MetadataProviderInfo( return _MetadataProviderInfo(
name: 'Deezer', name: 'Deezer',
icon: Icons.album, icon: Icons.album,
description: context.l10n.metadataNoRateLimits, description: context.l10n.providerExtension,
isBuiltIn: true, isBuiltIn: false,
); );
case 'qobuz': case 'qobuz':
return _MetadataProviderInfo( return _MetadataProviderInfo(
+176 -58
View File
@@ -70,7 +70,12 @@ class OptionsSettingsPage extends ConsumerWidget {
), ),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup(children: [const _MetadataSourceSelector()]), child: SettingsGroup(
children: const [
_MetadataSourceSelector(),
_DefaultSearchTabSelector(),
],
),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -714,13 +719,39 @@ class _MetadataSourceSelector extends ConsumerWidget {
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'}; 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider); 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); final isBuiltIn = _builtInProviders.containsKey(searchProvider);
Extension? activeExtension; Extension? activeExtension;
@@ -765,37 +796,45 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
children: [ children: [
_SourceChip( Expanded(
icon: Icons.graphic_eq, child: _SourceChip(
label: 'Deezer', icon: Icons.graphic_eq,
isSelected: searchProvider.isEmpty, label: defaultProviderLabel,
onTap: () { isSelected: searchProvider.isEmpty,
if (hasNonDefaultProvider) { onTap: () {
ref.read(settingsProvider.notifier).setSearchProvider(null); if (hasNonDefaultProvider) {
} ref
}, .read(settingsProvider.notifier)
.setSearchProvider(null);
}
},
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
_SourceChip( Expanded(
icon: Icons.waves, child: _SourceChip(
label: 'Tidal', icon: Icons.waves,
isSelected: searchProvider == 'tidal', label: 'Tidal',
onTap: () { isSelected: searchProvider == 'tidal',
ref onTap: () {
.read(settingsProvider.notifier) ref
.setSearchProvider('tidal'); .read(settingsProvider.notifier)
}, .setSearchProvider('tidal');
},
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
_SourceChip( Expanded(
icon: Icons.album, child: _SourceChip(
label: 'Qobuz', icon: Icons.album,
isSelected: searchProvider == 'qobuz', label: 'Qobuz',
onTap: () { isSelected: searchProvider == 'qobuz',
ref onTap: () {
.read(settingsProvider.notifier) ref
.setSearchProvider('qobuz'); .read(settingsProvider.notifier)
}, .setSearchProvider('qobuz');
},
),
), ),
], ],
), ),
@@ -811,7 +850,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'Tap Deezer to switch back from extension', 'Tap $defaultProviderLabel to switch back from extension',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, 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 { class _SourceChip extends StatelessWidget {
final IconData icon; final IconData icon;
final String label; final String label;
@@ -851,39 +972,36 @@ class _SourceChip extends StatelessWidget {
) )
: colorScheme.surfaceContainerHigh; : colorScheme.surfaceContainerHigh;
return Expanded( return Material(
child: Material( color: isSelected ? colorScheme.primaryContainer : unselectedColor,
color: isSelected ? colorScheme.primaryContainer : unselectedColor, borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: InkWell( child: Padding(
onTap: onTap, padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
borderRadius: BorderRadius.circular(12), child: Column(
child: Padding( mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.symmetric(vertical: 14), children: [
child: Column( Icon(
children: [ icon,
Icon( size: 28,
icon, color: isSelected
size: 28, ? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected color: isSelected
? colorScheme.onPrimaryContainer ? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 6), ),
Text( ],
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
), ),
), ),
), ),
+18 -1
View File
@@ -10,6 +10,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('SetupScreen');
class SetupScreen extends ConsumerStatefulWidget { class SetupScreen extends ConsumerStatefulWidget {
const SetupScreen({super.key}); const SetupScreen({super.key});
@@ -233,7 +236,21 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (Platform.isIOS) { if (Platform.isIOS) {
await _showIOSDirectoryOptions(); await _showIOSDirectoryOptions();
} else if (Platform.isAndroid) { } 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) { if (result != null) {
final treeUri = result['tree_uri'] as String? ?? ''; final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? ''; final displayName = result['display_name'] as String? ?? '';
@@ -171,12 +171,6 @@ class _ExtensionDetailsScreenState
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 4),
Text(
context.l10n.extensionsAuthor(ext.author),
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: colorScheme.onSurfaceVariant),
),
], ],
), ),
), ),
+123 -40
View File
@@ -4270,8 +4270,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'copyright': val('copyright', copyright), 'copyright': val('copyright', copyright),
'composer': val('composer', composer), 'composer': val('composer', composer),
'comment': fileMetadata?['comment']?.toString() ?? '', 'comment': fileMetadata?['comment']?.toString() ?? '',
'lyrics': fileMetadata?['lyrics']?.toString() ?? '',
}; };
final initialDurationSeconds =
_readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0;
if (!context.mounted) return; if (!context.mounted) return;
final saved = await showModalBottomSheet<bool>( final saved = await showModalBottomSheet<bool>(
@@ -4287,6 +4291,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
initialValues: initialValues, initialValues: initialValues,
filePath: cleanFilePath, filePath: cleanFilePath,
sourceTrackId: _spotifyId, sourceTrackId: _spotifyId,
durationMs: initialDurationSeconds > 0
? initialDurationSeconds * 1000
: 0,
artistTagMode: ref.read(settingsProvider).artistTagMode, artistTagMode: ref.read(settingsProvider).artistTagMode,
), ),
); );
@@ -4297,7 +4304,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
); );
try { try {
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath); 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 (_) { } catch (_) {
setState(() {}); setState(() {});
} }
@@ -4514,6 +4538,7 @@ class _EditMetadataSheet extends StatefulWidget {
final Map<String, String> initialValues; final Map<String, String> initialValues;
final String filePath; final String filePath;
final String? sourceTrackId; final String? sourceTrackId;
final int durationMs;
final String artistTagMode; final String artistTagMode;
const _EditMetadataSheet({ const _EditMetadataSheet({
@@ -4521,6 +4546,7 @@ class _EditMetadataSheet extends StatefulWidget {
required this.initialValues, required this.initialValues,
required this.filePath, required this.filePath,
this.sourceTrackId, this.sourceTrackId,
required this.durationMs,
required this.artistTagMode, required this.artistTagMode,
}); });
@@ -4560,6 +4586,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
'total_discs': 'total_discs', 'total_discs': 'total_discs',
'genre': 'genre', 'genre': 'genre',
'isrc': 'isrc', 'isrc': 'isrc',
'lyrics': 'lyrics',
'label': 'label', 'label': 'label',
'copyright': 'copyright', 'copyright': 'copyright',
'composer': 'composer', 'composer': 'composer',
@@ -4577,6 +4604,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
late final TextEditingController _discTotalCtrl; late final TextEditingController _discTotalCtrl;
late final TextEditingController _genreCtrl; late final TextEditingController _genreCtrl;
late final TextEditingController _isrcCtrl; late final TextEditingController _isrcCtrl;
late final TextEditingController _lyricsCtrl;
late final TextEditingController _labelCtrl; late final TextEditingController _labelCtrl;
late final TextEditingController _copyrightCtrl; late final TextEditingController _copyrightCtrl;
late final TextEditingController _composerCtrl; late final TextEditingController _composerCtrl;
@@ -4772,6 +4800,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return l10n.editMetadataFieldGenre; return l10n.editMetadataFieldGenre;
case 'isrc': case 'isrc':
return l10n.editMetadataFieldIsrc; return l10n.editMetadataFieldIsrc;
case 'lyrics':
return l10n.trackLyrics;
case 'label': case 'label':
return l10n.editMetadataFieldLabel; return l10n.editMetadataFieldLabel;
case 'copyright': case 'copyright':
@@ -4809,6 +4839,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return _genreCtrl; return _genreCtrl;
case 'isrc': case 'isrc':
return _isrcCtrl; return _isrcCtrl;
case 'lyrics':
return _lyricsCtrl;
case 'label': case 'label':
return _labelCtrl; return _labelCtrl;
case 'copyright': case 'copyright':
@@ -5107,19 +5139,23 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final artist = _artistCtrl.text.trim(); final artist = _artistCtrl.text.trim();
final album = _albumCtrl.text.trim(); final album = _albumCtrl.text.trim();
final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); final currentIsrc = _isrcCtrl.text.trim().toUpperCase();
final shouldFetchLyrics = _autoFillFields.contains('lyrics');
final needsTrackLookup = _autoFillFields.any((key) => key != 'lyrics');
Map<String, dynamic>? best; Map<String, dynamic>? best;
String? deezerId; String? deezerId;
try { if (needsTrackLookup) {
final resolved = await _resolveAutoFillTrackFromIdentifiers( try {
currentIsrc, final resolved = await _resolveAutoFillTrackFromIdentifiers(
); currentIsrc,
if (resolved != null) { );
best = resolved.track; if (resolved != null) {
deezerId = resolved.deezerId; 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>[]; final queryParts = <String>[];
@@ -5127,7 +5163,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (artist.isNotEmpty) queryParts.add(artist); if (artist.isNotEmpty) queryParts.add(artist);
if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album); if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album);
if (best == null && queryParts.isEmpty) { if (needsTrackLookup && best == null && queryParts.isEmpty) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)),
@@ -5140,7 +5176,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final normalizedArtist = _normalizeMetadataText(artist); final normalizedArtist = _normalizeMetadataText(artist);
final normalizedAlbum = _normalizeMetadataText(album); final normalizedAlbum = _normalizeMetadataText(album);
if (best == null) { if (needsTrackLookup && best == null) {
final query = queryParts.join(' '); final query = queryParts.join(' ');
final results = await PlatformBridge.searchTracksWithMetadataProviders( final results = await PlatformBridge.searchTracksWithMetadataProviders(
query, query,
@@ -5175,39 +5211,47 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
} }
final selectedBest = best; final selectedBest = best;
if (selectedBest == null) { if (needsTrackLookup && selectedBest == null) {
throw StateError('No metadata match resolved for auto-fill'); throw StateError('No metadata match resolved for auto-fill');
} }
final enriched = <String, String>{ final enriched = <String, String>{};
'title': (selectedBest['name'] ?? '').toString(), if (selectedBest != null) {
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '') enriched.addAll(<String, String>{
.toString(), 'title': (selectedBest['name'] ?? '').toString(),
'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '') 'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
.toString(), .toString(),
'album_artist': (selectedBest['album_artist'] ?? '').toString(), 'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '')
'date': (selectedBest['release_date'] ?? '').toString(), .toString(),
'track_number': (selectedBest['track_number'] ?? '').toString(), 'album_artist': (selectedBest['album_artist'] ?? '').toString(),
'total_tracks': (selectedBest['total_tracks'] ?? '').toString(), 'date': (selectedBest['release_date'] ?? '').toString(),
'disc_number': (selectedBest['disc_number'] ?? '').toString(), 'track_number': (selectedBest['track_number'] ?? '').toString(),
'total_discs': (selectedBest['total_discs'] ?? '').toString(), 'total_tracks': (selectedBest['total_tracks'] ?? '').toString(),
'isrc': (selectedBest['isrc'] ?? '').toString(), 'disc_number': (selectedBest['disc_number'] ?? '').toString(),
'composer': (selectedBest['composer'] ?? '').toString(), 'total_discs': (selectedBest['total_discs'] ?? '').toString(),
}; 'isrc': (selectedBest['isrc'] ?? '').toString(),
_mergeOnlineTrackData(enriched, selectedBest); 'composer': (selectedBest['composer'] ?? '').toString(),
});
_mergeOnlineTrackData(enriched, selectedBest);
}
final enrichedIsrc = (enriched['isrc'] ?? '').trim();
final needsIsrc = final needsIsrc =
_autoFillFields.contains('isrc') && enriched['isrc']!.isEmpty; _autoFillFields.contains('isrc') && enrichedIsrc.isEmpty;
final needsExtended = final needsExtended =
_autoFillFields.contains('genre') || _autoFillFields.contains('genre') ||
_autoFillFields.contains('label') || _autoFillFields.contains('label') ||
_autoFillFields.contains('copyright') || _autoFillFields.contains('copyright') ||
_autoFillFields.contains('composer'); _autoFillFields.contains('composer');
final rawSpotifyId = _extractRawSpotifyTrackId(selectedBest); final rawSpotifyId = selectedBest == null
? _extractRawSpotifyTrackIdFromValue(widget.sourceTrackId)
: _extractRawSpotifyTrackId(selectedBest);
deezerId ??= _extractRawDeezerTrackId(selectedBest); deezerId ??= selectedBest == null
final candidateIsrc = enriched['isrc']!.trim().toUpperCase(); ? null
: _extractRawDeezerTrackId(selectedBest);
final candidateIsrc = enrichedIsrc.toUpperCase();
final deezerLookupIsrc = _looksLikeIsrc(currentIsrc) final deezerLookupIsrc = _looksLikeIsrc(currentIsrc)
? currentIsrc ? currentIsrc
: (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : ''); : (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : '');
@@ -5243,7 +5287,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (!mounted) return; if (!mounted) return;
// Fetch ISRC from Deezer track metadata if still missing // 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 { try {
final deezerMeta = await PlatformBridge.getDeezerMetadata( final deezerMeta = await PlatformBridge.getDeezerMetadata(
'track', '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; if (!mounted) return;
var filledCount = 0; var filledCount = 0;
@@ -5293,7 +5370,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
} }
} }
if (_autoFillFields.contains('cover')) { if (_autoFillFields.contains('cover') && selectedBest != null) {
final coverUrl = final coverUrl =
(selectedBest['cover_url'] ?? selectedBest['images'] ?? '') (selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
.toString(); .toString();
@@ -5369,6 +5446,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
_discTotalCtrl = TextEditingController(text: v['total_discs'] ?? ''); _discTotalCtrl = TextEditingController(text: v['total_discs'] ?? '');
_genreCtrl = TextEditingController(text: v['genre'] ?? ''); _genreCtrl = TextEditingController(text: v['genre'] ?? '');
_isrcCtrl = TextEditingController(text: v['isrc'] ?? ''); _isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
_lyricsCtrl = TextEditingController(text: v['lyrics'] ?? '');
_labelCtrl = TextEditingController(text: v['label'] ?? ''); _labelCtrl = TextEditingController(text: v['label'] ?? '');
_copyrightCtrl = TextEditingController(text: v['copyright'] ?? ''); _copyrightCtrl = TextEditingController(text: v['copyright'] ?? '');
_composerCtrl = TextEditingController(text: v['composer'] ?? ''); _composerCtrl = TextEditingController(text: v['composer'] ?? '');
@@ -5391,6 +5469,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
_discTotalCtrl.dispose(); _discTotalCtrl.dispose();
_genreCtrl.dispose(); _genreCtrl.dispose();
_isrcCtrl.dispose(); _isrcCtrl.dispose();
_lyricsCtrl.dispose();
_labelCtrl.dispose(); _labelCtrl.dispose();
_copyrightCtrl.dispose(); _copyrightCtrl.dispose();
_composerCtrl.dispose(); _composerCtrl.dispose();
@@ -5413,6 +5492,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
'disc_total': _discTotalCtrl.text, 'disc_total': _discTotalCtrl.text,
'genre': _genreCtrl.text, 'genre': _genreCtrl.text,
'isrc': _isrcCtrl.text, 'isrc': _isrcCtrl.text,
'lyrics': _lyricsCtrl.text,
'label': _labelCtrl.text, 'label': _labelCtrl.text,
'copyright': _copyrightCtrl.text, 'copyright': _copyrightCtrl.text,
'composer': _composerCtrl.text, 'composer': _composerCtrl.text,
@@ -5477,6 +5557,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
: '', : '',
'GENRE': metadata['genre'] ?? '', 'GENRE': metadata['genre'] ?? '',
'ISRC': metadata['isrc'] ?? '', 'ISRC': metadata['isrc'] ?? '',
'LYRICS': metadata['lyrics'] ?? '',
'UNSYNCEDLYRICS': metadata['lyrics'] ?? '',
'ORGANIZATION': metadata['label'] ?? '', 'ORGANIZATION': metadata['label'] ?? '',
'COPYRIGHT': metadata['copyright'] ?? '', 'COPYRIGHT': metadata['copyright'] ?? '',
'COMPOSER': metadata['composer'] ?? '', 'COMPOSER': metadata['composer'] ?? '',
@@ -5486,11 +5568,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final existingMetadata = await PlatformBridge.readFileMetadata( final existingMetadata = await PlatformBridge.readFileMetadata(
ffmpegTarget, 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 // Preserve ReplayGain tags if present these are computed once
// during download and should survive manual metadata edits. // during download and should survive manual metadata edits.
final rgFields = <String, String>{ final rgFields = <String, String>{
@@ -5717,6 +5794,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
), ),
_field('Genre', _genreCtrl), _field('Genre', _genreCtrl),
_field('ISRC', _isrcCtrl), _field('ISRC', _isrcCtrl),
_field(
context.l10n.trackLyrics,
_lyricsCtrl,
maxLines: 8,
keyboard: TextInputType.multiline,
),
Padding( Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4), padding: const EdgeInsets.only(top: 8, bottom: 4),
child: InkWell( child: InkWell(
+17 -20
View File
@@ -61,32 +61,29 @@ class CsvImportService {
if (trackData == null) { if (trackData == null) {
try { try {
final query = '${track.artistName} ${track.name}'; final query = '${track.artistName} ${track.name}';
final searchResult = await PlatformBridge.searchDeezerAll( final searchResult = await PlatformBridge.customSearchWithExtension(
'deezer',
query, query,
trackLimit: 5, options: {'filter': 'track', 'limit': 5},
); );
if (searchResult.containsKey('tracks')) { if (searchResult.isNotEmpty) {
final tracksList = searchResult['tracks'] as List<dynamic>?; for (final resultMap in searchResult) {
if (tracksList != null && tracksList.isNotEmpty) { final resultName =
for (final result in tracksList) { (resultMap['name'] as String?)?.toLowerCase() ?? '';
final resultMap = result as Map<String, dynamic>; final trackNameLower = track.name.toLowerCase();
final resultName =
(resultMap['name'] as String?)?.toLowerCase() ?? '';
final trackNameLower = track.name.toLowerCase();
if (resultName.contains(trackNameLower) || if (resultName.contains(trackNameLower) ||
trackNameLower.contains(resultName)) { trackNameLower.contains(resultName)) {
trackData = resultMap; trackData = resultMap;
_log.d('Text search match for ${track.name}: $resultName'); _log.d('Text search match for ${track.name}: $resultName');
break; break;
}
} }
}
if (trackData == null && tracksList.isNotEmpty) { if (trackData == null) {
trackData = tracksList.first as Map<String, dynamic>; trackData = searchResult.first;
_log.d('Using first search result for ${track.name}'); _log.d('Using first search result for ${track.name}');
}
} }
} }
} catch (e) { } catch (e) {
+215 -162
View File
@@ -1139,20 +1139,28 @@ class FFmpegService {
: '.tmp'; : '.tmp';
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, ext); final tempOutput = _nextTempEmbedPath(tempDir.path, ext);
final arguments = <String>[
final sanitizedGain = albumGain.replaceAll('"', '\\"'); '-v',
final sanitizedPeak = albumPeak.replaceAll('"', '\\"'); 'error',
'-hide_banner',
// -map_metadata 0 preserves all existing metadata from the input. '-i',
// -metadata flags add/overwrite only the specified keys. filePath,
final command = '-map',
'-v error -hide_banner -i "$filePath" -map 0 -c copy -map_metadata 0 ' '0',
'-metadata REPLAYGAIN_ALBUM_GAIN="$sanitizedGain" ' '-c',
'-metadata REPLAYGAIN_ALBUM_PEAK="$sanitizedPeak" ' 'copy',
'"$tempOutput" -y'; '-map_metadata',
'0',
'-metadata',
'REPLAYGAIN_ALBUM_GAIN=$albumGain',
'-metadata',
'REPLAYGAIN_ALBUM_PEAK=$albumPeak',
tempOutput,
'-y',
];
_log.d('Writing album ReplayGain tags via FFmpeg'); _log.d('Writing album ReplayGain tags via FFmpeg');
final result = await _execute(command); final result = await _executeWithArguments(arguments);
if (result.success) { if (result.success) {
try { try {
@@ -1194,41 +1202,50 @@ class FFmpegService {
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac'); final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', flacPath];
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$flacPath" ');
if (coverPath != null) { 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) { if (coverPath != null) {
cmdBuffer.write('-map 1:0 '); arguments
cmdBuffer.write('-c:v copy '); ..add('-map')
cmdBuffer.write('-disposition:v attached_pic '); ..add('1:0')
cmdBuffer.write('-metadata:s:v title="Album cover" '); ..add('-c:v')
cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); ..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) { if (metadata != null) {
_appendVorbisMetadataToCommandBuffer( _appendVorbisMetadataToArguments(
cmdBuffer, arguments,
metadata, metadata,
artistTagMode: artistTagMode, artistTagMode: artistTagMode,
); );
} }
cmdBuffer.write('"$tempOutput" -y'); arguments
..add(tempOutput)
..add('-y');
final command = cmdBuffer.toString(); _log.d('Executing FFmpeg FLAC embed command');
_log.d('Executing FFmpeg command: ${_previewCommandForLog(command)}'); final result = await _executeWithArguments(arguments);
final result = await _execute(command);
if (result.success) { if (result.success) {
try { try {
@@ -1274,46 +1291,50 @@ class FFmpegService {
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3'); final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', mp3Path];
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$mp3Path" ');
if (coverPath != null) { if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" '); arguments
..add('-i')
..add(coverPath);
} }
cmdBuffer.write('-map 0:a '); arguments
cmdBuffer.write( ..add('-map')
preserveMetadata ? '-map_metadata 0 ' : '-map_metadata -1 ', ..add('0:a')
); ..add('-map_metadata')
..add(preserveMetadata ? '0' : '-1');
if (coverPath != null) { if (coverPath != null) {
cmdBuffer.write('-map 1:0 '); arguments
cmdBuffer.write('-c:v:0 copy '); ..add('-map')
cmdBuffer.write('-id3v2_version 3 '); ..add('1:0')
cmdBuffer.write('-metadata:s:v title="Album cover" '); ..add('-c:v:0')
cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); ..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) { if (metadata != null) {
final id3Metadata = _convertToId3Tags(metadata); _appendMappedMetadataToArguments(arguments, _convertToId3Tags(metadata));
id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
} }
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');
_log.d( final result = await _executeWithArguments(arguments);
'Executing FFmpeg MP3 embed command: ${_previewCommandForLog(command)}',
);
final result = await _execute(command);
if (result.success) { if (result.success) {
try { try {
@@ -1452,14 +1473,11 @@ class FFmpegService {
required String m4aPath, required String m4aPath,
String? coverPath, String? coverPath,
Map<String, String>? metadata, Map<String, String>? metadata,
bool preserveMetadata = false, bool preserveMetadata = true,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a'); final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', m4aPath];
final cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$m4aPath" ');
final normalizedCoverPath = coverPath?.trim(); final normalizedCoverPath = coverPath?.trim();
final hasCover = final hasCover =
@@ -1467,48 +1485,61 @@ class FFmpegService {
normalizedCoverPath.isNotEmpty && normalizedCoverPath.isNotEmpty &&
await File(normalizedCoverPath).exists(); await File(normalizedCoverPath).exists();
if (hasCover) { if (hasCover) {
cmdBuffer.write('-i "$normalizedCoverPath" '); arguments
..add('-i')
..add(normalizedCoverPath);
} }
final preserveExistingStreams = preserveMetadata && !hasCover; final preserveExistingStreams = preserveMetadata && !hasCover;
if (preserveExistingStreams) { if (preserveExistingStreams) {
// When no replacement cover is provided, preserve all input streams so // When no replacement cover is provided, preserve all input streams so
// the existing attached artwork is not dropped during the metadata rewrite. // 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 { } else {
cmdBuffer.write('-map 0:a -c:a copy '); arguments
..add('-map')
..add('0:a')
..add('-c:a')
..add('copy');
} }
cmdBuffer.write( arguments
preserveMetadata ? '-map_metadata 0 ' : '-map_metadata -1 ', ..add('-map_metadata')
); ..add(preserveMetadata ? '0' : '-1');
// For M4A cover replacements, mark the image as an attached picture so the // 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. // 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) // 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+. // does not register a codec tag for mjpeg on FFmpeg 8.0+.
if (hasCover) { if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic '); arguments
cmdBuffer.write('-metadata:s:v title="Album cover" '); ..add('-map')
cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); ..add('1:v')
cmdBuffer.write('-f mp4 '); ..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) { if (metadata != null) {
final m4aMetadata = _convertToM4aTags(metadata); _appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
for (final entry in m4aMetadata.entries) {
final sanitizedValue = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitizedValue" ');
}
} }
cmdBuffer.write('"$tempOutput" -y'); arguments
..add(tempOutput)
..add('-y');
final command = cmdBuffer.toString(); _log.d('Executing FFmpeg M4A embed command');
_log.d( final result = await _executeWithArguments(arguments);
'Executing FFmpeg M4A embed command: ${_previewCommandForLog(command)}',
);
final result = await _execute(command);
if (result.success) { if (result.success) {
try { try {
@@ -1767,40 +1798,50 @@ class FFmpegService {
bool deleteOriginal = true, bool deleteOriginal = true,
}) async { }) async {
final outputPath = _buildOutputPath(inputPath, '.m4a'); final outputPath = _buildOutputPath(inputPath, '.m4a');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', inputPath];
final cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$inputPath" ');
final hasCover = final hasCover =
coverPath != null && coverPath != null &&
coverPath.trim().isNotEmpty && coverPath.trim().isNotEmpty &&
await File(coverPath).exists(); await File(coverPath).exists();
if (hasCover) { if (hasCover) {
cmdBuffer.write('-i "$coverPath" '); arguments
..add('-i')
..add(coverPath);
} }
cmdBuffer.write('-map 0:a '); arguments
..add('-map')
..add('0:a');
if (hasCover) { if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic '); arguments
cmdBuffer.write('-metadata:s:v title="Album cover" '); ..add('-map')
cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); ..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 '); arguments
cmdBuffer.write('-map_metadata -1 '); ..add('-c:a')
..add('alac')
..add('-map_metadata')
..add('-1');
final m4aTags = _convertToM4aTags(metadata); _appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
for (final entry in m4aTags.entries) {
final sanitized = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
}
cmdBuffer.write('"$outputPath" -y'); arguments
..add(outputPath)
..add('-y');
_log.i( _log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC', 'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC',
); );
final result = await _execute(cmdBuffer.toString()); final result = await _executeWithArguments(arguments);
if (!result.success) { if (!result.success) {
_log.e('ALAC conversion failed: ${result.output}'); _log.e('ALAC conversion failed: ${result.output}');
@@ -1830,40 +1871,56 @@ class FFmpegService {
bool deleteOriginal = true, bool deleteOriginal = true,
}) async { }) async {
final outputPath = _buildOutputPath(inputPath, '.flac'); final outputPath = _buildOutputPath(inputPath, '.flac');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', inputPath];
final cmdBuffer = StringBuffer();
cmdBuffer.write('-v error -hide_banner ');
cmdBuffer.write('-i "$inputPath" ');
final hasCover = final hasCover =
coverPath != null && coverPath != null &&
coverPath.trim().isNotEmpty && coverPath.trim().isNotEmpty &&
await File(coverPath).exists(); await File(coverPath).exists();
if (hasCover) { if (hasCover) {
cmdBuffer.write('-i "$coverPath" '); arguments
..add('-i')
..add(coverPath);
} }
cmdBuffer.write('-map 0:a '); arguments
..add('-map')
..add('0:a');
if (hasCover) { if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic '); arguments
cmdBuffer.write('-metadata:s:v title="Album cover" '); ..add('-map')
cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); ..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 '); arguments
cmdBuffer.write('-map_metadata 0 '); ..add('-c:a')
..add('flac')
..add('-compression_level')
..add('8')
..add('-map_metadata')
..add('0');
_appendVorbisMetadataToCommandBuffer( _appendVorbisMetadataToArguments(
cmdBuffer, arguments,
metadata, metadata,
artistTagMode: artistTagMode, artistTagMode: artistTagMode,
); );
cmdBuffer.write('"$outputPath" -y'); arguments
..add(outputPath)
..add('-y');
_log.i( _log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC', 'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC',
); );
final result = await _execute(cmdBuffer.toString()); final result = await _executeWithArguments(arguments);
if (!result.success) { if (!result.success) {
_log.e('FLAC conversion failed: ${result.output}'); _log.e('FLAC conversion failed: ${result.output}');
@@ -1969,20 +2026,6 @@ class FFmpegService {
return vorbis; 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( static void _appendVorbisMetadataToArguments(
List<String> arguments, List<String> arguments,
Map<String, String> metadata, { 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( static List<MapEntry<String, String>> _buildVorbisMetadataEntries(
Map<String, String> metadata, { Map<String, String> metadata, {
String artistTagMode = artistTagModeJoined, String artistTagMode = artistTagModeJoined,
@@ -2115,19 +2169,6 @@ class FFmpegService {
case 'UNSYNCEDLYRICS': case 'UNSYNCEDLYRICS':
m4aMap['lyrics'] = value; m4aMap['lyrics'] = value;
break; 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 trackNumStr = track.number.toString().padLeft(2, '0');
final outputFileName = '$trackNumStr - $sanitizedTitle.$outputExt'; final outputFileName = '$trackNumStr - $sanitizedTitle.$outputExt';
final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName'; final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName';
final arguments = <String>[
final StringBuffer cmdBuffer = StringBuffer(); '-v',
cmdBuffer.write('-v error -hide_banner '); 'error',
cmdBuffer.write('-i "$audioPath" '); '-hide_banner',
'-i',
audioPath,
];
final startTime = _formatSecondsForFFmpeg(track.startSec); final startTime = _formatSecondsForFFmpeg(track.startSec);
cmdBuffer.write('-ss $startTime '); arguments
..add('-ss')
..add(startTime);
if (track.endSec > 0) { if (track.endSec > 0) {
final endTime = _formatSecondsForFFmpeg(track.endSec); final endTime = _formatSecondsForFFmpeg(track.endSec);
cmdBuffer.write('-to $endTime '); arguments
..add('-to')
..add(endTime);
} }
if (outputExt == 'flac') { if (outputExt == 'flac') {
cmdBuffer.write('-c:a flac -compression_level 8 '); arguments
..add('-c:a')
..add('flac')
..add('-compression_level')
..add('8');
} else { } else {
cmdBuffer.write('-c:a copy '); arguments
..add('-c:a')
..add('copy');
} }
final artist = track.artist.isNotEmpty final artist = track.artist.isNotEmpty
@@ -2280,11 +2334,11 @@ class FFmpegService {
final album = albumMetadata['album'] ?? ''; final album = albumMetadata['album'] ?? '';
final genre = albumMetadata['genre'] ?? ''; final genre = albumMetadata['genre'] ?? '';
final date = albumMetadata['date'] ?? ''; final date = albumMetadata['date'] ?? '';
final cueMetadata = <String, String>{};
void addMeta(String key, String value) { void addMeta(String key, String value) {
if (value.isNotEmpty) { if (value.isNotEmpty) {
final sanitized = value.replaceAll('"', '\\"'); cueMetadata[key] = value;
cmdBuffer.write('-metadata $key="$sanitized" ');
} }
} }
@@ -2298,14 +2352,13 @@ class FFmpegService {
if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc); if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc);
if (track.composer.isNotEmpty) addMeta('COMPOSER', track.composer); 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}');
_log.d( final result = await _executeWithArguments(arguments);
'CUE split track ${track.number}: ${_previewCommandForLog(command)}',
);
final result = await _execute(command);
if (!result.success) { if (!result.success) {
_log.e('CUE split failed for track ${track.number}: ${result.output}'); _log.e('CUE split failed for track ${track.number}: ${result.output}');
continue; continue;
-15
View File
@@ -496,21 +496,6 @@ class PlatformBridge {
await _channel.invokeMethod('clearTrackCache'); 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( static Future<Map<String, dynamic>> searchTidalAll(
String query, { String query, {
int trackLimit = 15, int trackLimit = 15,
+13 -38
View File
@@ -13,27 +13,9 @@ class ShareIntentService {
static final RegExp _spotifyUriPattern = RegExp( static final RegExp _spotifyUriPattern = RegExp(
r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+', r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+',
); );
static final RegExp _spotifyUrlPattern = RegExp( static final RegExp _genericHttpUrlPattern = RegExp(
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?', "https?://[^\\s<>\\\"']+",
); caseSensitive: false,
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]*)?',
); );
final _sharedUrlController = StreamController<String>.broadcast(); final _sharedUrlController = StreamController<String>.broadcast();
@@ -99,24 +81,17 @@ class ShareIntentService {
return uriMatch.group(0); return uriMatch.group(0);
} }
final patterns = [ // Keep share parsing generic and let manifest-based URL handlers decide
_spotifyUrlPattern, // which installed extension can handle the incoming link.
_deezerUrlPattern, for (final match in _genericHttpUrlPattern.allMatches(text)) {
_deezerShortLinkPattern, final rawUrl = match.group(0);
_tidalUrlPattern, if (rawUrl == null || rawUrl.isEmpty) {
_ytMusicUrlPattern, continue;
_youtubeUrlPattern, }
];
for (final pattern in patterns) { final sanitizedUrl = rawUrl.replaceFirst(RegExp(r'[.,;:!?)\]}]+$'), '');
final match = pattern.firstMatch(text); if (sanitizedUrl.isNotEmpty) {
if (match != null) { return sanitizedUrl;
final fullUrl = match.group(0)!;
if (pattern == _ytMusicUrlPattern || pattern == _youtubeUrlPattern) {
return fullUrl;
}
final queryIndex = fullUrl.indexOf('?');
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
} }
} }
+32 -23
View File
@@ -10,6 +10,19 @@ import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('ClickableMetadata'); 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( Future<void> navigateToArtist(
BuildContext context, { BuildContext context, {
@@ -39,15 +52,14 @@ Future<void> navigateToArtist(
_showLoadingSnackBar(context, 'Looking up artist...'); _showLoadingSnackBar(context, 'Looking up artist...');
try { try {
final results = await PlatformBridge.searchDeezerAll( final artistList = await _searchDeezerExtension(
artistName, artistName,
trackLimit: 0, filter: 'artist',
artistLimit: 3, limit: 3,
); );
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).hideCurrentSnackBar();
final artistList = results['artists'] as List<dynamic>? ?? [];
if (artistList.isEmpty) { if (artistList.isEmpty) {
_showUnavailable(context, 'Artist'); _showUnavailable(context, 'Artist');
return; return;
@@ -56,15 +68,13 @@ Future<void> navigateToArtist(
Map<String, dynamic>? bestMatch; Map<String, dynamic>? bestMatch;
final lowerName = artistName.toLowerCase().trim(); final lowerName = artistName.toLowerCase().trim();
for (final a in artistList) { for (final a in artistList) {
if (a is Map<String, dynamic>) { final name = (a['name'] as String? ?? '').toLowerCase().trim();
final name = (a['name'] as String? ?? '').toLowerCase().trim(); if (name == lowerName) {
if (name == lowerName) { bestMatch = a;
bestMatch = a; break;
break;
}
} }
} }
bestMatch ??= artistList.first as Map<String, dynamic>; bestMatch ??= artistList.first;
final resolvedId = bestMatch['id'] as String? ?? ''; final resolvedId = bestMatch['id'] as String? ?? '';
final resolvedName = bestMatch['name'] as String? ?? artistName; final resolvedName = bestMatch['name'] as String? ?? artistName;
@@ -81,6 +91,7 @@ Future<void> navigateToArtist(
artistId: resolvedId, artistId: resolvedId,
artistName: resolvedName, artistName: resolvedName,
coverUrl: resolvedImage ?? coverUrl, coverUrl: resolvedImage ?? coverUrl,
extensionId: _deezerExtensionId,
); );
} catch (e) { } catch (e) {
_log.e('Failed to look up artist "$artistName": $e', e); _log.e('Failed to look up artist "$artistName": $e', e);
@@ -125,15 +136,14 @@ Future<void> navigateToAlbum(
? '$albumName $artistName' ? '$albumName $artistName'
: albumName; : albumName;
final results = await PlatformBridge.searchDeezerAll( final albumList = await _searchDeezerExtension(
query, query,
trackLimit: 0, filter: 'album',
artistLimit: 0, limit: 5,
); );
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).hideCurrentSnackBar();
final albumList = results['albums'] as List<dynamic>? ?? [];
if (albumList.isEmpty) { if (albumList.isEmpty) {
_showUnavailable(context, 'Album'); _showUnavailable(context, 'Album');
return; return;
@@ -142,15 +152,13 @@ Future<void> navigateToAlbum(
Map<String, dynamic>? bestMatch; Map<String, dynamic>? bestMatch;
final lowerName = albumName.toLowerCase().trim(); final lowerName = albumName.toLowerCase().trim();
for (final a in albumList) { for (final a in albumList) {
if (a is Map<String, dynamic>) { final name = (a['name'] as String? ?? '').toLowerCase().trim();
final name = (a['name'] as String? ?? '').toLowerCase().trim(); if (name == lowerName) {
if (name == lowerName) { bestMatch = a;
bestMatch = a; break;
break;
}
} }
} }
bestMatch ??= albumList.first as Map<String, dynamic>; bestMatch ??= albumList.first;
final resolvedId = bestMatch['id'] as String? ?? ''; final resolvedId = bestMatch['id'] as String? ?? '';
final resolvedName = bestMatch['name'] as String? ?? albumName; final resolvedName = bestMatch['name'] as String? ?? albumName;
@@ -167,6 +175,7 @@ Future<void> navigateToAlbum(
albumId: resolvedId, albumId: resolvedId,
albumName: resolvedName, albumName: resolvedName,
coverUrl: resolvedImage ?? coverUrl, coverUrl: resolvedImage ?? coverUrl,
extensionId: _deezerExtensionId,
); );
} catch (e) { } catch (e) {
_log.e('Failed to look up album "$albumName": $e', e); _log.e('Failed to look up album "$albumName": $e', e);
@@ -207,7 +216,7 @@ void _pushAlbumScreen(
String? coverUrl, String? coverUrl,
String? extensionId, String? extensionId,
}) { }) {
const builtInProviders = {'tidal', 'qobuz', 'deezer'}; const builtInProviders = {'tidal', 'qobuz'};
final isExtension = final isExtension =
extensionId != null && !builtInProviders.contains(extensionId); extensionId != null && !builtInProviders.contains(extensionId);
final resolvedExtensionId = extensionId; final resolvedExtensionId = extensionId;
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none" publish_to: "none"
version: 4.2.2+123 version: 4.3.0+125
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0