mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd750b95ca | |||
| e42e44f28b | |||
| 67daefdf60 | |||
| fabaf0a3ff |
@@ -40,7 +40,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"com.zarz.spotiflac/download_progress_stream"
|
||||
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
|
||||
"com.zarz.spotiflac/library_scan_progress_stream"
|
||||
private val STREAM_POLLING_INTERVAL_MS = 1200L
|
||||
private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L
|
||||
private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||
private val safScanLock = Any()
|
||||
@@ -55,6 +56,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private var flutterBackCallback: OnBackPressedCallback? = null
|
||||
@Volatile private var safScanCancel = false
|
||||
@Volatile private var safScanActive = false
|
||||
/** Tri-state: null = untested, true = works, false = fails (Samsung SELinux). */
|
||||
@Volatile private var procSelfFdReadable: Boolean? = null
|
||||
private val safTreeLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { activityResult ->
|
||||
@@ -374,6 +377,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
synchronized(safScanLock) {
|
||||
safScanProgress = SafScanProgress()
|
||||
}
|
||||
// Allow re-probing /proc/self/fd readability on every new scan session.
|
||||
procSelfFdReadable = null
|
||||
}
|
||||
|
||||
private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) {
|
||||
@@ -454,7 +459,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"Download progress stream poll failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
delay(STREAM_POLLING_INTERVAL_MS)
|
||||
delay(DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,6 +476,18 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
libraryScanProgressEventSink = sink
|
||||
lastLibraryScanProgressPayload = null
|
||||
libraryScanProgressStreamJob = scope.launch {
|
||||
try {
|
||||
val initialPayload = withContext(Dispatchers.IO) {
|
||||
readLibraryScanProgressJsonForStream()
|
||||
}
|
||||
lastLibraryScanProgressPayload = initialPayload
|
||||
sink.success(parseJsonPayload(initialPayload))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Library scan progress initial poll failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
while (isActive && libraryScanProgressEventSink === sink) {
|
||||
try {
|
||||
val payload = withContext(Dispatchers.IO) {
|
||||
@@ -486,7 +503,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"Library scan progress stream poll failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
delay(STREAM_POLLING_INTERVAL_MS)
|
||||
delay(LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -777,29 +794,59 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return if (ext.isNullOrBlank()) "audio" else "audio$ext"
|
||||
}
|
||||
|
||||
private fun buildLibraryCoverCacheKey(stablePath: String, lastModified: Long): String {
|
||||
val normalizedPath = stablePath.trim()
|
||||
if (normalizedPath.isEmpty()) return ""
|
||||
return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath
|
||||
}
|
||||
|
||||
private fun readAudioMetadataFromUri(
|
||||
uri: Uri,
|
||||
displayNameHint: String? = null,
|
||||
fallbackExt: String? = null,
|
||||
coverCacheKey: String = "",
|
||||
): JSONObject? {
|
||||
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
|
||||
|
||||
try {
|
||||
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
val directPath = "/proc/self/fd/${pfd.fd}"
|
||||
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName)
|
||||
if (metadataJson.isNotBlank()) {
|
||||
val obj = JSONObject(metadataJson)
|
||||
if (!obj.has("error")) {
|
||||
return obj
|
||||
// Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux).
|
||||
if (procSelfFdReadable != false) {
|
||||
try {
|
||||
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
val directPath = "/proc/self/fd/${pfd.fd}"
|
||||
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
|
||||
directPath,
|
||||
displayName,
|
||||
coverCacheKey,
|
||||
)
|
||||
if (metadataJson.isNotBlank()) {
|
||||
val obj = JSONObject(metadataJson)
|
||||
val filenameFallback = obj.optBoolean("metadataFromFilename", false)
|
||||
if (!obj.has("error") && !filenameFallback) {
|
||||
procSelfFdReadable = true
|
||||
return obj
|
||||
}
|
||||
// Go could not read real metadata from the fd path –
|
||||
// remember so we skip the attempt for remaining files.
|
||||
if (procSelfFdReadable == null) {
|
||||
procSelfFdReadable = false
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct /proc/self/fd read not usable on this device, " +
|
||||
"using temp-file fallback for remaining files",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (procSelfFdReadable == null) {
|
||||
procSelfFdReadable = false
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct /proc/self/fd read not usable on this device, " +
|
||||
"using temp-file fallback for remaining files",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct SAF metadata read fallback for $uri: ${e.message}",
|
||||
)
|
||||
}
|
||||
|
||||
val tempPath = try {
|
||||
@@ -813,7 +860,11 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
} ?: return null
|
||||
|
||||
try {
|
||||
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName)
|
||||
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
|
||||
tempPath,
|
||||
displayName,
|
||||
coverCacheKey,
|
||||
)
|
||||
if (metadataJson.isBlank()) return null
|
||||
val obj = JSONObject(metadataJson)
|
||||
return if (obj.has("error")) null else obj
|
||||
@@ -1190,6 +1241,11 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
||||
val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueDoc.lastModified() }
|
||||
val coverCacheKey = buildLibraryCoverCacheKey(
|
||||
audioDoc.uri.toString(),
|
||||
audioLastModified,
|
||||
)
|
||||
|
||||
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
|
||||
if (tempAudioPath == null) {
|
||||
@@ -1208,11 +1264,12 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
|
||||
|
||||
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
||||
val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
|
||||
tempCuePath,
|
||||
tempDir,
|
||||
cueDoc.uri.toString(),
|
||||
cueLastModified
|
||||
cueLastModified,
|
||||
coverCacheKey,
|
||||
)
|
||||
|
||||
val cueArray = JSONArray(cueResultsJson)
|
||||
@@ -1264,13 +1321,19 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
|
||||
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||
val stableUri = doc.uri.toString()
|
||||
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, lastModified)
|
||||
val metadataObj = readAudioMetadataFromUri(
|
||||
doc.uri,
|
||||
name,
|
||||
fallbackExt,
|
||||
coverCacheKey,
|
||||
)
|
||||
if (metadataObj == null) {
|
||||
errors++
|
||||
} else {
|
||||
try {
|
||||
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||
val stableUri = doc.uri.toString()
|
||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||
metadataObj.put("filePath", stableUri)
|
||||
metadataObj.put("fileModTime", lastModified)
|
||||
@@ -1538,6 +1601,11 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
||||
val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueLastModified }
|
||||
val coverCacheKey = buildLibraryCoverCacheKey(
|
||||
audioDoc.uri.toString(),
|
||||
audioLastModified,
|
||||
)
|
||||
|
||||
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
|
||||
if (tempAudioPath == null) {
|
||||
@@ -1554,11 +1622,12 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
tempAudioPath = renamedAudio.absolutePath
|
||||
}
|
||||
|
||||
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
||||
val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
|
||||
tempCuePath,
|
||||
tempDir,
|
||||
cueDoc.uri.toString(),
|
||||
cueLastModified
|
||||
cueLastModified,
|
||||
coverCacheKey,
|
||||
)
|
||||
|
||||
val cueArray = JSONArray(cueResultsJson)
|
||||
@@ -1655,13 +1724,19 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
|
||||
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
||||
val stableUri = doc.uri.toString()
|
||||
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, safeLastModified)
|
||||
val metadataObj = readAudioMetadataFromUri(
|
||||
doc.uri,
|
||||
name,
|
||||
fallbackExt,
|
||||
coverCacheKey,
|
||||
)
|
||||
if (metadataObj == null) {
|
||||
errors++
|
||||
} else {
|
||||
try {
|
||||
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
||||
val stableUri = doc.uri.toString()
|
||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||
metadataObj.put("filePath", stableUri)
|
||||
metadataObj.put("fileModTime", safeLastModified)
|
||||
@@ -2358,11 +2433,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return@withContext obj.toString()
|
||||
// Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf
|
||||
}
|
||||
// FLAC: Go wrote directly to temp, copy back now
|
||||
if (!writeUriFromPath(uri, tempPath)) {
|
||||
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
|
||||
}
|
||||
raw
|
||||
// FLAC: Go wrote directly to temp, copy back now
|
||||
if (!writeUriFromPath(uri, tempPath)) {
|
||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
|
||||
}
|
||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||
raw
|
||||
} catch (e: Exception) {
|
||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||
throw e
|
||||
|
||||
@@ -980,6 +980,8 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
artistValues := make([]string, 0, 1)
|
||||
albumArtistValues := make([]string, 0, 1)
|
||||
|
||||
// Read vendor string length
|
||||
var vendorLen uint32
|
||||
@@ -1034,9 +1036,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
case "TITLE":
|
||||
metadata.Title = value
|
||||
case "ARTIST":
|
||||
metadata.Artist = value
|
||||
artistValues = append(artistValues, value)
|
||||
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
||||
metadata.AlbumArtist = value
|
||||
albumArtistValues = append(albumArtistValues, value)
|
||||
case "ALBUM":
|
||||
metadata.Album = value
|
||||
case "DATE", "YEAR":
|
||||
@@ -1066,6 +1068,13 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
metadata.Copyright = value
|
||||
}
|
||||
}
|
||||
|
||||
if len(artistValues) > 0 {
|
||||
metadata.Artist = joinVorbisCommentValues(artistValues)
|
||||
}
|
||||
if len(albumArtistValues) > 0 {
|
||||
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
|
||||
}
|
||||
}
|
||||
|
||||
func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
@@ -1620,14 +1629,28 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
||||
}
|
||||
|
||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
return SaveCoverToCacheWithHint(filePath, "", cacheDir)
|
||||
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
|
||||
}
|
||||
|
||||
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
|
||||
}
|
||||
|
||||
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
|
||||
explicitKey = strings.TrimSpace(explicitKey)
|
||||
if explicitKey != "" {
|
||||
return explicitKey
|
||||
}
|
||||
|
||||
cacheKey := filePath
|
||||
if stat, err := os.Stat(filePath); err == nil {
|
||||
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
||||
}
|
||||
return cacheKey
|
||||
}
|
||||
|
||||
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
|
||||
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||
hash := hashString(cacheKey)
|
||||
|
||||
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveLibraryCoverCacheKeyUsesExplicitKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const explicitKey = "content://media/external/audio/media/42|123456"
|
||||
got := resolveLibraryCoverCacheKey("/tmp/saf_random.flac", explicitKey)
|
||||
if got != explicitKey {
|
||||
t.Fatalf("expected explicit cache key %q, got %q", explicitKey, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLibraryCoverCacheKeyUsesFilePathAndStatWhenNoExplicitKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempFile, err := os.CreateTemp("", "cover-cache-*.flac")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp failed: %v", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
tempFile.Close()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
got := resolveLibraryCoverCacheKey(tempPath, "")
|
||||
if !strings.HasPrefix(got, tempPath+"|") {
|
||||
t.Fatalf("expected stat-based cache key to start with %q, got %q", tempPath+"|", got)
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,11 @@ type CueSheet struct {
|
||||
|
||||
// CueTrack represents a single track in a cue sheet
|
||||
type CueTrack struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Performer string `json:"performer"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Performer string `json:"performer"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||
}
|
||||
@@ -422,7 +422,7 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime)
|
||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
|
||||
}
|
||||
|
||||
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
||||
@@ -433,6 +433,17 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
|
||||
// - fileModTime: if > 0, used as the FileModTime for all results instead of
|
||||
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
|
||||
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||
return ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||
cuePath,
|
||||
audioDir,
|
||||
virtualPathPrefix,
|
||||
fileModTime,
|
||||
"",
|
||||
scanTime,
|
||||
)
|
||||
}
|
||||
|
||||
func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -441,7 +452,15 @@ func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileM
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime)
|
||||
return scanCueSheetForLibrary(
|
||||
cuePath,
|
||||
sheet,
|
||||
audioPath,
|
||||
virtualPathPrefix,
|
||||
fileModTime,
|
||||
coverCacheKey,
|
||||
scanTime,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
||||
@@ -459,7 +478,7 @@ func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir str
|
||||
return audioPath, nil
|
||||
}
|
||||
|
||||
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||
if sheet == nil {
|
||||
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||
}
|
||||
@@ -492,7 +511,12 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" {
|
||||
cp, err := SaveCoverToCache(audioPath, coverCacheDir)
|
||||
cp, err := SaveCoverToCacheWithHintAndKey(
|
||||
audioPath,
|
||||
"",
|
||||
coverCacheDir,
|
||||
coverCacheKey,
|
||||
)
|
||||
if err == nil && cp != "" {
|
||||
coverPath = cp
|
||||
}
|
||||
|
||||
@@ -524,18 +524,19 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
|
||||
+123
-74
@@ -49,6 +49,7 @@ type DownloadRequest struct {
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
Quality string `json:"quality"`
|
||||
EmbedMetadata bool `json:"embed_metadata"`
|
||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
@@ -117,24 +118,25 @@ type DownloadResult struct {
|
||||
}
|
||||
|
||||
type reEnrichRequest struct {
|
||||
FilePath string `json:"file_path"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
MaxQuality bool `json:"max_quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ISRC string `json:"isrc"`
|
||||
Genre string `json:"genre"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
SearchOnline bool `json:"search_online"`
|
||||
FilePath string `json:"file_path"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
MaxQuality bool `json:"max_quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ISRC string `json:"isrc"`
|
||||
Genre string `json:"genre"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
SearchOnline bool `json:"search_online"`
|
||||
}
|
||||
|
||||
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
@@ -191,15 +193,58 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
|
||||
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
||||
return DownloadRequest{
|
||||
TrackName: req.TrackName,
|
||||
ArtistName: req.ArtistName,
|
||||
AlbumName: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
ISRC: req.ISRC,
|
||||
DurationMS: int(req.DurationMs),
|
||||
TrackName: req.TrackName,
|
||||
ArtistName: req.ArtistName,
|
||||
AlbumName: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
ISRC: req.ISRC,
|
||||
DurationMS: int(req.DurationMs),
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
}
|
||||
}
|
||||
|
||||
func buildReEnrichFFmpegMetadata(req reEnrichRequest, lyricsLRC string) map[string]string {
|
||||
metadata := map[string]string{}
|
||||
if req.TrackName != "" {
|
||||
metadata["TITLE"] = req.TrackName
|
||||
}
|
||||
if req.ArtistName != "" {
|
||||
metadata["ARTIST"] = req.ArtistName
|
||||
}
|
||||
if req.AlbumName != "" {
|
||||
metadata["ALBUM"] = req.AlbumName
|
||||
}
|
||||
if req.AlbumArtist != "" {
|
||||
metadata["ALBUMARTIST"] = req.AlbumArtist
|
||||
}
|
||||
if req.ReleaseDate != "" {
|
||||
metadata["DATE"] = req.ReleaseDate
|
||||
}
|
||||
if req.ISRC != "" {
|
||||
metadata["ISRC"] = req.ISRC
|
||||
}
|
||||
if req.Genre != "" {
|
||||
metadata["GENRE"] = req.Genre
|
||||
}
|
||||
if req.TrackNumber > 0 {
|
||||
metadata["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
|
||||
}
|
||||
if req.DiscNumber > 0 {
|
||||
metadata["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
|
||||
}
|
||||
if req.Label != "" {
|
||||
metadata["ORGANIZATION"] = req.Label
|
||||
}
|
||||
if req.Copyright != "" {
|
||||
metadata["COPYRIGHT"] = req.Copyright
|
||||
}
|
||||
if lyricsLRC != "" {
|
||||
metadata["LYRICS"] = lyricsLRC
|
||||
metadata["UNSYNCEDLYRICS"] = lyricsLRC
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
|
||||
if len(tracks) == 0 {
|
||||
return nil
|
||||
@@ -1109,6 +1154,26 @@ func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileMod
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
results, err := ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||
cuePath,
|
||||
audioDir,
|
||||
virtualPathPrefix,
|
||||
fileModTime,
|
||||
coverCacheKey,
|
||||
scanTime,
|
||||
)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// EditFileMetadata writes metadata to an audio file.
|
||||
// For FLAC files, uses native Go FLAC library.
|
||||
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
|
||||
@@ -1133,19 +1198,20 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
}
|
||||
|
||||
meta := Metadata{
|
||||
Title: fields["title"],
|
||||
Artist: fields["artist"],
|
||||
Album: fields["album"],
|
||||
AlbumArtist: fields["album_artist"],
|
||||
Date: fields["date"],
|
||||
TrackNumber: trackNum,
|
||||
DiscNumber: discNum,
|
||||
ISRC: fields["isrc"],
|
||||
Genre: fields["genre"],
|
||||
Label: fields["label"],
|
||||
Copyright: fields["copyright"],
|
||||
Composer: fields["composer"],
|
||||
Comment: fields["comment"],
|
||||
Title: fields["title"],
|
||||
Artist: fields["artist"],
|
||||
Album: fields["album"],
|
||||
AlbumArtist: fields["album_artist"],
|
||||
ArtistTagMode: fields["artist_tag_mode"],
|
||||
Date: fields["date"],
|
||||
TrackNumber: trackNum,
|
||||
DiscNumber: discNum,
|
||||
ISRC: fields["isrc"],
|
||||
Genre: fields["genre"],
|
||||
Label: fields["label"],
|
||||
Copyright: fields["copyright"],
|
||||
Composer: fields["composer"],
|
||||
Comment: fields["comment"],
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(filePath, meta, coverPath); err != nil {
|
||||
@@ -2148,18 +2214,19 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
if isFlac {
|
||||
// Native Go FLAC metadata embedding
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Lyrics: lyricsLRC,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Lyrics: lyricsLRC,
|
||||
}
|
||||
|
||||
if len(coverDataBytes) > 0 {
|
||||
@@ -2195,36 +2262,14 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
|
||||
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
|
||||
cleanupCover = false
|
||||
ffmpegMetadata := buildReEnrichFFmpegMetadata(req, lyricsLRC)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"method": "ffmpeg",
|
||||
"cover_path": coverTempPath,
|
||||
"lyrics": lyricsLRC,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"metadata": map[string]string{
|
||||
"TITLE": req.TrackName,
|
||||
"ARTIST": req.ArtistName,
|
||||
"ALBUM": req.AlbumName,
|
||||
"ALBUMARTIST": req.AlbumArtist,
|
||||
"DATE": req.ReleaseDate,
|
||||
"ISRC": req.ISRC,
|
||||
"GENRE": req.Genre,
|
||||
},
|
||||
}
|
||||
if req.TrackNumber > 0 {
|
||||
result["metadata"].(map[string]string)["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
|
||||
}
|
||||
if req.DiscNumber > 0 {
|
||||
result["metadata"].(map[string]string)["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
|
||||
}
|
||||
if req.Label != "" {
|
||||
result["metadata"].(map[string]string)["ORGANIZATION"] = req.Label
|
||||
}
|
||||
if req.Copyright != "" {
|
||||
result["metadata"].(map[string]string)["COPYRIGHT"] = req.Copyright
|
||||
}
|
||||
if lyricsLRC != "" {
|
||||
result["metadata"].(map[string]string)["LYRICS"] = lyricsLRC
|
||||
result["metadata"].(map[string]string)["UNSYNCEDLYRICS"] = lyricsLRC
|
||||
"metadata": ffmpegMetadata,
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
@@ -3468,3 +3513,7 @@ func ReadAudioMetadataJSON(filePath string) (string, error) {
|
||||
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
|
||||
return ReadAudioMetadataWithDisplayName(filePath, displayName)
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) {
|
||||
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey)
|
||||
}
|
||||
|
||||
@@ -177,3 +177,48 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
AlbumArtist: "",
|
||||
ReleaseDate: "",
|
||||
TrackNumber: 0,
|
||||
DiscNumber: 0,
|
||||
ISRC: "",
|
||||
Genre: "",
|
||||
Label: "",
|
||||
Copyright: "",
|
||||
}
|
||||
|
||||
metadata := buildReEnrichFFmpegMetadata(req, "")
|
||||
|
||||
if metadata["TITLE"] != "Song" {
|
||||
t.Fatalf("title = %q", metadata["TITLE"])
|
||||
}
|
||||
if metadata["ARTIST"] != "Artist" {
|
||||
t.Fatalf("artist = %q", metadata["ARTIST"])
|
||||
}
|
||||
if metadata["ALBUM"] != "Album" {
|
||||
t.Fatalf("album = %q", metadata["ALBUM"])
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
"ALBUMARTIST",
|
||||
"DATE",
|
||||
"TRACKNUMBER",
|
||||
"DISCNUMBER",
|
||||
"ISRC",
|
||||
"GENRE",
|
||||
"ORGANIZATION",
|
||||
"COPYRIGHT",
|
||||
"LYRICS",
|
||||
"UNSYNCEDLYRICS",
|
||||
} {
|
||||
if _, exists := metadata[key]; exists {
|
||||
t.Fatalf("did not expect key %s in metadata: %#v", key, metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+62
-34
@@ -13,25 +13,26 @@ import (
|
||||
)
|
||||
|
||||
type LibraryScanResult struct {
|
||||
ID string `json:"id"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||
FilePath string `json:"filePath"`
|
||||
CoverPath string `json:"coverPath,omitempty"`
|
||||
ScannedAt string `json:"scannedAt"`
|
||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
TrackNumber int `json:"trackNumber,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
BitDepth int `json:"bitDepth,omitempty"`
|
||||
SampleRate int `json:"sampleRate,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
ID string `json:"id"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||
FilePath string `json:"filePath"`
|
||||
CoverPath string `json:"coverPath,omitempty"`
|
||||
ScannedAt string `json:"scannedAt"`
|
||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
TrackNumber int `json:"trackNumber,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
BitDepth int `json:"bitDepth,omitempty"`
|
||||
SampleRate int `json:"sampleRate,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
|
||||
}
|
||||
|
||||
type LibraryScanProgress struct {
|
||||
@@ -219,6 +220,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
cueInfo.audioPath,
|
||||
"",
|
||||
fileInfo.modTime,
|
||||
"",
|
||||
scanTime,
|
||||
)
|
||||
} else {
|
||||
@@ -269,10 +271,14 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime)
|
||||
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
||||
|
||||
result := &LibraryScanResult{
|
||||
@@ -292,7 +298,12 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" {
|
||||
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
|
||||
coverPath, err := SaveCoverToCacheWithHintAndKey(
|
||||
filePath,
|
||||
displayNameHint,
|
||||
coverCacheDir,
|
||||
coverCacheKey,
|
||||
)
|
||||
if err == nil && coverPath != "" {
|
||||
result.CoverPath = coverPath
|
||||
}
|
||||
@@ -300,11 +311,11 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result)
|
||||
return scanFLACFile(filePath, result, displayNameHint)
|
||||
case ".m4a":
|
||||
return scanM4AFile(filePath, result)
|
||||
return scanM4AFile(filePath, result, displayNameHint)
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result)
|
||||
return scanMP3File(filePath, result, displayNameHint)
|
||||
case ".opus", ".ogg":
|
||||
return scanOggFile(filePath, result, displayNameHint)
|
||||
default:
|
||||
@@ -340,10 +351,10 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
|
||||
}
|
||||
}
|
||||
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
return scanFromFilename(filePath, "", result)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
@@ -365,14 +376,19 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err == nil && metadata != nil {
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
@@ -393,15 +409,15 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadID3Tags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, "", result)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
@@ -428,7 +444,7 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -466,6 +482,7 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
}
|
||||
|
||||
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
result.MetadataFromFilename = true
|
||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||
|
||||
@@ -541,8 +558,18 @@ func ReadAudioMetadata(filePath string) (string, error) {
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
||||
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, "")
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0)
|
||||
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
|
||||
filePath,
|
||||
displayNameHint,
|
||||
coverCacheKey,
|
||||
scanTime,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -746,6 +773,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
cueInfo.audioPath,
|
||||
"",
|
||||
f.modTime,
|
||||
"",
|
||||
scanTime,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScanFromFilenameMarksMetadataFallback(t *testing.T) {
|
||||
result := &LibraryScanResult{}
|
||||
|
||||
scanned, err := scanFromFilename(
|
||||
"/proc/self/fd/209",
|
||||
"189.mp3",
|
||||
result,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("scanFromFilename returned error: %v", err)
|
||||
}
|
||||
if !scanned.MetadataFromFilename {
|
||||
t.Fatal("expected filename fallback marker to be set")
|
||||
}
|
||||
if scanned.TrackName != "189" {
|
||||
t.Fatalf("unexpected track name: %q", scanned.TrackName)
|
||||
}
|
||||
if scanned.ArtistName != "Unknown Artist" {
|
||||
t.Fatalf("unexpected artist name: %q", scanned.ArtistName)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ type LogBuffer struct {
|
||||
|
||||
const (
|
||||
defaultLogBufferSize = 500
|
||||
maxLogMessageLength = 500
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -58,14 +57,6 @@ func GetLogBuffer() *LogBuffer {
|
||||
return globalLogBuffer
|
||||
}
|
||||
|
||||
func truncateLogMessage(message string) string {
|
||||
runes := []rune(message)
|
||||
if len(runes) <= maxLogMessageLength {
|
||||
return message
|
||||
}
|
||||
return string(runes[:maxLogMessageLength]) + "...[truncated]"
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
@@ -87,7 +78,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
}
|
||||
|
||||
message = sanitizeSensitiveLogText(message)
|
||||
message = truncateLogMessage(message)
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().Format("15:04:05.000"),
|
||||
|
||||
@@ -982,7 +982,7 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
||||
|
||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
|
||||
builder.WriteString("\n")
|
||||
|
||||
if lyrics.SyncType == "LINE_SYNCED" {
|
||||
|
||||
+128
-25
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -19,6 +20,10 @@ import (
|
||||
"github.com/go-flac/go-flac/v2"
|
||||
)
|
||||
|
||||
const artistTagModeSplitVorbis = "split_vorbis"
|
||||
|
||||
var artistTagSplitPattern = regexp.MustCompile(`\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?\s*`)
|
||||
|
||||
func detectCoverMIME(coverPath string, coverData []byte) string {
|
||||
// Prefer magic-byte detection over file extension.
|
||||
// Some providers return non-JPEG data behind .jpg URLs.
|
||||
@@ -96,22 +101,23 @@ func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock,
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
AlbumArtist string
|
||||
Date string
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Description string
|
||||
Lyrics string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
Composer string
|
||||
Comment string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
AlbumArtist string
|
||||
ArtistTagMode string
|
||||
Date string
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Description string
|
||||
Lyrics string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
Composer string
|
||||
Comment string
|
||||
}
|
||||
|
||||
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
@@ -139,9 +145,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
}
|
||||
|
||||
setComment(cmt, "TITLE", metadata.Title)
|
||||
setComment(cmt, "ARTIST", metadata.Artist)
|
||||
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||
setArtistComments(
|
||||
cmt,
|
||||
"ALBUMARTIST",
|
||||
metadata.AlbumArtist,
|
||||
metadata.ArtistTagMode,
|
||||
)
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
@@ -248,9 +259,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
}
|
||||
|
||||
setComment(cmt, "TITLE", metadata.Title)
|
||||
setComment(cmt, "ARTIST", metadata.Artist)
|
||||
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||
setArtistComments(
|
||||
cmt,
|
||||
"ALBUMARTIST",
|
||||
metadata.AlbumArtist,
|
||||
metadata.ArtistTagMode,
|
||||
)
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
@@ -339,9 +355,9 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
}
|
||||
|
||||
metadata.Title = getComment(cmt, "TITLE")
|
||||
metadata.Artist = getComment(cmt, "ARTIST")
|
||||
metadata.Artist = getJoinedComment(cmt, "ARTIST")
|
||||
metadata.Album = getComment(cmt, "ALBUM")
|
||||
metadata.AlbumArtist = getComment(cmt, "ALBUMARTIST")
|
||||
metadata.AlbumArtist = getJoinedComment(cmt, "ALBUMARTIST")
|
||||
metadata.Date = getComment(cmt, "DATE")
|
||||
metadata.ISRC = getComment(cmt, "ISRC")
|
||||
metadata.Description = getComment(cmt, "DESCRIPTION")
|
||||
@@ -394,6 +410,28 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
removeCommentKey(cmt, key)
|
||||
cmt.Comments = append(cmt.Comments, key+"="+value)
|
||||
}
|
||||
|
||||
func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, mode string) {
|
||||
values := []string{value}
|
||||
if shouldSplitVorbisArtistTags(mode) {
|
||||
values = splitArtistTagValues(value)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return
|
||||
}
|
||||
removeCommentKey(cmt, key)
|
||||
for _, artist := range values {
|
||||
if strings.TrimSpace(artist) == "" {
|
||||
continue
|
||||
}
|
||||
cmt.Comments = append(cmt.Comments, key+"="+artist)
|
||||
}
|
||||
}
|
||||
|
||||
func removeCommentKey(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) {
|
||||
keyUpper := strings.ToUpper(key)
|
||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||
comment := cmt.Comments[i]
|
||||
@@ -405,20 +443,85 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
cmt.Comments = append(cmt.Comments, key+"="+value)
|
||||
}
|
||||
|
||||
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
||||
values := getCommentValues(cmt, key)
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func getJoinedComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
||||
return joinVorbisCommentValues(getCommentValues(cmt, key))
|
||||
}
|
||||
|
||||
func getCommentValues(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) []string {
|
||||
keyUpper := strings.ToUpper(key) + "="
|
||||
values := make([]string, 0, 1)
|
||||
for _, comment := range cmt.Comments {
|
||||
if len(comment) > len(key) {
|
||||
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||
if commentUpper == keyUpper {
|
||||
return comment[len(key)+1:]
|
||||
values = append(values, comment[len(key)+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return values
|
||||
}
|
||||
|
||||
func shouldSplitVorbisArtistTags(mode string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(mode), artistTagModeSplitVorbis)
|
||||
}
|
||||
|
||||
func splitArtistTagValues(rawArtists string) []string {
|
||||
trimmed := strings.TrimSpace(rawArtists)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := artistTagSplitPattern.Split(trimmed, -1)
|
||||
values := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
artist := strings.TrimSpace(part)
|
||||
if artist == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(artist)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
values = append(values, artist)
|
||||
}
|
||||
if len(values) > 0 {
|
||||
return values
|
||||
}
|
||||
return []string{trimmed}
|
||||
}
|
||||
|
||||
func joinVorbisCommentValues(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
joined := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(trimmed)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
joined = append(joined, trimmed)
|
||||
}
|
||||
return strings.Join(joined, ", ")
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/go-flac/flacvorbis/v2"
|
||||
)
|
||||
|
||||
func TestSplitArtistTagValues(t *testing.T) {
|
||||
got := splitArtistTagValues("Artist A, Artist B feat. Artist C & Artist B")
|
||||
want := []string{"Artist A", "Artist B", "Artist C"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("splitArtistTagValues() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetArtistCommentsSplitVorbis(t *testing.T) {
|
||||
cmt := flacvorbis.New()
|
||||
setArtistComments(cmt, "ARTIST", "Artist A, Artist B", artistTagModeSplitVorbis)
|
||||
|
||||
got := getCommentValues(cmt, "ARTIST")
|
||||
want := []string{"Artist A", "Artist B"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("getCommentValues(ARTIST) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVorbisCommentsJoinsRepeatedArtists(t *testing.T) {
|
||||
metadata := &AudioMetadata{}
|
||||
parseVorbisComments(
|
||||
buildVorbisCommentPayload(
|
||||
[]string{
|
||||
"TITLE=Song",
|
||||
"ARTIST=Artist A",
|
||||
"ARTIST=Artist B",
|
||||
"ALBUMARTIST=Album Artist A",
|
||||
"ALBUMARTIST=Album Artist B",
|
||||
},
|
||||
),
|
||||
metadata,
|
||||
)
|
||||
|
||||
if metadata.Title != "Song" {
|
||||
t.Fatalf("title = %q", metadata.Title)
|
||||
}
|
||||
if metadata.Artist != "Artist A, Artist B" {
|
||||
t.Fatalf("artist = %q", metadata.Artist)
|
||||
}
|
||||
if metadata.AlbumArtist != "Album Artist A, Album Artist B" {
|
||||
t.Fatalf("album artist = %q", metadata.AlbumArtist)
|
||||
}
|
||||
}
|
||||
|
||||
func buildVorbisCommentPayload(comments []string) []byte {
|
||||
var buf bytes.Buffer
|
||||
_ = binary.Write(&buf, binary.LittleEndian, uint32(len("spotiflac")))
|
||||
buf.WriteString("spotiflac")
|
||||
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(comments)))
|
||||
for _, comment := range comments {
|
||||
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(comment)))
|
||||
buf.WriteString(comment)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
+278
-18
@@ -13,6 +13,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -1650,7 +1651,8 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
@@ -1664,6 +1666,234 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
|
||||
return result.Tracks.Items, nil
|
||||
}
|
||||
|
||||
type qobuzTrackSearchCandidate struct {
|
||||
score int
|
||||
track QobuzTrack
|
||||
}
|
||||
|
||||
func qobuzNormalizedSearchText(value string) string {
|
||||
return normalizeLooseArtistName(value)
|
||||
}
|
||||
|
||||
func qobuzSearchTokens(value string) []string {
|
||||
normalized := qobuzNormalizedSearchText(value)
|
||||
if normalized == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Fields(normalized)
|
||||
tokens := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
if len(part) < 2 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[part]; ok {
|
||||
continue
|
||||
}
|
||||
seen[part] = struct{}{}
|
||||
tokens = append(tokens, part)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func qobuzScoreTrackSearchCandidate(query string, track *QobuzTrack) int {
|
||||
if track == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
queryNorm := qobuzNormalizedSearchText(query)
|
||||
if queryNorm == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
titleNorm := qobuzNormalizedSearchText(track.Title)
|
||||
displayNorm := qobuzNormalizedSearchText(qobuzTrackDisplayTitle(track))
|
||||
artistNorm := qobuzNormalizedSearchText(qobuzTrackArtistName(track))
|
||||
albumNorm := qobuzNormalizedSearchText(strings.TrimSpace(track.Album.Title))
|
||||
|
||||
score := 0
|
||||
|
||||
if qobuzTitlesMatch(query, track.Title) || qobuzTitlesMatch(query, qobuzTrackDisplayTitle(track)) {
|
||||
score += 900
|
||||
}
|
||||
|
||||
switch {
|
||||
case queryNorm == titleNorm, queryNorm == displayNorm:
|
||||
score += 1200
|
||||
case (titleNorm != "" && strings.Contains(titleNorm, queryNorm)) ||
|
||||
(displayNorm != "" && strings.Contains(displayNorm, queryNorm)):
|
||||
score += 420
|
||||
case (titleNorm != "" && strings.Contains(queryNorm, titleNorm)) ||
|
||||
(displayNorm != "" && strings.Contains(queryNorm, displayNorm)):
|
||||
score += 260
|
||||
}
|
||||
|
||||
if artistNorm != "" && strings.Contains(queryNorm, artistNorm) {
|
||||
score += 180
|
||||
}
|
||||
if albumNorm != "" && strings.Contains(queryNorm, albumNorm) {
|
||||
score += 100
|
||||
}
|
||||
|
||||
for _, token := range qobuzSearchTokens(query) {
|
||||
switch {
|
||||
case strings.Contains(titleNorm, token), strings.Contains(displayNorm, token):
|
||||
score += 180
|
||||
case strings.Contains(artistNorm, token):
|
||||
score += 70
|
||||
case strings.Contains(albumNorm, token):
|
||||
score += 35
|
||||
}
|
||||
}
|
||||
|
||||
if track.ISRC != "" {
|
||||
score += 15
|
||||
}
|
||||
if track.MaximumBitDepth >= 24 {
|
||||
score += 10
|
||||
}
|
||||
if track.MaximumSamplingRate >= 88.2 {
|
||||
score += 10
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func selectQobuzTracksFromAlbumSearchResults(
|
||||
query string,
|
||||
limit int,
|
||||
albumSummaries []qobuzAlbumDetails,
|
||||
loadAlbum func(string) (*qobuzAlbumDetails, error),
|
||||
) ([]QobuzTrack, error) {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("empty qobuz album-search fallback query")
|
||||
}
|
||||
if len(albumSummaries) == 0 {
|
||||
return nil, fmt.Errorf("album search returned no albums")
|
||||
}
|
||||
|
||||
candidates := make([]qobuzTrackSearchCandidate, 0, limit)
|
||||
seenTrackIDs := make(map[int64]struct{})
|
||||
|
||||
for _, summary := range albumSummaries {
|
||||
albumID := strings.TrimSpace(summary.ID)
|
||||
if albumID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
album, err := loadAlbum(albumID)
|
||||
if err != nil || album == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range album.Tracks.Items {
|
||||
track := album.Tracks.Items[i]
|
||||
track.Album.ID = album.ID
|
||||
track.Album.QobuzID = album.QobuzID
|
||||
track.Album.Title = album.Title
|
||||
track.Album.ReleaseDate = album.ReleaseDateOriginal
|
||||
track.Album.TracksCount = album.TracksCount
|
||||
track.Album.ProductType = album.ProductType
|
||||
track.Album.ReleaseType = album.ReleaseType
|
||||
track.Album.Artist.ID = album.Artist.ID
|
||||
track.Album.Artist.Name = album.Artist.Name
|
||||
track.Album.Artists = album.Artists
|
||||
track.Album.Image = album.Image
|
||||
|
||||
if track.ID > 0 {
|
||||
if _, ok := seenTrackIDs[track.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seenTrackIDs[track.ID] = struct{}{}
|
||||
}
|
||||
|
||||
score := qobuzScoreTrackSearchCandidate(query, &track)
|
||||
if score <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
candidates = append(candidates, qobuzTrackSearchCandidate{
|
||||
score: score,
|
||||
track: track,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil, fmt.Errorf("album-search fallback returned no scored track candidates")
|
||||
}
|
||||
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
if candidates[i].score != candidates[j].score {
|
||||
return candidates[i].score > candidates[j].score
|
||||
}
|
||||
if candidates[i].track.MaximumBitDepth != candidates[j].track.MaximumBitDepth {
|
||||
return candidates[i].track.MaximumBitDepth > candidates[j].track.MaximumBitDepth
|
||||
}
|
||||
return candidates[i].track.ID < candidates[j].track.ID
|
||||
})
|
||||
|
||||
if limit > 0 && len(candidates) > limit {
|
||||
candidates = candidates[:limit]
|
||||
}
|
||||
|
||||
tracks := make([]QobuzTrack, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
tracks = append(tracks, candidate.track)
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearch(query string, limit int) ([]QobuzTrack, error) {
|
||||
albumLimit := limit
|
||||
if albumLimit < 3 {
|
||||
albumLimit = 3
|
||||
}
|
||||
if albumLimit > 8 {
|
||||
albumLimit = 8
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
|
||||
url.QueryEscape(strings.TrimSpace(query)),
|
||||
albumLimit,
|
||||
q.appID,
|
||||
)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("album search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var albumResp struct {
|
||||
Albums struct {
|
||||
Items []qobuzAlbumDetails `json:"items"`
|
||||
} `json:"albums"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&albumResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return selectQobuzTracksFromAlbumSearchResults(
|
||||
query,
|
||||
limit,
|
||||
albumResp.Albums.Items,
|
||||
q.getAlbumDetails,
|
||||
)
|
||||
}
|
||||
|
||||
func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 {
|
||||
matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1)
|
||||
if len(matches) == 0 {
|
||||
@@ -1741,9 +1971,18 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int)
|
||||
if len(apiTracks) > 0 {
|
||||
return apiTracks, nil
|
||||
}
|
||||
GoLog("[Qobuz] API search returned 0 results for '%s', trying store fallback\n", query)
|
||||
GoLog("[Qobuz] API search returned 0 results for '%s', trying album-search fallback\n", query)
|
||||
} else {
|
||||
GoLog("[Qobuz] API search failed for '%s': %v. Trying store fallback.\n", query, apiErr)
|
||||
GoLog("[Qobuz] API search failed for '%s': %v. Trying album-search fallback.\n", query, apiErr)
|
||||
}
|
||||
|
||||
albumTracks, albumErr := q.searchQobuzTracksViaAlbumSearch(query, limit)
|
||||
if albumErr == nil && len(albumTracks) > 0 {
|
||||
GoLog("[Qobuz] Album-search fallback returned %d candidate tracks for '%s'\n", len(albumTracks), query)
|
||||
return albumTracks, nil
|
||||
}
|
||||
if albumErr != nil {
|
||||
GoLog("[Qobuz] Album-search fallback failed for '%s': %v. Trying store fallback.\n", query, albumErr)
|
||||
}
|
||||
|
||||
storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit)
|
||||
@@ -1752,10 +1991,21 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int)
|
||||
return storeTracks, nil
|
||||
}
|
||||
|
||||
if apiErr != nil && storeErr != nil {
|
||||
return nil, fmt.Errorf("api search failed (%v); store fallback failed (%v)", apiErr, storeErr)
|
||||
if apiErr != nil && albumErr != nil && storeErr != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"api search failed (%v); album-search fallback failed (%v); store fallback failed (%v)",
|
||||
apiErr,
|
||||
albumErr,
|
||||
storeErr,
|
||||
)
|
||||
}
|
||||
if albumErr == nil && len(albumTracks) == 0 && storeErr != nil {
|
||||
return nil, storeErr
|
||||
}
|
||||
if storeErr != nil {
|
||||
if albumErr != nil {
|
||||
return nil, albumErr
|
||||
}
|
||||
return nil, storeErr
|
||||
}
|
||||
return nil, fmt.Errorf("no tracks found for query: %s", query)
|
||||
@@ -2335,18 +2585,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: track.Title,
|
||||
Artist: track.Performer.Name,
|
||||
Album: albumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: releaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Title: track.Title,
|
||||
Artist: req.ArtistName,
|
||||
Album: albumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
Date: releaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
@@ -2411,6 +2662,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
req.DiscNumber,
|
||||
)
|
||||
|
||||
// Prefer the cover URL the frontend sent (user-selected album) over the
|
||||
// track's default album cover returned by the Qobuz track/get API, which
|
||||
// may belong to a different album when the same track appears on multiple
|
||||
// releases.
|
||||
resultCoverURL := strings.TrimSpace(req.CoverURL)
|
||||
if resultCoverURL == "" {
|
||||
resultCoverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
|
||||
}
|
||||
|
||||
return QobuzDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: actualBitDepth,
|
||||
@@ -2422,7 +2682,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
TrackNumber: resultTrackNumber,
|
||||
DiscNumber: resultDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)),
|
||||
CoverURL: resultCoverURL,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,21 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func buildTestQobuzAlbum(id, title, artist string, tracks ...QobuzTrack) *qobuzAlbumDetails {
|
||||
album := &qobuzAlbumDetails{
|
||||
ID: id,
|
||||
Title: title,
|
||||
ReleaseDateOriginal: "2013-05-20",
|
||||
TracksCount: len(tracks),
|
||||
ProductType: "album",
|
||||
ReleaseType: "album",
|
||||
}
|
||||
album.Artist = qobuzArtistRef{ID: 1, Name: artist}
|
||||
album.Artists = []qobuzArtistRef{{ID: 1, Name: artist}}
|
||||
album.Tracks.Items = tracks
|
||||
return album
|
||||
}
|
||||
|
||||
func TestParseQobuzURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -276,6 +291,68 @@ func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
|
||||
return track
|
||||
}
|
||||
|
||||
func TestSelectQobuzTracksFromAlbumSearchResultsPrefersMatchingTrack(t *testing.T) {
|
||||
summaries := []qobuzAlbumDetails{
|
||||
{ID: "album-a"},
|
||||
{ID: "album-b"},
|
||||
}
|
||||
|
||||
match := *testQobuzTrack(1, "Get Lucky", "Daft Punk", 369)
|
||||
other := *testQobuzTrack(2, "Fragments of Time", "Daft Punk", 280)
|
||||
fallback := *testQobuzTrack(3, "Da Funk", "Daft Punk", 330)
|
||||
|
||||
albums := map[string]*qobuzAlbumDetails{
|
||||
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", match, other),
|
||||
"album-b": buildTestQobuzAlbum("album-b", "Homework", "Daft Punk", fallback),
|
||||
}
|
||||
|
||||
tracks, err := selectQobuzTracksFromAlbumSearchResults(
|
||||
"daft punk get lucky",
|
||||
3,
|
||||
summaries,
|
||||
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(tracks) == 0 {
|
||||
t.Fatal("expected tracks, got none")
|
||||
}
|
||||
if tracks[0].ID != 1 {
|
||||
t.Fatalf("expected Get Lucky to rank first, got track id %d", tracks[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectQobuzTracksFromAlbumSearchResultsDedupesTracks(t *testing.T) {
|
||||
summaries := []qobuzAlbumDetails{
|
||||
{ID: "album-a"},
|
||||
{ID: "album-b"},
|
||||
}
|
||||
|
||||
shared := *testQobuzTrack(42, "Get Lucky", "Daft Punk", 369)
|
||||
|
||||
albums := map[string]*qobuzAlbumDetails{
|
||||
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", shared),
|
||||
"album-b": buildTestQobuzAlbum("album-b", "Random Access Memories Deluxe", "Daft Punk", shared),
|
||||
}
|
||||
|
||||
tracks, err := selectQobuzTracksFromAlbumSearchResults(
|
||||
"daft punk get lucky",
|
||||
5,
|
||||
summaries,
|
||||
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("expected 1 deduped track, got %d", len(tracks))
|
||||
}
|
||||
if tracks[0].ID != 42 {
|
||||
t.Fatalf("unexpected deduped track id: %d", tracks[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
|
||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||
|
||||
+13
-12
@@ -2354,18 +2354,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: releaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: actualDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
Date: releaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: actualDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '4.1.2';
|
||||
static const String buildNumber = '119';
|
||||
static const String version = '4.1.3';
|
||||
static const String buildNumber = '120';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
/// Shows "Internal" in debug builds, actual version in release.
|
||||
|
||||
@@ -400,6 +400,42 @@ abstract class AppLocalizations {
|
||||
/// **'Download highest resolution cover art'**
|
||||
String get optionsMaxQualityCoverSubtitle;
|
||||
|
||||
/// Setting title for how artist metadata is written into files
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist Tag Mode'**
|
||||
String get optionsArtistTagMode;
|
||||
|
||||
/// Bottom-sheet description for artist tag mode setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose how multiple artists are written into embedded tags.'**
|
||||
String get optionsArtistTagModeDescription;
|
||||
|
||||
/// Artist tag mode option that joins multiple artists into one value
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Single joined value'**
|
||||
String get optionsArtistTagModeJoined;
|
||||
|
||||
/// Subtitle for joined artist tag mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'**
|
||||
String get optionsArtistTagModeJoinedSubtitle;
|
||||
|
||||
/// Artist tag mode option that writes repeated ARTIST tags for Vorbis formats
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Split tags for FLAC/Opus'**
|
||||
String get optionsArtistTagModeSplitVorbis;
|
||||
|
||||
/// Subtitle for split Vorbis artist tag mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'**
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle;
|
||||
|
||||
/// Number of parallel downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3334,6 +3370,12 @@ abstract class AppLocalizations {
|
||||
/// **'{count, plural, =1{track} other{tracks}}'**
|
||||
String libraryTracksUnit(int count);
|
||||
|
||||
/// Unit label for files count during library scanning
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{file} other{files}}'**
|
||||
String libraryFilesUnit(int count);
|
||||
|
||||
/// Last scan time display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3352,6 +3394,12 @@ abstract class AppLocalizations {
|
||||
/// **'Scanning...'**
|
||||
String get libraryScanning;
|
||||
|
||||
/// Status shown after file scanning finishes but library persistence is still running
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Finalizing library...'**
|
||||
String get libraryScanFinalizing;
|
||||
|
||||
/// Scan progress display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3478,6 +3526,42 @@ abstract class AppLocalizations {
|
||||
/// **'Format'**
|
||||
String get libraryFilterFormat;
|
||||
|
||||
/// Filter section - metadata completeness
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Metadata'**
|
||||
String get libraryFilterMetadata;
|
||||
|
||||
/// Filter option - items with complete metadata
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Complete metadata'**
|
||||
String get libraryFilterMetadataComplete;
|
||||
|
||||
/// Filter option - items missing any tracked metadata field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Missing any metadata'**
|
||||
String get libraryFilterMetadataMissingAny;
|
||||
|
||||
/// Filter option - items missing release year/date
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Missing year'**
|
||||
String get libraryFilterMetadataMissingYear;
|
||||
|
||||
/// Filter option - items missing genre
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Missing genre'**
|
||||
String get libraryFilterMetadataMissingGenre;
|
||||
|
||||
/// Filter option - items missing album artist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Missing album artist'**
|
||||
String get libraryFilterMetadataMissingAlbumArtist;
|
||||
|
||||
/// Filter section - sort order
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3496,6 +3580,30 @@ abstract class AppLocalizations {
|
||||
/// **'Oldest'**
|
||||
String get libraryFilterSortOldest;
|
||||
|
||||
/// Sort option - album ascending
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album (A-Z)'**
|
||||
String get libraryFilterSortAlbumAsc;
|
||||
|
||||
/// Sort option - album descending
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album (Z-A)'**
|
||||
String get libraryFilterSortAlbumDesc;
|
||||
|
||||
/// Sort option - genre ascending
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Genre (A-Z)'**
|
||||
String get libraryFilterSortGenreAsc;
|
||||
|
||||
/// Sort option - genre descending
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Genre (Z-A)'**
|
||||
String get libraryFilterSortGenreDesc;
|
||||
|
||||
/// Relative time - less than a minute ago
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -158,6 +158,27 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Cover in höchster Auflösung herunterladen';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Parallele Downloads';
|
||||
|
||||
@@ -1851,6 +1872,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Zuletzt gescannt: $time';
|
||||
@@ -1862,6 +1894,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Scannen...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% von $total Dateien';
|
||||
@@ -1930,6 +1965,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sortieren';
|
||||
|
||||
@@ -1939,6 +1992,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Älteste';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Gerade eben';
|
||||
|
||||
|
||||
@@ -154,6 +154,27 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
|
||||
@@ -154,6 +154,27 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
|
||||
@@ -156,6 +156,27 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@@ -1825,6 +1846,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -1836,6 +1868,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
@@ -1904,6 +1939,24 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@@ -1913,6 +1966,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
|
||||
@@ -154,6 +154,27 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
|
||||
@@ -158,6 +158,27 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Unduh cover art resolusi tertinggi';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Unduhan Bersamaan';
|
||||
|
||||
@@ -1833,6 +1854,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -1844,6 +1876,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
@@ -1912,6 +1947,24 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@@ -1921,6 +1974,18 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
|
||||
@@ -152,6 +152,27 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => '同時ダウンロード';
|
||||
|
||||
@@ -1810,6 +1831,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return '最終スキャン: $time';
|
||||
@@ -1821,6 +1853,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'スキャン中...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
@@ -1889,6 +1924,24 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => '形式';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@@ -1898,6 +1951,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
|
||||
@@ -148,6 +148,27 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => '동시 다운로드';
|
||||
|
||||
@@ -1803,6 +1824,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -1814,6 +1846,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
@@ -1882,6 +1917,24 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@@ -1891,6 +1944,18 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
|
||||
@@ -154,6 +154,27 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
|
||||
@@ -154,6 +154,27 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
|
||||
@@ -159,6 +159,27 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Скачивать обложку в макс. разрешении';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Одновременные загрузки';
|
||||
|
||||
@@ -1861,6 +1882,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Последнее сканирование: $time';
|
||||
@@ -1872,6 +1904,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Сканирование...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% из $total файлов';
|
||||
@@ -1948,6 +1983,24 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Формат';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Сортировка';
|
||||
|
||||
@@ -1957,6 +2010,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Старые';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Только что';
|
||||
|
||||
|
||||
@@ -157,6 +157,27 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'En yüksek kalitedeki albüm kapaklarını indir';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Eş Zamanlı İndirmeler';
|
||||
|
||||
@@ -1829,6 +1850,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -1840,6 +1872,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
@@ -1908,6 +1943,24 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@@ -1917,6 +1970,18 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
|
||||
@@ -154,6 +154,27 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryFilesUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
|
||||
@@ -190,6 +190,30 @@
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"description": "Subtitle for max quality cover"
|
||||
},
|
||||
"optionsArtistTagMode": "Artist Tag Mode",
|
||||
"@optionsArtistTagMode": {
|
||||
"description": "Setting title for how artist metadata is written into files"
|
||||
},
|
||||
"optionsArtistTagModeDescription": "Choose how multiple artists are written into embedded tags.",
|
||||
"@optionsArtistTagModeDescription": {
|
||||
"description": "Bottom-sheet description for artist tag mode setting"
|
||||
},
|
||||
"optionsArtistTagModeJoined": "Single joined value",
|
||||
"@optionsArtistTagModeJoined": {
|
||||
"description": "Artist tag mode option that joins multiple artists into one value"
|
||||
},
|
||||
"optionsArtistTagModeJoinedSubtitle": "Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.",
|
||||
"@optionsArtistTagModeJoinedSubtitle": {
|
||||
"description": "Subtitle for joined artist tag mode"
|
||||
},
|
||||
"optionsArtistTagModeSplitVorbis": "Split tags for FLAC/Opus",
|
||||
"@optionsArtistTagModeSplitVorbis": {
|
||||
"description": "Artist tag mode option that writes repeated ARTIST tags for Vorbis formats"
|
||||
},
|
||||
"optionsArtistTagModeSplitVorbisSubtitle": "Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.",
|
||||
"@optionsArtistTagModeSplitVorbisSubtitle": {
|
||||
"description": "Subtitle for split Vorbis artist tag mode"
|
||||
},
|
||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||
"@optionsConcurrentDownloads": {
|
||||
"description": "Number of parallel downloads"
|
||||
@@ -2399,6 +2423,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryFilesUnit": "{count, plural, =1{file} other{files}}",
|
||||
"@libraryFilesUnit": {
|
||||
"description": "Unit label for files count during library scanning",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryLastScanned": "Last scanned: {time}",
|
||||
"@libraryLastScanned": {
|
||||
"description": "Last scan time display",
|
||||
@@ -2416,6 +2449,10 @@
|
||||
"@libraryScanning": {
|
||||
"description": "Status during scan"
|
||||
},
|
||||
"libraryScanFinalizing": "Finalizing library...",
|
||||
"@libraryScanFinalizing": {
|
||||
"description": "Status shown after file scanning finishes but library persistence is still running"
|
||||
},
|
||||
"libraryScanProgress": "{progress}% of {total} files",
|
||||
"@libraryScanProgress": {
|
||||
"description": "Scan progress display",
|
||||
@@ -2513,6 +2550,30 @@
|
||||
"@libraryFilterFormat": {
|
||||
"description": "Filter section - file format"
|
||||
},
|
||||
"libraryFilterMetadata": "Metadata",
|
||||
"@libraryFilterMetadata": {
|
||||
"description": "Filter section - metadata completeness"
|
||||
},
|
||||
"libraryFilterMetadataComplete": "Complete metadata",
|
||||
"@libraryFilterMetadataComplete": {
|
||||
"description": "Filter option - items with complete metadata"
|
||||
},
|
||||
"libraryFilterMetadataMissingAny": "Missing any metadata",
|
||||
"@libraryFilterMetadataMissingAny": {
|
||||
"description": "Filter option - items missing any tracked metadata field"
|
||||
},
|
||||
"libraryFilterMetadataMissingYear": "Missing year",
|
||||
"@libraryFilterMetadataMissingYear": {
|
||||
"description": "Filter option - items missing release year/date"
|
||||
},
|
||||
"libraryFilterMetadataMissingGenre": "Missing genre",
|
||||
"@libraryFilterMetadataMissingGenre": {
|
||||
"description": "Filter option - items missing genre"
|
||||
},
|
||||
"libraryFilterMetadataMissingAlbumArtist": "Missing album artist",
|
||||
"@libraryFilterMetadataMissingAlbumArtist": {
|
||||
"description": "Filter option - items missing album artist"
|
||||
},
|
||||
"libraryFilterSort": "Sort",
|
||||
"@libraryFilterSort": {
|
||||
"description": "Filter section - sort order"
|
||||
@@ -2525,6 +2586,22 @@
|
||||
"@libraryFilterSortOldest": {
|
||||
"description": "Sort option - oldest first"
|
||||
},
|
||||
"libraryFilterSortAlbumAsc": "Album (A-Z)",
|
||||
"@libraryFilterSortAlbumAsc": {
|
||||
"description": "Sort option - album ascending"
|
||||
},
|
||||
"libraryFilterSortAlbumDesc": "Album (Z-A)",
|
||||
"@libraryFilterSortAlbumDesc": {
|
||||
"description": "Sort option - album descending"
|
||||
},
|
||||
"libraryFilterSortGenreAsc": "Genre (A-Z)",
|
||||
"@libraryFilterSortGenreAsc": {
|
||||
"description": "Sort option - genre ascending"
|
||||
},
|
||||
"libraryFilterSortGenreDesc": "Genre (Z-A)",
|
||||
"@libraryFilterSortGenreDesc": {
|
||||
"description": "Sort option - genre descending"
|
||||
},
|
||||
"timeJustNow": "Just now",
|
||||
"@timeJustNow": {
|
||||
"description": "Relative time - less than a minute ago"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
|
||||
part 'settings.g.dart';
|
||||
|
||||
@@ -12,6 +13,8 @@ class AppSettings {
|
||||
final String downloadTreeUri; // SAF persistable tree URI
|
||||
final bool autoFallback;
|
||||
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
|
||||
final String
|
||||
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
|
||||
final bool embedLyrics;
|
||||
final bool maxQualityCover;
|
||||
final bool isFirstLaunch;
|
||||
@@ -88,6 +91,7 @@ class AppSettings {
|
||||
this.downloadTreeUri = '',
|
||||
this.autoFallback = true,
|
||||
this.embedMetadata = true,
|
||||
this.artistTagMode = artistTagModeJoined,
|
||||
this.embedLyrics = true,
|
||||
this.maxQualityCover = true,
|
||||
this.isFirstLaunch = true,
|
||||
@@ -152,6 +156,7 @@ class AppSettings {
|
||||
String? downloadTreeUri,
|
||||
bool? autoFallback,
|
||||
bool? embedMetadata,
|
||||
String? artistTagMode,
|
||||
bool? embedLyrics,
|
||||
bool? maxQualityCover,
|
||||
bool? isFirstLaunch,
|
||||
@@ -210,6 +215,7 @@ class AppSettings {
|
||||
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
||||
autoFallback: autoFallback ?? this.autoFallback,
|
||||
embedMetadata: embedMetadata ?? this.embedMetadata,
|
||||
artistTagMode: artistTagMode ?? this.artistTagMode,
|
||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
|
||||
@@ -15,6 +15,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
|
||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||
embedMetadata: json['embedMetadata'] as bool? ?? true,
|
||||
artistTagMode: json['artistTagMode'] as String? ?? 'joined',
|
||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
@@ -93,6 +94,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'downloadTreeUri': instance.downloadTreeUri,
|
||||
'autoFallback': instance.autoFallback,
|
||||
'embedMetadata': instance.embedMetadata,
|
||||
'artistTagMode': instance.artistTagMode,
|
||||
'embedLyrics': instance.embedLyrics,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
|
||||
@@ -2996,6 +2996,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
? coverPath
|
||||
: null,
|
||||
metadata: metadata,
|
||||
artistTagMode: settings.artistTagMode,
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
@@ -3328,6 +3329,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
? coverPath
|
||||
: null,
|
||||
metadata: metadata,
|
||||
artistTagMode: settings.artistTagMode,
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
@@ -4215,6 +4217,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: quality,
|
||||
embedMetadata: metadataEmbeddingEnabled,
|
||||
artistTagMode: settings.artistTagMode,
|
||||
embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics,
|
||||
embedMaxQualityCover:
|
||||
metadataEmbeddingEnabled && settings.maxQualityCover,
|
||||
|
||||
@@ -20,6 +20,7 @@ final _prefs = SharedPreferences.getInstance();
|
||||
class LocalLibraryState {
|
||||
final List<LocalLibraryItem> items;
|
||||
final bool isScanning;
|
||||
final bool scanIsFinalizing;
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
final int scanTotalFiles;
|
||||
@@ -35,6 +36,7 @@ class LocalLibraryState {
|
||||
LocalLibraryState({
|
||||
this.items = const [],
|
||||
this.isScanning = false,
|
||||
this.scanIsFinalizing = false,
|
||||
this.scanProgress = 0,
|
||||
this.scanCurrentFile,
|
||||
this.scanTotalFiles = 0,
|
||||
@@ -85,6 +87,7 @@ class LocalLibraryState {
|
||||
LocalLibraryState copyWith({
|
||||
List<LocalLibraryItem>? items,
|
||||
bool? isScanning,
|
||||
bool? scanIsFinalizing,
|
||||
double? scanProgress,
|
||||
String? scanCurrentFile,
|
||||
int? scanTotalFiles,
|
||||
@@ -100,6 +103,7 @@ class LocalLibraryState {
|
||||
return LocalLibraryState(
|
||||
items: nextItems,
|
||||
isScanning: isScanning ?? this.isScanning,
|
||||
scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing,
|
||||
scanProgress: scanProgress ?? this.scanProgress,
|
||||
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
||||
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
|
||||
@@ -120,7 +124,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
static const _progressPollingInterval = Duration(milliseconds: 1200);
|
||||
static const _progressPollingInterval = Duration(milliseconds: 350);
|
||||
static const _progressStreamBootstrapTimeout = Duration(milliseconds: 900);
|
||||
Timer? _progressTimer;
|
||||
Timer? _progressStreamBootstrapTimer;
|
||||
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
|
||||
@@ -220,6 +225,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
state = state.copyWith(
|
||||
isScanning: true,
|
||||
scanIsFinalizing: false,
|
||||
scanProgress: 0,
|
||||
scanCurrentFile: null,
|
||||
scanTotalFiles: 0,
|
||||
@@ -297,11 +303,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
||||
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
||||
if (_scanCancelRequested) {
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanWasCancelled: true,
|
||||
);
|
||||
await _showScanCancelledNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
scanIsFinalizing: true,
|
||||
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
|
||||
scanCurrentFile: null,
|
||||
);
|
||||
|
||||
final items = <LocalLibraryItem>[];
|
||||
int skippedDownloads = 0;
|
||||
for (final json in results) {
|
||||
@@ -334,11 +350,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
state = state.copyWith(
|
||||
items: persistedItems,
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
scanWasCancelled: false,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
);
|
||||
await _pruneLibraryCoverCache(persistedItems);
|
||||
|
||||
_log.i(
|
||||
'Full scan complete: ${persistedItems.length} tracks found, '
|
||||
@@ -403,11 +421,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
if (_scanCancelRequested) {
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanWasCancelled: true,
|
||||
);
|
||||
await _showScanCancelledNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
scanIsFinalizing: true,
|
||||
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
|
||||
scanCurrentFile: null,
|
||||
);
|
||||
|
||||
final scannedList =
|
||||
(result['files'] as List<dynamic>?) ??
|
||||
(result['scanned'] as List<dynamic>?) ??
|
||||
@@ -497,6 +525,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
scanWasCancelled: false,
|
||||
@@ -516,7 +545,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_log.e('Library scan failed: $e', e, stack);
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
await _showScanFailedNotification(e.toString());
|
||||
} finally {
|
||||
if (didStartSecurityAccess) {
|
||||
@@ -573,16 +606,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
_progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (_hasReceivedProgressStreamEvent) {
|
||||
return;
|
||||
}
|
||||
_log.w('Library scan progress stream timeout, fallback to polling');
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_usingProgressStream = false;
|
||||
_startProgressPollingTimer();
|
||||
});
|
||||
Future<void>.microtask(_requestProgressSnapshot);
|
||||
|
||||
_progressStreamBootstrapTimer = Timer(
|
||||
_progressStreamBootstrapTimeout,
|
||||
() {
|
||||
if (_hasReceivedProgressStreamEvent) {
|
||||
return;
|
||||
}
|
||||
_log.w('Library scan progress stream timeout, fallback to polling');
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_usingProgressStream = false;
|
||||
_startProgressPollingTimer();
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -609,20 +647,41 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _requestProgressSnapshot() async {
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
await _handleLibraryScanProgress(progress);
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Initial library scan progress fetch failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async {
|
||||
final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||
0.0,
|
||||
100.0,
|
||||
);
|
||||
final isComplete = progress['is_complete'] == true;
|
||||
final displayProgress = isComplete
|
||||
? 99.0
|
||||
: (normalizedProgress >= 100.0 ? 99.0 : normalizedProgress);
|
||||
final currentFile = progress['current_file'] as String?;
|
||||
final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0;
|
||||
final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0;
|
||||
final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0;
|
||||
final isComplete = progress['is_complete'] == true;
|
||||
|
||||
final shouldUpdateState =
|
||||
state.scanProgress != normalizedProgress ||
|
||||
state.scanProgress != displayProgress ||
|
||||
state.scanIsFinalizing != isComplete ||
|
||||
state.scanCurrentFile != currentFile ||
|
||||
state.scanTotalFiles != totalFiles ||
|
||||
state.scannedFiles != scannedFiles ||
|
||||
@@ -630,8 +689,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
if (shouldUpdateState) {
|
||||
state = state.copyWith(
|
||||
scanProgress: normalizedProgress,
|
||||
scanCurrentFile: currentFile,
|
||||
scanIsFinalizing: isComplete,
|
||||
scanProgress: displayProgress,
|
||||
scanCurrentFile: isComplete ? null : currentFile,
|
||||
scanTotalFiles: totalFiles,
|
||||
scannedFiles: scannedFiles,
|
||||
scanErrorCount: errorCount,
|
||||
@@ -704,7 +764,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Cancelling library scan');
|
||||
_scanCancelRequested = true;
|
||||
await PlatformBridge.cancelLibraryScan();
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanWasCancelled: true,
|
||||
);
|
||||
_stopProgressPolling();
|
||||
await _showScanCancelledNotification();
|
||||
}
|
||||
@@ -815,6 +879,46 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Library cleared');
|
||||
}
|
||||
|
||||
Future<void> _pruneLibraryCoverCache(Iterable<LocalLibraryItem> items) async {
|
||||
try {
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
|
||||
if (!await libraryCoverDir.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final referencedCoverPaths = items
|
||||
.map((item) => item.coverPath)
|
||||
.whereType<String>()
|
||||
.where((path) => path.isNotEmpty)
|
||||
.toSet();
|
||||
|
||||
var deletedCount = 0;
|
||||
await for (final entity in libraryCoverDir.list(
|
||||
recursive: true,
|
||||
followLinks: false,
|
||||
)) {
|
||||
if (entity is! File || referencedCoverPaths.contains(entity.path)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await entity.delete();
|
||||
deletedCount++;
|
||||
} catch (e) {
|
||||
_log.w(
|
||||
'Failed deleting stale library cover cache ${entity.path}: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
_log.i('Pruned $deletedCount stale library cover cache files');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed pruning library cover cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeItem(String id) async {
|
||||
await _db.delete(id);
|
||||
state = state.copyWith(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -260,6 +261,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setArtistTagMode(String mode) {
|
||||
if (mode == artistTagModeJoined || mode == artistTagModeSplitVorbis) {
|
||||
state = state.copyWith(artistTagMode: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
void setLyricsMode(String mode) {
|
||||
if (mode == 'embed' || mode == 'external' || mode == 'both') {
|
||||
state = state.copyWith(lyricsMode: mode);
|
||||
|
||||
@@ -1235,6 +1235,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
bitrate: bitrate,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
artistTagMode: settings.artistTagMode,
|
||||
deleteOriginal: !isSaf,
|
||||
);
|
||||
|
||||
|
||||
@@ -817,6 +817,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
lowerPath.endsWith('.opus') ||
|
||||
lowerPath.endsWith('.ogg');
|
||||
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
String? ffmpegResult;
|
||||
if (isMp3) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
||||
@@ -835,6 +836,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
opusPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -867,11 +869,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
Future<bool> _reEnrichLocalTrack(LocalLibraryItem item) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final request = <String, dynamic>{
|
||||
'file_path': item.filePath,
|
||||
'cover_url': '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': true,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': '',
|
||||
'track_name': item.trackName,
|
||||
'artist_name': item.artistName,
|
||||
@@ -1510,6 +1514,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
bitrate: bitrate,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
artistTagMode: settings.artistTagMode,
|
||||
deleteOriginal: !isSaf,
|
||||
);
|
||||
|
||||
|
||||
+598
-4
@@ -124,6 +124,12 @@ class UnifiedLibraryItem {
|
||||
coverUrl != null ||
|
||||
(localCoverPath != null && localCoverPath!.isNotEmpty);
|
||||
|
||||
String? get albumArtist => historyItem?.albumArtist ?? localItem?.albumArtist;
|
||||
|
||||
String? get releaseDate => historyItem?.releaseDate ?? localItem?.releaseDate;
|
||||
|
||||
String? get genre => historyItem?.genre ?? localItem?.genre;
|
||||
|
||||
String get searchKey =>
|
||||
'${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}';
|
||||
String get albumKey =>
|
||||
@@ -319,6 +325,7 @@ class _QueueGroupedAlbumFilterRequest {
|
||||
final String? filterSource;
|
||||
final String? filterQuality;
|
||||
final String? filterFormat;
|
||||
final String? filterMetadata;
|
||||
final String sortMode;
|
||||
|
||||
const _QueueGroupedAlbumFilterRequest({
|
||||
@@ -326,6 +333,7 @@ class _QueueGroupedAlbumFilterRequest {
|
||||
required this.filterSource,
|
||||
required this.filterQuality,
|
||||
required this.filterFormat,
|
||||
required this.filterMetadata,
|
||||
required this.sortMode,
|
||||
});
|
||||
|
||||
@@ -337,6 +345,7 @@ class _QueueGroupedAlbumFilterRequest {
|
||||
filterSource == other.filterSource &&
|
||||
filterQuality == other.filterQuality &&
|
||||
filterFormat == other.filterFormat &&
|
||||
filterMetadata == other.filterMetadata &&
|
||||
sortMode == other.sortMode;
|
||||
|
||||
@override
|
||||
@@ -345,6 +354,7 @@ class _QueueGroupedAlbumFilterRequest {
|
||||
filterSource,
|
||||
filterQuality,
|
||||
filterFormat,
|
||||
filterMetadata,
|
||||
sortMode,
|
||||
);
|
||||
}
|
||||
@@ -358,6 +368,161 @@ String _queueFileExtLower(String filePath) {
|
||||
return filePath.substring(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
bool _queueHasMetadataValue(String? value) {
|
||||
return value != null && value.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
String _queueNormalizedMetadataValue(String? value) {
|
||||
return value?.trim().toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
DateTime? _queueParseReleaseDate(String? value) {
|
||||
final trimmed = value?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsed = DateTime.tryParse(trimmed);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
final yearMatch = RegExp(r'(\d{4})').firstMatch(trimmed);
|
||||
if (yearMatch == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final year = int.tryParse(yearMatch.group(1)!);
|
||||
if (year == null || year <= 0) {
|
||||
return null;
|
||||
}
|
||||
return DateTime(year);
|
||||
}
|
||||
|
||||
bool _queueMatchesMetadataFilter({
|
||||
required String? filterMetadata,
|
||||
required String? albumArtist,
|
||||
required String? releaseDate,
|
||||
required String? genre,
|
||||
}) {
|
||||
if (filterMetadata == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final hasAlbumArtist = _queueHasMetadataValue(albumArtist);
|
||||
final hasReleaseDate = _queueParseReleaseDate(releaseDate) != null;
|
||||
final hasGenre = _queueHasMetadataValue(genre);
|
||||
final isComplete = hasAlbumArtist && hasReleaseDate && hasGenre;
|
||||
|
||||
switch (filterMetadata) {
|
||||
case 'complete':
|
||||
return isComplete;
|
||||
case 'missing-any':
|
||||
return !isComplete;
|
||||
case 'missing-year':
|
||||
return !hasReleaseDate;
|
||||
case 'missing-genre':
|
||||
return !hasGenre;
|
||||
case 'missing-album-artist':
|
||||
return !hasAlbumArtist;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool _queueUnifiedItemMatchesMetadataFilter(
|
||||
UnifiedLibraryItem item,
|
||||
String? filterMetadata,
|
||||
) {
|
||||
return _queueMatchesMetadataFilter(
|
||||
filterMetadata: filterMetadata,
|
||||
albumArtist: item.albumArtist,
|
||||
releaseDate: item.releaseDate,
|
||||
genre: item.genre,
|
||||
);
|
||||
}
|
||||
|
||||
int _queueCompareOptionalText(
|
||||
String? left,
|
||||
String? right, {
|
||||
bool descending = false,
|
||||
}) {
|
||||
final normalizedLeft = _queueNormalizedMetadataValue(left);
|
||||
final normalizedRight = _queueNormalizedMetadataValue(right);
|
||||
final leftEmpty = normalizedLeft.isEmpty;
|
||||
final rightEmpty = normalizedRight.isEmpty;
|
||||
|
||||
if (leftEmpty && rightEmpty) {
|
||||
return 0;
|
||||
}
|
||||
if (leftEmpty) {
|
||||
return 1;
|
||||
}
|
||||
if (rightEmpty) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
final comparison = normalizedLeft.compareTo(normalizedRight);
|
||||
return descending ? -comparison : comparison;
|
||||
}
|
||||
|
||||
int _queueCompareOptionalDate(
|
||||
DateTime? left,
|
||||
DateTime? right, {
|
||||
bool descending = false,
|
||||
}) {
|
||||
if (left == null && right == null) {
|
||||
return 0;
|
||||
}
|
||||
if (left == null) {
|
||||
return 1;
|
||||
}
|
||||
if (right == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
final comparison = left.compareTo(right);
|
||||
return descending ? -comparison : comparison;
|
||||
}
|
||||
|
||||
DateTime? _queueGroupedAlbumReleaseDate(_GroupedAlbum album) {
|
||||
for (final track in album.tracks) {
|
||||
final releaseDate = _queueParseReleaseDate(track.releaseDate);
|
||||
if (releaseDate != null) {
|
||||
return releaseDate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? _queueGroupedLocalAlbumReleaseDate(_GroupedLocalAlbum album) {
|
||||
for (final track in album.tracks) {
|
||||
final releaseDate = _queueParseReleaseDate(track.releaseDate);
|
||||
if (releaseDate != null) {
|
||||
return releaseDate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _queueGroupedAlbumGenre(_GroupedAlbum album) {
|
||||
for (final track in album.tracks) {
|
||||
if (_queueHasMetadataValue(track.genre)) {
|
||||
return track.genre;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _queueGroupedLocalAlbumGenre(_GroupedLocalAlbum album) {
|
||||
for (final track in album.tracks) {
|
||||
if (_queueHasMetadataValue(track.genre)) {
|
||||
return track.genre;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _queueLocalQualityLabel(LocalLibraryItem item) {
|
||||
if (item.bitrate != null && item.bitrate! > 0) {
|
||||
return '${item.bitrate}kbps';
|
||||
@@ -519,6 +684,7 @@ List<_GroupedAlbum> _queueFilterGroupedAlbums(
|
||||
if (request.filterSource == null &&
|
||||
request.filterQuality == null &&
|
||||
request.filterFormat == null &&
|
||||
request.filterMetadata == null &&
|
||||
request.searchQuery.isEmpty &&
|
||||
request.sortMode == 'latest') {
|
||||
return albums;
|
||||
@@ -531,7 +697,9 @@ List<_GroupedAlbum> _queueFilterGroupedAlbums(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (request.filterQuality != null || request.filterFormat != null) {
|
||||
if (request.filterQuality != null ||
|
||||
request.filterFormat != null ||
|
||||
request.filterMetadata != null) {
|
||||
var hasMatchingTrack = false;
|
||||
for (final track in album.tracks) {
|
||||
if (!_queuePassesQualityFilter(request.filterQuality, track.quality)) {
|
||||
@@ -540,6 +708,14 @@ List<_GroupedAlbum> _queueFilterGroupedAlbums(
|
||||
if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) {
|
||||
continue;
|
||||
}
|
||||
if (!_queueMatchesMetadataFilter(
|
||||
filterMetadata: request.filterMetadata,
|
||||
albumArtist: track.albumArtist,
|
||||
releaseDate: track.releaseDate,
|
||||
genre: track.genre,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
hasMatchingTrack = true;
|
||||
break;
|
||||
}
|
||||
@@ -552,6 +728,29 @@ List<_GroupedAlbum> _queueFilterGroupedAlbums(
|
||||
switch (request.sortMode) {
|
||||
case 'oldest':
|
||||
result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload));
|
||||
case 'artist-asc':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
a.artistName,
|
||||
b.artistName,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
case 'artist-desc':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
a.artistName,
|
||||
b.artistName,
|
||||
descending: true,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
case 'a-z':
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
@@ -562,6 +761,64 @@ List<_GroupedAlbum> _queueFilterGroupedAlbums(
|
||||
(a, b) =>
|
||||
b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()),
|
||||
);
|
||||
case 'album-asc':
|
||||
result.sort(
|
||||
(a, b) => _queueCompareOptionalText(a.albumName, b.albumName),
|
||||
);
|
||||
case 'album-desc':
|
||||
result.sort(
|
||||
(a, b) => _queueCompareOptionalText(
|
||||
a.albumName,
|
||||
b.albumName,
|
||||
descending: true,
|
||||
),
|
||||
);
|
||||
case 'release-oldest':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalDate(
|
||||
_queueGroupedAlbumReleaseDate(a),
|
||||
_queueGroupedAlbumReleaseDate(b),
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
case 'release-newest':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalDate(
|
||||
_queueGroupedAlbumReleaseDate(a),
|
||||
_queueGroupedAlbumReleaseDate(b),
|
||||
descending: true,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
case 'genre-asc':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
_queueGroupedAlbumGenre(a),
|
||||
_queueGroupedAlbumGenre(b),
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
case 'genre-desc':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
_queueGroupedAlbumGenre(a),
|
||||
_queueGroupedAlbumGenre(b),
|
||||
descending: true,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -576,6 +833,7 @@ List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums(
|
||||
if (request.filterSource == null &&
|
||||
request.filterQuality == null &&
|
||||
request.filterFormat == null &&
|
||||
request.filterMetadata == null &&
|
||||
request.searchQuery.isEmpty &&
|
||||
request.sortMode == 'latest') {
|
||||
return albums;
|
||||
@@ -588,7 +846,9 @@ List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (request.filterQuality != null || request.filterFormat != null) {
|
||||
if (request.filterQuality != null ||
|
||||
request.filterFormat != null ||
|
||||
request.filterMetadata != null) {
|
||||
var hasMatchingTrack = false;
|
||||
for (final track in album.tracks) {
|
||||
if (!_queuePassesQualityFilter(
|
||||
@@ -600,6 +860,14 @@ List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums(
|
||||
if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) {
|
||||
continue;
|
||||
}
|
||||
if (!_queueMatchesMetadataFilter(
|
||||
filterMetadata: request.filterMetadata,
|
||||
albumArtist: track.albumArtist,
|
||||
releaseDate: track.releaseDate,
|
||||
genre: track.genre,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
hasMatchingTrack = true;
|
||||
break;
|
||||
}
|
||||
@@ -612,6 +880,29 @@ List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums(
|
||||
switch (request.sortMode) {
|
||||
case 'oldest':
|
||||
result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned));
|
||||
case 'artist-asc':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
a.artistName,
|
||||
b.artistName,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
case 'artist-desc':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
a.artistName,
|
||||
b.artistName,
|
||||
descending: true,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
case 'a-z':
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
@@ -622,6 +913,64 @@ List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums(
|
||||
(a, b) =>
|
||||
b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()),
|
||||
);
|
||||
case 'album-asc':
|
||||
result.sort(
|
||||
(a, b) => _queueCompareOptionalText(a.albumName, b.albumName),
|
||||
);
|
||||
case 'album-desc':
|
||||
result.sort(
|
||||
(a, b) => _queueCompareOptionalText(
|
||||
a.albumName,
|
||||
b.albumName,
|
||||
descending: true,
|
||||
),
|
||||
);
|
||||
case 'release-oldest':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalDate(
|
||||
_queueGroupedLocalAlbumReleaseDate(a),
|
||||
_queueGroupedLocalAlbumReleaseDate(b),
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
case 'release-newest':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalDate(
|
||||
_queueGroupedLocalAlbumReleaseDate(a),
|
||||
_queueGroupedLocalAlbumReleaseDate(b),
|
||||
descending: true,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
case 'genre-asc':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
_queueGroupedLocalAlbumGenre(a),
|
||||
_queueGroupedLocalAlbumGenre(b),
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
case 'genre-desc':
|
||||
result.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
_queueGroupedLocalAlbumGenre(a),
|
||||
_queueGroupedLocalAlbumGenre(b),
|
||||
descending: true,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.albumName, b.albumName);
|
||||
});
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -781,10 +1130,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
String? _filterCacheSource;
|
||||
String? _filterCacheQuality;
|
||||
String? _filterCacheFormat;
|
||||
String? _filterCacheMetadata;
|
||||
String _filterCacheSortMode = 'latest';
|
||||
String? _filterSource; // null = all, 'downloaded', 'local'
|
||||
String? _filterQuality; // null = all, 'hires', 'cd', 'lossy'
|
||||
String? _filterFormat; // null = all, 'flac', 'mp3', 'm4a', 'opus', 'ogg'
|
||||
String? _filterMetadata; // null = all, 'complete', 'missing-*'
|
||||
String _sortMode = 'latest'; // 'latest', 'oldest', 'a-z', 'z-a'
|
||||
|
||||
double _effectiveTextScale() {
|
||||
@@ -871,6 +1222,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_filterCacheSource == _filterSource &&
|
||||
_filterCacheQuality == _filterQuality &&
|
||||
_filterCacheFormat == _filterFormat &&
|
||||
_filterCacheMetadata == _filterMetadata &&
|
||||
_filterCacheSortMode == _sortMode;
|
||||
|
||||
if (isCacheValid) {
|
||||
@@ -886,6 +1238,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_filterCacheSource = _filterSource;
|
||||
_filterCacheQuality = _filterQuality;
|
||||
_filterCacheFormat = _filterFormat;
|
||||
_filterCacheMetadata = _filterMetadata;
|
||||
_filterCacheSortMode = _sortMode;
|
||||
}
|
||||
|
||||
@@ -1868,6 +2221,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (_filterSource != null) count++;
|
||||
if (_filterQuality != null) count++;
|
||||
if (_filterFormat != null) count++;
|
||||
if (_filterMetadata != null) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -1876,6 +2230,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_filterSource = null;
|
||||
_filterQuality = null;
|
||||
_filterFormat = null;
|
||||
_filterMetadata = null;
|
||||
_sortMode = 'latest';
|
||||
_unifiedItemsCache.clear();
|
||||
_invalidateFilterContentCache();
|
||||
@@ -1931,6 +2286,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (ext != _filterFormat) return false;
|
||||
}
|
||||
|
||||
if (!_queueUnifiedItemMatchesMetadataFilter(
|
||||
item,
|
||||
_filterMetadata,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.toList(growable: false);
|
||||
@@ -1957,6 +2319,95 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
(a, b) =>
|
||||
b.trackName.toLowerCase().compareTo(a.trackName.toLowerCase()),
|
||||
);
|
||||
case 'artist-asc':
|
||||
sorted.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
a.artistName,
|
||||
b.artistName,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.trackName, b.trackName);
|
||||
});
|
||||
case 'artist-desc':
|
||||
sorted.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
a.artistName,
|
||||
b.artistName,
|
||||
descending: true,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.trackName, b.trackName);
|
||||
});
|
||||
case 'album-asc':
|
||||
sorted.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
a.albumName,
|
||||
b.albumName,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.trackName, b.trackName);
|
||||
});
|
||||
case 'album-desc':
|
||||
sorted.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
a.albumName,
|
||||
b.albumName,
|
||||
descending: true,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.trackName, b.trackName);
|
||||
});
|
||||
case 'release-oldest':
|
||||
sorted.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalDate(
|
||||
_queueParseReleaseDate(a.releaseDate),
|
||||
_queueParseReleaseDate(b.releaseDate),
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.trackName, b.trackName);
|
||||
});
|
||||
case 'release-newest':
|
||||
sorted.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalDate(
|
||||
_queueParseReleaseDate(a.releaseDate),
|
||||
_queueParseReleaseDate(b.releaseDate),
|
||||
descending: true,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.trackName, b.trackName);
|
||||
});
|
||||
case 'genre-asc':
|
||||
sorted.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(a.genre, b.genre);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.trackName, b.trackName);
|
||||
});
|
||||
case 'genre-desc':
|
||||
sorted.sort((a, b) {
|
||||
final comparison = _queueCompareOptionalText(
|
||||
a.genre,
|
||||
b.genre,
|
||||
descending: true,
|
||||
);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
return _queueCompareOptionalText(a.trackName, b.trackName);
|
||||
});
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
@@ -1982,6 +2433,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
String? tempSource = _filterSource;
|
||||
String? tempQuality = _filterQuality;
|
||||
String? tempFormat = _filterFormat;
|
||||
String? tempMetadata = _filterMetadata;
|
||||
String tempSortMode = _sortMode;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
@@ -2034,6 +2486,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
tempSource = null;
|
||||
tempQuality = null;
|
||||
tempFormat = null;
|
||||
tempMetadata = null;
|
||||
tempSortMode = 'latest';
|
||||
});
|
||||
},
|
||||
@@ -2147,6 +2600,76 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Text(
|
||||
context.l10n.libraryFilterMetadata,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: Text(context.l10n.libraryFilterAll),
|
||||
selected: tempMetadata == null,
|
||||
onSelected: (_) =>
|
||||
setSheetState(() => tempMetadata = null),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(
|
||||
context.l10n.libraryFilterMetadataComplete,
|
||||
),
|
||||
selected: tempMetadata == 'complete',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempMetadata = 'complete',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(
|
||||
context.l10n.libraryFilterMetadataMissingAny,
|
||||
),
|
||||
selected: tempMetadata == 'missing-any',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempMetadata = 'missing-any',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(
|
||||
context.l10n.libraryFilterMetadataMissingYear,
|
||||
),
|
||||
selected: tempMetadata == 'missing-year',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempMetadata = 'missing-year',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(
|
||||
context
|
||||
.l10n
|
||||
.libraryFilterMetadataMissingGenre,
|
||||
),
|
||||
selected: tempMetadata == 'missing-genre',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempMetadata = 'missing-genre',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(
|
||||
context
|
||||
.l10n
|
||||
.libraryFilterMetadataMissingAlbumArtist,
|
||||
),
|
||||
selected:
|
||||
tempMetadata == 'missing-album-artist',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempMetadata = 'missing-album-artist',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Text(
|
||||
context.l10n.libraryFilterSort,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
@@ -2175,17 +2698,81 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(context.l10n.sortAlphaAsc),
|
||||
label: Text(context.l10n.searchSortTitleAZ),
|
||||
selected: tempSortMode == 'a-z',
|
||||
onSelected: (_) =>
|
||||
setSheetState(() => tempSortMode = 'a-z'),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(context.l10n.sortAlphaDesc),
|
||||
label: Text(context.l10n.searchSortTitleZA),
|
||||
selected: tempSortMode == 'z-a',
|
||||
onSelected: (_) =>
|
||||
setSheetState(() => tempSortMode = 'z-a'),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(context.l10n.searchSortArtistAZ),
|
||||
selected: tempSortMode == 'artist-asc',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempSortMode = 'artist-asc',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(context.l10n.searchSortArtistZA),
|
||||
selected: tempSortMode == 'artist-desc',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempSortMode = 'artist-desc',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(
|
||||
context.l10n.libraryFilterSortAlbumAsc,
|
||||
),
|
||||
selected: tempSortMode == 'album-asc',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempSortMode = 'album-asc',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(
|
||||
context.l10n.libraryFilterSortAlbumDesc,
|
||||
),
|
||||
selected: tempSortMode == 'album-desc',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempSortMode = 'album-desc',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(context.l10n.searchSortDateNewest),
|
||||
selected: tempSortMode == 'release-newest',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempSortMode = 'release-newest',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(context.l10n.searchSortDateOldest),
|
||||
selected: tempSortMode == 'release-oldest',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempSortMode = 'release-oldest',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(
|
||||
context.l10n.libraryFilterSortGenreAsc,
|
||||
),
|
||||
selected: tempSortMode == 'genre-asc',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempSortMode = 'genre-asc',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(
|
||||
context.l10n.libraryFilterSortGenreDesc,
|
||||
),
|
||||
selected: tempSortMode == 'genre-desc',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempSortMode = 'genre-desc',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -2198,6 +2785,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_filterSource = tempSource;
|
||||
_filterQuality = tempQuality;
|
||||
_filterFormat = tempFormat;
|
||||
_filterMetadata = tempMetadata;
|
||||
_sortMode = tempSortMode;
|
||||
_unifiedItemsCache.clear();
|
||||
_invalidateFilterContentCache();
|
||||
@@ -2738,6 +3326,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
filterSource: _filterSource,
|
||||
filterQuality: _filterQuality,
|
||||
filterFormat: _filterFormat,
|
||||
filterMetadata: _filterMetadata,
|
||||
sortMode: _sortMode,
|
||||
),
|
||||
),
|
||||
@@ -4317,6 +4906,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
lowerPath.endsWith('.opus') ||
|
||||
lowerPath.endsWith('.ogg');
|
||||
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
String? ffmpegResult;
|
||||
if (isMp3) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
||||
@@ -4335,6 +4925,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
opusPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4369,11 +4960,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
Future<bool> _reEnrichQueueLocalTrack(LocalLibraryItem item) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final request = <String, dynamic>{
|
||||
'file_path': item.filePath,
|
||||
'cover_url': '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': true,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': '',
|
||||
'track_name': item.trackName,
|
||||
'artist_name': item.artistName,
|
||||
@@ -5074,6 +5667,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
bitrate: bitrate,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
artistTagMode: settings.artistTagMode,
|
||||
deleteOriginal: !isSaf,
|
||||
);
|
||||
|
||||
|
||||
@@ -392,6 +392,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
itemCount: libraryState.items.length,
|
||||
excludedDownloadedCount: libraryState.excludedDownloadedCount,
|
||||
isScanning: libraryState.isScanning,
|
||||
scanIsFinalizing: libraryState.scanIsFinalizing,
|
||||
scanProgress: libraryState.scanProgress,
|
||||
scanCurrentFile: libraryState.scanCurrentFile,
|
||||
scanTotalFiles: libraryState.scanTotalFiles,
|
||||
@@ -528,8 +529,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
children: [
|
||||
if (libraryState.isScanning)
|
||||
_ScanProgressTile(
|
||||
isFinalizing: libraryState.scanIsFinalizing,
|
||||
progress: libraryState.scanProgress,
|
||||
currentFile: libraryState.scanCurrentFile,
|
||||
scannedFiles: libraryState.scannedFiles,
|
||||
totalFiles: libraryState.scanTotalFiles,
|
||||
onCancel: _cancelScan,
|
||||
)
|
||||
@@ -646,6 +649,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
final int itemCount;
|
||||
final int excludedDownloadedCount;
|
||||
final bool isScanning;
|
||||
final bool scanIsFinalizing;
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
final int scanTotalFiles;
|
||||
@@ -656,6 +660,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
required this.itemCount,
|
||||
required this.excludedDownloadedCount,
|
||||
required this.isScanning,
|
||||
required this.scanIsFinalizing,
|
||||
required this.scanProgress,
|
||||
this.scanCurrentFile,
|
||||
required this.scanTotalFiles,
|
||||
@@ -680,6 +685,11 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final showIndeterminateProgress =
|
||||
isScanning &&
|
||||
(scanIsFinalizing ||
|
||||
scanTotalFiles <= 0 ||
|
||||
(scannedFiles <= 0 && scanProgress <= 0));
|
||||
final displayCount = isScanning
|
||||
? scannedFiles
|
||||
: itemCount + excludedDownloadedCount;
|
||||
@@ -798,7 +808,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isScanning
|
||||
? context.l10n.libraryTracksUnit(scannedFiles)
|
||||
? context.l10n.libraryFilesUnit(scannedFiles)
|
||||
: context.l10n.libraryTracksUnit(displayCount),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
@@ -821,14 +831,49 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isScanning && scanCurrentFile != null) ...[
|
||||
if (isScanning) ...[
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(
|
||||
value: scanProgress / 100,
|
||||
value: showIndeterminateProgress
|
||||
? null
|
||||
: scanProgress / 100,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
scanIsFinalizing
|
||||
? context.l10n.libraryScanFinalizing
|
||||
: scanTotalFiles > 0
|
||||
? context.l10n.libraryScanProgress(
|
||||
scanProgress.toStringAsFixed(0),
|
||||
scanTotalFiles,
|
||||
)
|
||||
: context.l10n.libraryScanning,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!scanIsFinalizing &&
|
||||
scanCurrentFile != null &&
|
||||
scanCurrentFile!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
scanCurrentFile!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
@@ -865,14 +910,18 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _ScanProgressTile extends StatelessWidget {
|
||||
final bool isFinalizing;
|
||||
final double progress;
|
||||
final String? currentFile;
|
||||
final int scannedFiles;
|
||||
final int totalFiles;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _ScanProgressTile({
|
||||
required this.isFinalizing,
|
||||
required this.progress,
|
||||
this.currentFile,
|
||||
required this.scannedFiles,
|
||||
required this.totalFiles,
|
||||
required this.onCancel,
|
||||
});
|
||||
@@ -880,6 +929,8 @@ class _ScanProgressTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final showIndeterminateProgress =
|
||||
isFinalizing || totalFiles <= 0 || (scannedFiles <= 0 && progress <= 0);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
@@ -901,10 +952,14 @@ class _ScanProgressTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.libraryScanProgress(
|
||||
progress.toStringAsFixed(0),
|
||||
totalFiles,
|
||||
),
|
||||
isFinalizing
|
||||
? context.l10n.libraryScanFinalizing
|
||||
: totalFiles > 0
|
||||
? context.l10n.libraryScanProgress(
|
||||
progress.toStringAsFixed(0),
|
||||
totalFiles,
|
||||
)
|
||||
: context.l10n.libraryScanning,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -920,12 +975,14 @@ class _ScanProgressTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: progress / 100,
|
||||
value: showIndeterminateProgress ? null : progress / 100,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
if (currentFile != null) ...[
|
||||
if (!isFinalizing &&
|
||||
currentFile != null &&
|
||||
currentFile!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
currentFile!,
|
||||
|
||||
@@ -519,7 +519,7 @@ class _LogEntryTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
entry.message,
|
||||
entry.previewMessage,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
@@ -527,10 +527,10 @@ class _LogEntryTile extends StatelessWidget {
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
if (entry.error != null) ...[
|
||||
if (entry.previewError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
entry.error!,
|
||||
entry.previewError!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
|
||||
@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class OptionsSettingsPage extends ConsumerWidget {
|
||||
@@ -115,7 +116,22 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
value: settings.embedMetadata,
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setEmbedMetadata(v),
|
||||
showDivider: settings.embedMetadata,
|
||||
),
|
||||
if (settings.embedMetadata)
|
||||
SettingsItem(
|
||||
icon: Icons.people_alt_outlined,
|
||||
title: context.l10n.optionsArtistTagMode,
|
||||
subtitle: _getArtistTagModeLabel(
|
||||
context,
|
||||
settings.artistTagMode,
|
||||
),
|
||||
onTap: () => _showArtistTagModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.artistTagMode,
|
||||
),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.image,
|
||||
title: context.l10n.optionsMaxQualityCover,
|
||||
@@ -236,6 +252,88 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _getArtistTagModeLabel(BuildContext context, String mode) {
|
||||
switch (mode) {
|
||||
case artistTagModeSplitVorbis:
|
||||
return context.l10n.optionsArtistTagModeSplitVorbis;
|
||||
default:
|
||||
return context.l10n.optionsArtistTagModeJoined;
|
||||
}
|
||||
}
|
||||
|
||||
void _showArtistTagModePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String currentMode,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.optionsArtistTagMode,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.optionsArtistTagModeDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.segment_outlined),
|
||||
title: Text(context.l10n.optionsArtistTagModeJoined),
|
||||
subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle),
|
||||
trailing: currentMode == artistTagModeJoined
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setArtistTagMode(artistTagModeJoined);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.library_music_outlined),
|
||||
title: Text(context.l10n.optionsArtistTagModeSplitVorbis),
|
||||
subtitle: Text(
|
||||
context.l10n.optionsArtistTagModeSplitVorbisSubtitle,
|
||||
),
|
||||
trailing: currentMode == artistTagModeSplitVorbis
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setArtistTagMode(artistTagModeSplitVorbis);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showClearHistoryDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
@@ -1838,6 +1838,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
String? ffmpegResult;
|
||||
if (isMp3) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
||||
@@ -1856,6 +1857,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
opusPath: workingPath,
|
||||
coverPath: coverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2228,6 +2230,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (!_fileExists) return;
|
||||
|
||||
try {
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
|
||||
);
|
||||
@@ -2238,6 +2241,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
'cover_url': _coverUrl ?? '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': true,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': _spotifyId ?? '',
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
@@ -2340,6 +2344,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
opusPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3554,6 +3559,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bitrate: bitrate,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
artistTagMode: ref.read(settingsProvider).artistTagMode,
|
||||
deleteOriginal: !isSaf,
|
||||
);
|
||||
|
||||
@@ -3768,6 +3774,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
initialValues: initialValues,
|
||||
filePath: cleanFilePath,
|
||||
sourceTrackId: _spotifyId,
|
||||
artistTagMode: ref.read(settingsProvider).artistTagMode,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3989,12 +3996,14 @@ class _EditMetadataSheet extends StatefulWidget {
|
||||
final Map<String, String> initialValues;
|
||||
final String filePath;
|
||||
final String? sourceTrackId;
|
||||
final String artistTagMode;
|
||||
|
||||
const _EditMetadataSheet({
|
||||
required this.colorScheme,
|
||||
required this.initialValues,
|
||||
required this.filePath,
|
||||
this.sourceTrackId,
|
||||
required this.artistTagMode,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -4875,6 +4884,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
'composer': _composerCtrl.text,
|
||||
'comment': _commentCtrl.text,
|
||||
'cover_path': _selectedCoverPath ?? '',
|
||||
'artist_tag_mode': widget.artistTagMode,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -5005,6 +5015,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
opusPath: ffmpegTarget,
|
||||
coverPath: existingCoverPath,
|
||||
metadata: vorbisMap,
|
||||
artistTagMode: widget.artistTagMode,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class DownloadRequestPayload {
|
||||
final String filenameFormat;
|
||||
final String quality;
|
||||
final bool embedMetadata;
|
||||
final String artistTagMode;
|
||||
final bool embedLyrics;
|
||||
final bool embedMaxQualityCover;
|
||||
final int trackNumber;
|
||||
@@ -49,6 +50,7 @@ class DownloadRequestPayload {
|
||||
required this.filenameFormat,
|
||||
this.quality = 'LOSSLESS',
|
||||
this.embedMetadata = true,
|
||||
this.artistTagMode = 'joined',
|
||||
this.embedLyrics = true,
|
||||
this.embedMaxQualityCover = true,
|
||||
this.trackNumber = 1,
|
||||
@@ -89,6 +91,7 @@ class DownloadRequestPayload {
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'embed_metadata': embedMetadata,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'track_number': trackNumber,
|
||||
@@ -133,6 +136,7 @@ class DownloadRequestPayload {
|
||||
filenameFormat: filenameFormat,
|
||||
quality: quality,
|
||||
embedMetadata: embedMetadata,
|
||||
artistTagMode: artistTagMode,
|
||||
embedLyrics: embedLyrics,
|
||||
embedMaxQualityCover: embedMaxQualityCover,
|
||||
trackNumber: trackNumber,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('FFmpeg');
|
||||
@@ -887,6 +888,7 @@ class FFmpegService {
|
||||
required String flacPath,
|
||||
String? coverPath,
|
||||
Map<String, String>? metadata,
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
|
||||
@@ -911,10 +913,11 @@ class FFmpegService {
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
if (metadata != null) {
|
||||
metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
_appendVorbisMetadataToCommandBuffer(
|
||||
cmdBuffer,
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
}
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
@@ -1046,6 +1049,7 @@ class FFmpegService {
|
||||
required String opusPath,
|
||||
String? coverPath,
|
||||
Map<String, String>? metadata,
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
|
||||
@@ -1063,11 +1067,11 @@ class FFmpegService {
|
||||
];
|
||||
|
||||
if (metadata != null) {
|
||||
metadata.forEach((key, value) {
|
||||
arguments
|
||||
..add('-metadata')
|
||||
..add('$key=$value');
|
||||
});
|
||||
_appendVorbisMetadataToArguments(
|
||||
arguments,
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
}
|
||||
|
||||
if (coverPath != null) {
|
||||
@@ -1154,8 +1158,11 @@ class FFmpegService {
|
||||
// For M4A/MP4, cover art is mapped as a video stream and stored in the
|
||||
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
|
||||
// flag is only valid for Matroska/WebM containers and must NOT be used here.
|
||||
// Force the mp4 muxer when cover art is present because the default ipod
|
||||
// muxer (auto-selected for .m4a) does not register a codec tag for mjpeg,
|
||||
// causing "codec not currently supported in container" on FFmpeg 8.0+.
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-map 1:v -c:v copy ');
|
||||
cmdBuffer.write('-map 1:v -c:v copy -f mp4 ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
@@ -1326,6 +1333,7 @@ class FFmpegService {
|
||||
required String bitrate,
|
||||
required Map<String, String> metadata,
|
||||
String? coverPath,
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final format = targetFormat.toLowerCase();
|
||||
@@ -1348,6 +1356,7 @@ class FFmpegService {
|
||||
inputPath: inputPath,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
artistTagMode: artistTagMode,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
@@ -1391,6 +1400,7 @@ class FFmpegService {
|
||||
opusPath: outputPath,
|
||||
coverPath: coverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1491,6 +1501,7 @@ class FFmpegService {
|
||||
required String inputPath,
|
||||
required Map<String, String> metadata,
|
||||
String? coverPath,
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||
@@ -1515,11 +1526,11 @@ class FFmpegService {
|
||||
cmdBuffer.write('-c:a flac -compression_level 8 ');
|
||||
cmdBuffer.write('-map_metadata 0 ');
|
||||
|
||||
final vorbisComments = _normalizeToVorbisComments(metadata);
|
||||
for (final entry in vorbisComments.entries) {
|
||||
final sanitized = entry.value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
|
||||
}
|
||||
_appendVorbisMetadataToCommandBuffer(
|
||||
cmdBuffer,
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
|
||||
cmdBuffer.write('"$outputPath" -y');
|
||||
|
||||
@@ -1617,6 +1628,86 @@ class FFmpegService {
|
||||
return vorbis;
|
||||
}
|
||||
|
||||
static void _appendVorbisMetadataToCommandBuffer(
|
||||
StringBuffer cmdBuffer,
|
||||
Map<String, String> metadata, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
for (final entry in _buildVorbisMetadataEntries(
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
)) {
|
||||
final sanitized = entry.value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
|
||||
}
|
||||
}
|
||||
|
||||
static void _appendVorbisMetadataToArguments(
|
||||
List<String> arguments,
|
||||
Map<String, String> metadata, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
for (final entry in _buildVorbisMetadataEntries(
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
)) {
|
||||
arguments
|
||||
..add('-metadata')
|
||||
..add('${entry.key}=${entry.value}');
|
||||
}
|
||||
}
|
||||
|
||||
static List<MapEntry<String, String>> _buildVorbisMetadataEntries(
|
||||
Map<String, String> metadata, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
final vorbis = _normalizeToVorbisComments(metadata);
|
||||
final entries = <MapEntry<String, String>>[];
|
||||
|
||||
for (final entry in vorbis.entries) {
|
||||
if (entry.key == 'ARTIST' || entry.key == 'ALBUMARTIST') {
|
||||
continue;
|
||||
}
|
||||
entries.add(entry);
|
||||
}
|
||||
|
||||
_appendVorbisArtistEntries(
|
||||
entries,
|
||||
'ARTIST',
|
||||
vorbis['ARTIST'],
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
_appendVorbisArtistEntries(
|
||||
entries,
|
||||
'ALBUMARTIST',
|
||||
vorbis['ALBUMARTIST'],
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
static void _appendVorbisArtistEntries(
|
||||
List<MapEntry<String, String>> entries,
|
||||
String key,
|
||||
String? rawValue, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
final value = rawValue?.trim() ?? '';
|
||||
if (value.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldSplitVorbisArtistTags(artistTagMode)) {
|
||||
entries.add(MapEntry(key, value));
|
||||
return;
|
||||
}
|
||||
|
||||
for (final artist in splitArtistTagValues(value)) {
|
||||
entries.add(MapEntry(key, artist));
|
||||
}
|
||||
}
|
||||
|
||||
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
|
||||
static Map<String, String> _convertToM4aTags(Map<String, String> metadata) {
|
||||
final m4aMap = <String, String>{};
|
||||
@@ -1691,6 +1782,9 @@ class FFmpegService {
|
||||
final key = entry.key.toUpperCase();
|
||||
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (normalizedKey) {
|
||||
case 'TITLE':
|
||||
@@ -1708,12 +1802,16 @@ class FFmpegService {
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
id3Map['track'] = value;
|
||||
if (value != '0') {
|
||||
id3Map['track'] = value;
|
||||
}
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
id3Map['disc'] = value;
|
||||
if (value != '0') {
|
||||
id3Map['disc'] = value;
|
||||
}
|
||||
break;
|
||||
case 'DATE':
|
||||
case 'YEAR':
|
||||
|
||||
@@ -3,6 +3,9 @@ final RegExp _artistNameSplitPattern = RegExp(
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
const artistTagModeJoined = 'joined';
|
||||
const artistTagModeSplitVorbis = 'split_vorbis';
|
||||
|
||||
List<String> splitArtistNames(String rawArtists) {
|
||||
final raw = rawArtists.trim();
|
||||
if (raw.isEmpty) return const [];
|
||||
@@ -13,3 +16,25 @@ List<String> splitArtistNames(String rawArtists) {
|
||||
.where((part) => part.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
bool shouldSplitVorbisArtistTags(String mode) {
|
||||
return mode == artistTagModeSplitVorbis;
|
||||
}
|
||||
|
||||
List<String> splitArtistTagValues(String rawArtists) {
|
||||
final seen = <String>{};
|
||||
final values = <String>[];
|
||||
for (final part in splitArtistNames(rawArtists)) {
|
||||
final key = part.toLowerCase();
|
||||
if (seen.add(key)) {
|
||||
values.add(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (values.isNotEmpty) {
|
||||
return values;
|
||||
}
|
||||
|
||||
final trimmed = rawArtists.trim();
|
||||
return trimmed.isEmpty ? const [] : <String>[trimmed];
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ class LogEntry {
|
||||
return '$h:$m:$s.$ms';
|
||||
}
|
||||
|
||||
String get previewMessage => _truncateLogText(message);
|
||||
|
||||
String? get previewError => error == null ? null : _truncateLogText(error!);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final errorPart = error != null ? ' | $error' : '';
|
||||
@@ -128,11 +132,9 @@ class LogBuffer extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final sanitizedMessage = _truncateLogText(
|
||||
_redactSensitiveText(entry.message),
|
||||
);
|
||||
final sanitizedMessage = _redactSensitiveText(entry.message);
|
||||
final sanitizedError = entry.error != null
|
||||
? _truncateLogText(_redactSensitiveText(entry.error!))
|
||||
? _redactSensitiveText(entry.error!)
|
||||
: null;
|
||||
final sanitizedEntry =
|
||||
(sanitizedMessage == entry.message && sanitizedError == entry.error)
|
||||
@@ -381,9 +383,7 @@ class BufferedOutput extends LogOutput {
|
||||
}
|
||||
|
||||
final level = _levelToString(event.level);
|
||||
final message = _truncateLogText(
|
||||
_redactSensitiveText(event.lines.join('\n')),
|
||||
);
|
||||
final message = _redactSensitiveText(event.lines.join('\n'));
|
||||
|
||||
LogBuffer().add(
|
||||
LogEntry(
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
||||
publish_to: "none"
|
||||
version: 4.1.2+119
|
||||
version: 4.1.3+120
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user