From 76fe8dbc69cc5618b346373953dd1d56a5092da8 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 11 Mar 2026 00:31:20 +0700 Subject: [PATCH] feat: add CUE sheet support for local library scanning and splitting (#201) Parse .cue files in library scanner (Go + SAF) to display individual tracks instead of one large audio file. Add FFmpeg-based CUE splitting to extract tracks into separate FLAC files with embedded metadata and cover art. - Go: CUE parser, two-pass scan (CUE first, skip referenced audio), virtual paths (cue#trackNN) for DB UNIQUE constraint, audioDir override for SAF temp-file scenarios - Android: SAF scanner recognizes .cue in both full and incremental scan, copies .cue+audio to temp for Go parsing, unchanged-CUE audio sibling dedup, parseCueSheet handler resolves SAF audio siblings - Dart: FFmpegService.splitCueToTracks, CUE split UI in track metadata screen, persistent output dir for SAF splits with write-back - CUE virtual path normalization across fileExists/fileStat/deleteFile/ openFile; play/share/open blocked for virtual tracks with guidance to split first; delete only removes DB entry, not shared .cue file - iOS: parseCueSheet handler - Localization: 12 new CUE-related strings Requested by @Seerafimm Closes #201 --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 527 +++++++++++++++- go_backend/cue_parser.go | 577 +++++++++++++++++ go_backend/exports.go | 26 + go_backend/library_scan.go | 120 +++- ios/Runner/AppDelegate.swift | 9 + lib/l10n/app_localizations.dart | 72 +++ lib/l10n/app_localizations_de.dart | 48 ++ lib/l10n/app_localizations_en.dart | 48 ++ lib/l10n/app_localizations_es.dart | 48 ++ lib/l10n/app_localizations_fr.dart | 48 ++ lib/l10n/app_localizations_hi.dart | 48 ++ lib/l10n/app_localizations_id.dart | 48 ++ lib/l10n/app_localizations_ja.dart | 48 ++ lib/l10n/app_localizations_ko.dart | 48 ++ lib/l10n/app_localizations_nl.dart | 48 ++ lib/l10n/app_localizations_pt.dart | 48 ++ lib/l10n/app_localizations_ru.dart | 48 ++ lib/l10n/app_localizations_tr.dart | 48 ++ lib/l10n/app_localizations_zh.dart | 48 ++ lib/l10n/arb/app_en.arb | 84 +++ lib/providers/playback_provider.dart | 12 + lib/screens/local_album_screen.dart | 20 +- lib/screens/track_metadata_screen.dart | 593 +++++++++++++++++- lib/services/ffmpeg_service.dart | 154 +++++ lib/services/platform_bridge.dart | 16 + lib/utils/file_access.dart | 50 +- 26 files changed, 2844 insertions(+), 40 deletions(-) create mode 100644 go_backend/cue_parser.go diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index da56220d..8fe38bfc 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -807,6 +807,72 @@ class MainActivity: FlutterFragmentActivity() { } } + /** + * Get the parent DocumentFile directory for a SAF document URI. + * The child URI must be a tree-based document URI (e.g. from SAF tree scan). + * Returns a DocumentFile that supports findFile() for sibling lookup. + */ + private fun safParentDir(childUri: Uri): DocumentFile? { + try { + val docId = android.provider.DocumentsContract.getDocumentId(childUri) + if (docId.isNullOrEmpty()) return null + + // Document IDs typically look like "primary:Music/Album/file.cue" + // Parent would be "primary:Music/Album" + val lastSlash = docId.lastIndexOf('/') + if (lastSlash <= 0) return null + + val parentDocId = docId.substring(0, lastSlash) + + // Build a tree document URI for the parent so it supports listing/findFile + val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri) + if (treeDocId.isNullOrEmpty()) return null + + val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree( + childUri, parentDocId + ) + return DocumentFile.fromTreeUri(this, parentUri) + ?: DocumentFile.fromSingleUri(this, parentUri) + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to get SAF parent dir: ${e.message}") + return null + } + } + + /** + * Extract the audio filename referenced by a CUE sheet file. + * Reads the FILE "name" TYPE line from the .cue text. + * Returns just the filename (no path), or null if not found. + */ + private fun extractCueAudioFileName(cueTempPath: String): String? { + try { + val lines = File(cueTempPath).readLines() + for (line in lines) { + val trimmed = line.trim().let { l -> + // Strip BOM + if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l + } + if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) { + val rest = trimmed.substring(5).trim() + // Parse: "filename" TYPE or filename TYPE + val filename = if (rest.startsWith("\"")) { + val endQuote = rest.indexOf('"', 1) + if (endQuote > 0) rest.substring(1, endQuote) else rest + } else { + // Last word is the type, everything else is the filename + val parts = rest.split("\\s+".toRegex()) + if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest + } + // Return just the filename (strip any path separators) + return filename.substringAfterLast("/").substringAfterLast("\\") + } + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to extract audio filename from CUE: ${e.message}") + } + return null + } + private fun scanSafTree(treeUriStr: String): String { if (treeUriStr.isBlank()) return "[]" @@ -820,8 +886,10 @@ class MainActivity: FlutterFragmentActivity() { it.currentFile = "Scanning folders..." } - val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") + val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val audioFiles = mutableListOf>() + // CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio + val cueFiles = mutableListOf>() val visitedDirUris = mutableSetOf() var traversalErrors = 0 @@ -870,7 +938,9 @@ class MainActivity: FlutterFragmentActivity() { } else if (child.isFile) { val name = child.name ?: continue val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - if (ext.isNotBlank() && supportedExt.contains(".$ext")) { + if (ext == "cue") { + cueFiles.add(child to dir) + } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { audioFiles.add(child to path) } } @@ -885,11 +955,12 @@ class MainActivity: FlutterFragmentActivity() { } } + val totalItems = audioFiles.size + cueFiles.size updateSafScanProgress { - it.totalFiles = audioFiles.size + it.totalFiles = totalItems } - if (audioFiles.isEmpty()) { + if (audioFiles.isEmpty() && cueFiles.isEmpty()) { updateSafScanProgress { it.isComplete = true it.progressPct = 100.0 @@ -901,12 +972,138 @@ class MainActivity: FlutterFragmentActivity() { var scanned = 0 var errors = traversalErrors + // --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio --- + val cueReferencedAudioUris = mutableSetOf() + + for ((cueDoc, parentDir) in cueFiles) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + return "[]" + } + + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + updateSafScanProgress { it.currentFile = cueName } + + var tempCuePath: String? = null + var tempAudioPath: String? = null + try { + // Copy CUE to temp + tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCuePath == null) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy CUE ${cueDoc.uri}") + scanned++ + continue + } + + // Extract the audio filename from the CUE sheet text + val audioFileName = extractCueAudioFileName(tempCuePath) + + // Find the referenced audio file as a sibling in the same SAF directory + var audioDoc: DocumentFile? = null + if (!audioFileName.isNullOrBlank()) { + audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } + } + + // Fallback: try common audio extensions with the CUE base name + if (audioDoc == null) { + val cueBaseName = cueName.substringBeforeLast('.') + val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") + for (ext in commonExts) { + audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } + if (audioDoc != null) break + // Try uppercase + audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } + if (audioDoc != null) break + } + } + + if (audioDoc == null) { + android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName") + errors++ + scanned++ + continue + } + + // Mark this audio file so we skip it in the regular audio pass + cueReferencedAudioUris.add(audioDoc.uri.toString()) + + // Copy audio to same temp dir so Go can resolve it + val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath + 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 + + tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) + if (tempAudioPath == null) { + android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy audio for CUE $cueName") + errors++ + scanned++ + continue + } + + // Rename temp audio to its original name so Go can find it by name + val renamedAudio = File(tempDir, audioName) + val tempAudioFile = File(tempAudioPath) + if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { + tempAudioFile.renameTo(renamedAudio) + tempAudioPath = renamedAudio.absolutePath + } + + val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L } + + // Call Go to produce library scan entries for each CUE track + val cueResultsJson = Gobackend.scanCueSheetForLibrary( + tempCuePath, + tempDir, + cueDoc.uri.toString(), + cueLastModified + ) + + val cueArray = JSONArray(cueResultsJson) + for (j in 0 until cueArray.length()) { + results.put(cueArray.getJSONObject(j)) + } + + android.util.Log.d( + "SpotiFLAC", + "SAF scan: CUE $cueName -> ${cueArray.length()} tracks" + ) + } catch (e: Exception) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF scan: error processing CUE $cueName: ${e.message}") + } finally { + try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} + try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} + } + + scanned++ + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 + updateSafScanProgress { + it.scannedFiles = scanned + it.errorCount = errors + it.progressPct = pct + } + } + + // --- Regular audio file pass: skip files referenced by CUE sheets --- for ((doc, _) in audioFiles) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } return "[]" } + // Skip audio files that are represented by CUE track entries + if (cueReferencedAudioUris.contains(doc.uri.toString())) { + scanned++ + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 + updateSafScanProgress { + it.scannedFiles = scanned + it.progressPct = pct + } + continue + } + val name = try { doc.name ?: "" } catch (_: Exception) { "" } updateSafScanProgress { it.currentFile = name @@ -947,7 +1144,7 @@ class MainActivity: FlutterFragmentActivity() { } scanned++ - val pct = scanned.toDouble() / audioFiles.size.toDouble() * 100.0 + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 updateSafScanProgress { it.scannedFiles = scanned it.errorCount = errors @@ -965,6 +1162,8 @@ class MainActivity: FlutterFragmentActivity() { /** * Incremental SAF tree scan - only scans new or modified files. + * Supports .cue sheets: expands them into virtual track entries and + * deduplicates audio files referenced by CUE sheets. * @param treeUriStr The SAF tree URI to scan * @param existingFilesJson JSON object mapping file URI -> lastModified timestamp * @return JSON object with new/changed files and removed URIs @@ -1007,13 +1206,29 @@ class MainActivity: FlutterFragmentActivity() { it.currentFile = "Scanning folders..." } - val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") + val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val audioFiles = mutableListOf>() // doc, path, lastModified + // CUE files to scan: (cueDoc, parentDir, lastModified) + val cueFilesToScan = mutableListOf>() + // Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set + val unchangedCueFiles = mutableListOf>() val currentUris = mutableSetOf() val visitedDirUris = mutableSetOf() var traversalErrors = 0 - // Collect all audio files with lastModified + // Build a map of CUE base URIs -> existing virtual track URIs from the database. + // Virtual paths look like "content://...album.cue#track01". + // We need this to preserve virtual paths for unchanged CUE files. + val existingCueVirtualPaths = mutableMapOf>() // cueUri -> [virtualPaths] + for (key in existingFiles.keys) { + val hashIdx = key.indexOf("#track") + if (hashIdx > 0) { + val baseCueUri = key.substring(0, hashIdx) + existingCueVirtualPaths.getOrPut(baseCueUri) { mutableListOf() }.add(key) + } + } + + // Collect all files with lastModified val queue: ArrayDeque> = ArrayDeque() queue.add(root to "") @@ -1076,7 +1291,27 @@ class MainActivity: FlutterFragmentActivity() { val name = child.name ?: continue val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - if (ext.isNotBlank() && supportedExt.contains(".$ext")) { + + if (ext == "cue") { + val lastModified = try { + child.lastModified() + } catch (_: Exception) { 0L } + + // Check if any virtual track from this CUE exists with matching modTime + val virtualPaths = existingCueVirtualPaths[uriStr] + val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] } + + if (existingModified != null && existingModified == lastModified) { + // CUE is unchanged — mark virtual paths as current so they aren't removed + unchangedCueFiles.add(child to dir) + for (vp in virtualPaths) { + currentUris.add(vp) + } + } else { + // CUE is new or modified — needs scanning + cueFilesToScan.add(Triple(child, dir, lastModified)) + } + } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { val existingModified = existingFiles[uriStr] val lastModified = try { child.lastModified() @@ -1104,13 +1339,14 @@ class MainActivity: FlutterFragmentActivity() { // Find removed files (in existing but not in current) val removedUris = existingFiles.keys.filter { !currentUris.contains(it) } val totalFiles = currentUris.size - val skippedCount = (totalFiles - audioFiles.size).coerceAtLeast(0) + val filesToProcess = audioFiles.size + cueFilesToScan.size + val skippedCount = (totalFiles - filesToProcess).coerceAtLeast(0) updateSafScanProgress { it.totalFiles = totalFiles } - if (audioFiles.isEmpty()) { + if (audioFiles.isEmpty() && cueFilesToScan.isEmpty()) { updateSafScanProgress { it.isComplete = true it.scannedFiles = totalFiles @@ -1128,6 +1364,173 @@ class MainActivity: FlutterFragmentActivity() { var scanned = 0 var errors = traversalErrors + // --- CUE first pass: parse new/modified CUE sheets --- + val cueReferencedAudioUris = mutableSetOf() + + for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", skippedCount) + result.put("totalFiles", totalFiles) + result.put("cancelled", true) + return result.toString() + } + + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + updateSafScanProgress { it.currentFile = cueName } + + var tempCuePath: String? = null + var tempAudioPath: String? = null + try { + // Copy CUE to temp + tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCuePath == null) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy CUE ${cueDoc.uri}") + scanned++ + continue + } + + // Extract the audio filename from the CUE sheet text + val audioFileName = extractCueAudioFileName(tempCuePath) + + // Find the referenced audio file as a sibling in the same SAF directory + var audioDoc: DocumentFile? = null + if (!audioFileName.isNullOrBlank()) { + audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } + } + + // Fallback: try common audio extensions with the CUE base name + if (audioDoc == null) { + val cueBaseName = cueName.substringBeforeLast('.') + val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") + for (ext in commonExts) { + audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } + if (audioDoc != null) break + audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } + if (audioDoc != null) break + } + } + + if (audioDoc == null) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName") + errors++ + scanned++ + continue + } + + // Mark this audio file so we skip it in the regular audio pass + cueReferencedAudioUris.add(audioDoc.uri.toString()) + + // Copy audio to same temp dir so Go can resolve it + val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath + 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 + + tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) + if (tempAudioPath == null) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy audio for CUE $cueName") + errors++ + scanned++ + continue + } + + // Rename temp audio to its original name so Go can find it by name + val renamedAudio = File(tempDir, audioName) + val tempAudioFile = File(tempAudioPath) + if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { + tempAudioFile.renameTo(renamedAudio) + tempAudioPath = renamedAudio.absolutePath + } + + // Call Go to produce library scan entries for each CUE track + val cueResultsJson = Gobackend.scanCueSheetForLibrary( + tempCuePath, + tempDir, + cueDoc.uri.toString(), + cueLastModified + ) + + val cueArray = JSONArray(cueResultsJson) + for (j in 0 until cueArray.length()) { + val trackObj = cueArray.getJSONObject(j) + results.put(trackObj) + // Register each virtual path as current so deletion detection works + val virtualPath = trackObj.optString("filePath", "") + if (virtualPath.isNotBlank()) { + currentUris.add(virtualPath) + } + } + + android.util.Log.d( + "SpotiFLAC", + "SAF incremental scan: CUE $cueName -> ${cueArray.length()} tracks" + ) + } catch (e: Exception) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF incremental scan: error processing CUE $cueName: ${e.message}") + } finally { + try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} + try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} + } + + scanned++ + val processed = skippedCount + scanned + val pct = if (totalFiles > 0) { + processed.toDouble() / totalFiles.toDouble() * 100.0 + } else { + 100.0 + } + updateSafScanProgress { + it.scannedFiles = processed + it.errorCount = errors + it.progressPct = pct + } + } + + // Discover audio siblings for unchanged CUE files so we skip them + // in the regular audio pass. Copy the .cue to temp (tiny file) to extract + // the audio filename, then find the sibling by name. + for ((cueDoc, parentDir) in unchangedCueFiles) { + var tempCue: String? = null + try { + tempCue = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCue != null) { + val audioFileName = extractCueAudioFileName(tempCue) + var audioDoc: DocumentFile? = null + if (!audioFileName.isNullOrBlank()) { + audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } + } + // Fallback: try common extensions with CUE base name + if (audioDoc == null) { + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + val cueBaseName = cueName.substringBeforeLast('.') + if (cueBaseName.isNotBlank()) { + val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") + for (ext in commonExts) { + audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } + if (audioDoc != null) break + audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } + if (audioDoc != null) break + } + } + } + if (audioDoc != null) { + cueReferencedAudioUris.add(audioDoc.uri.toString()) + } + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to resolve audio for unchanged CUE: ${e.message}") + } finally { + try { tempCue?.let { File(it).delete() } } catch (_: Exception) {} + } + } + + // --- Regular audio file pass: skip files referenced by CUE sheets --- for ((doc, _, lastModified) in audioFiles) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } @@ -1140,6 +1543,22 @@ class MainActivity: FlutterFragmentActivity() { return result.toString() } + // Skip audio files that are represented by CUE track entries + if (cueReferencedAudioUris.contains(doc.uri.toString())) { + scanned++ + val processed = skippedCount + scanned + val pct = if (totalFiles > 0) { + processed.toDouble() / totalFiles.toDouble() * 100.0 + } else { + 100.0 + } + updateSafScanProgress { + it.scannedFiles = processed + it.progressPct = pct + } + continue + } + val name = try { doc.name ?: "" } catch (_: Exception) { "" } updateSafScanProgress { it.currentFile = name @@ -1194,6 +1613,9 @@ class MainActivity: FlutterFragmentActivity() { } } + // Recalculate removedUris now that CUE virtual paths have been registered + val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) } + updateSafScanProgress { it.isComplete = true it.progressPct = 100.0 @@ -1201,7 +1623,7 @@ class MainActivity: FlutterFragmentActivity() { val result = JSONObject() result.put("files", results) - result.put("removedUris", JSONArray(removedUris)) + result.put("removedUris", JSONArray(finalRemovedUris)) result.put("skippedCount", skippedCount) result.put("totalFiles", totalFiles) return result.toString() @@ -2756,6 +3178,89 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + // CUE Sheet Parsing + "parseCueSheet" -> { + val cuePath = call.argument("cue_path") ?: "" + val audioDir = call.argument("audio_dir") ?: "" + val response = withContext(Dispatchers.IO) { + try { + if (cuePath.startsWith("content://")) { + val uri = Uri.parse(cuePath) + val tempCuePath = copyUriToTemp(uri, ".cue") + ?: return@withContext """{"error":"Failed to copy CUE file to temp"}""" + var tempAudioPath: String? = null + try { + // Extract audio filename from CUE text + val audioFileName = extractCueAudioFileName(tempCuePath) + + // Try to find the audio sibling in SAF + var audioDoc: DocumentFile? = null + val parentDir = safParentDir(uri) + if (parentDir != null && !audioFileName.isNullOrBlank()) { + audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } + } + + // Fallback: try common extensions with the CUE base name + if (audioDoc == null && parentDir != null) { + val cueName = try { + DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: "" + } catch (_: Exception) { "" } + val cueBaseName = cueName.substringBeforeLast('.') + if (cueBaseName.isNotBlank()) { + val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") + for (ext in commonExts) { + audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } + if (audioDoc != null) break + audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } + if (audioDoc != null) break + } + } + } + + val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath + if (audioDoc != null) { + // Copy audio to same temp dir with original name + val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } + val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null + val copiedAudio = copyUriToTemp(audioDoc.uri, fallbackExt) + if (copiedAudio != null) { + val renamedAudio = File(tempDir, audioName) + val copiedFile = File(copiedAudio) + if (renamedAudio.absolutePath != copiedFile.absolutePath) { + copiedFile.renameTo(renamedAudio) + } + tempAudioPath = renamedAudio.absolutePath + } + } + + // Parse with audio in temp dir; Go will resolve there + val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir) + + // Replace the temp audio_path with the SAF content:// URI + // so Dart knows it's a SAF file and handles it accordingly + if (audioDoc != null) { + val resultObj = JSONObject(resultJson) + resultObj.put("audio_path", audioDoc.uri.toString()) + // Also pass the original CUE URI for reference + resultObj.put("cue_path", cuePath) + resultObj.toString() + } else { + resultJson + } + } finally { + try { File(tempCuePath).delete() } catch (_: Exception) {} + try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} + } + } else { + Gobackend.parseCueSheet(cuePath, audioDir) + } + } catch (e: Exception) { + """{"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go new file mode 100644 index 00000000..a938be2a --- /dev/null +++ b/go_backend/cue_parser.go @@ -0,0 +1,577 @@ +package gobackend + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// CueSheet represents a parsed .cue file +type CueSheet struct { + // Album-level metadata + Performer string `json:"performer"` + Title string `json:"title"` + FileName string `json:"file_name"` + FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc. + Genre string `json:"genre,omitempty"` + Date string `json:"date,omitempty"` + Comment string `json:"comment,omitempty"` + Composer string `json:"composer,omitempty"` + Tracks []CueTrack `json:"tracks"` +} + +// 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"` + // Index positions in seconds (fractional) + StartTime float64 `json:"start_time"` // INDEX 01 in seconds + PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present) +} + +// CueSplitInfo represents the information needed to split a CUE+audio file +type CueSplitInfo struct { + CuePath string `json:"cue_path"` + AudioPath string `json:"audio_path"` + Album string `json:"album"` + Artist string `json:"artist"` + Genre string `json:"genre,omitempty"` + Date string `json:"date,omitempty"` + Tracks []CueSplitTrack `json:"tracks"` +} + +// CueSplitTrack has the FFmpeg split parameters for a single track +type CueSplitTrack struct { + Number int `json:"number"` + Title string `json:"title"` + Artist string `json:"artist"` + ISRC string `json:"isrc,omitempty"` + Composer string `json:"composer,omitempty"` + StartSec float64 `json:"start_sec"` + EndSec float64 `json:"end_sec"` // -1 means until end of file +} + +var ( + reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`) + reQuoted = regexp.MustCompile(`"([^"]*)"`) +) + +// ParseCueFile parses a .cue file and returns a CueSheet +func ParseCueFile(cuePath string) (*CueSheet, error) { + f, err := os.Open(cuePath) + if err != nil { + return nil, fmt.Errorf("failed to open cue file: %w", err) + } + defer f.Close() + + sheet := &CueSheet{} + var currentTrack *CueTrack + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + // Handle BOM at start of file + if strings.HasPrefix(line, "\xef\xbb\xbf") { + line = strings.TrimPrefix(line, "\xef\xbb\xbf") + line = strings.TrimSpace(line) + } + + upper := strings.ToUpper(line) + + // REM commands (album-level metadata) + if strings.HasPrefix(upper, "REM ") { + matches := reRemCommand.FindStringSubmatch(line) + if len(matches) == 3 { + key := strings.ToUpper(matches[1]) + value := unquoteCue(matches[2]) + switch key { + case "GENRE": + sheet.Genre = value + case "DATE": + sheet.Date = value + case "COMMENT": + sheet.Comment = value + case "COMPOSER": + if currentTrack != nil { + currentTrack.Composer = value + } else { + sheet.Composer = value + } + } + } + continue + } + + // PERFORMER + if strings.HasPrefix(upper, "PERFORMER ") { + value := unquoteCue(line[len("PERFORMER "):]) + if currentTrack != nil { + currentTrack.Performer = value + } else { + sheet.Performer = value + } + continue + } + + // TITLE + if strings.HasPrefix(upper, "TITLE ") { + value := unquoteCue(line[len("TITLE "):]) + if currentTrack != nil { + currentTrack.Title = value + } else { + sheet.Title = value + } + continue + } + + // FILE + if strings.HasPrefix(upper, "FILE ") { + rest := line[len("FILE "):] + // Extract filename and type + // Format: FILE "filename.flac" WAVE + // or: FILE filename.flac WAVE + fname, ftype := parseCueFileLine(rest) + sheet.FileName = fname + sheet.FileType = ftype + continue + } + + // TRACK + if strings.HasPrefix(upper, "TRACK ") { + // Save previous track + if currentTrack != nil { + sheet.Tracks = append(sheet.Tracks, *currentTrack) + } + + parts := strings.Fields(line) + trackNum := 0 + if len(parts) >= 2 { + trackNum, _ = strconv.Atoi(parts[1]) + } + + currentTrack = &CueTrack{ + Number: trackNum, + PreGap: -1, + } + continue + } + + // INDEX + if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil { + parts := strings.Fields(line) + if len(parts) >= 3 { + indexNum, _ := strconv.Atoi(parts[1]) + timeSec := parseCueTimestamp(parts[2]) + switch indexNum { + case 0: + currentTrack.PreGap = timeSec + case 1: + currentTrack.StartTime = timeSec + } + } + continue + } + + // ISRC + if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil { + currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):]) + continue + } + + // SONGWRITER (used as composer sometimes) + if strings.HasPrefix(upper, "SONGWRITER ") { + value := unquoteCue(line[len("SONGWRITER "):]) + if currentTrack != nil { + currentTrack.Composer = value + } else { + sheet.Composer = value + } + continue + } + } + + // Don't forget the last track + if currentTrack != nil { + sheet.Tracks = append(sheet.Tracks, *currentTrack) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading cue file: %w", err) + } + + if len(sheet.Tracks) == 0 { + return nil, fmt.Errorf("no tracks found in cue file") + } + + return sheet, nil +} + +// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds +func parseCueTimestamp(ts string) float64 { + parts := strings.Split(ts, ":") + if len(parts) != 3 { + return 0 + } + + minutes, _ := strconv.Atoi(parts[0]) + seconds, _ := strconv.Atoi(parts[1]) + frames, _ := strconv.Atoi(parts[2]) + + return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0 +} + +// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg +func formatCueTimestamp(seconds float64) string { + if seconds < 0 { + return "0" + } + hours := int(seconds) / 3600 + mins := (int(seconds) % 3600) / 60 + secs := seconds - float64(hours*3600) - float64(mins*60) + return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs) +} + +// unquoteCue removes surrounding quotes from a CUE value +func unquoteCue(s string) string { + s = strings.TrimSpace(s) + if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 { + return matches[1] + } + return s +} + +// parseCueFileLine parses the FILE command's filename and type +func parseCueFileLine(rest string) (string, string) { + rest = strings.TrimSpace(rest) + + var filename, ftype string + + if strings.HasPrefix(rest, "\"") { + // Quoted filename + endQuote := strings.Index(rest[1:], "\"") + if endQuote >= 0 { + filename = rest[1 : endQuote+1] + remaining := strings.TrimSpace(rest[endQuote+2:]) + ftype = remaining + } else { + filename = rest + } + } else { + // Unquoted filename - last word is the type + parts := strings.Fields(rest) + if len(parts) >= 2 { + ftype = parts[len(parts)-1] + filename = strings.Join(parts[:len(parts)-1], " ") + } else if len(parts) == 1 { + filename = parts[0] + } + } + + return filename, strings.TrimSpace(ftype) +} + +// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet. +// It checks relative to the cue file's directory. +func ResolveCueAudioPath(cuePath string, cueFileName string) string { + cueDir := filepath.Dir(cuePath) + + // 1. Try the exact filename from the .cue + candidate := filepath.Join(cueDir, cueFileName) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + + // 2. Try common case variations + baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName)) + commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"} + for _, ext := range commonExts { + candidate = filepath.Join(cueDir, baseName+ext) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + // Try uppercase ext + candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext)) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + + // 3. Try to find any audio file with the same base name as the .cue file + cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath)) + for _, ext := range commonExts { + candidate = filepath.Join(cueDir, cueBase+ext) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + + // 4. If there's only one audio file in the directory, use that + entries, err := os.ReadDir(cueDir) + if err == nil { + audioExts := map[string]bool{ + ".flac": true, ".wav": true, ".ape": true, ".mp3": true, + ".ogg": true, ".wv": true, ".m4a": true, ".aiff": true, + } + var audioFiles []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(entry.Name())) + if audioExts[ext] { + audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name())) + } + } + if len(audioFiles) == 1 { + return audioFiles[0] + } + } + + return "" +} + +// BuildCueSplitInfo creates the split information from a parsed CUE sheet. +// This is returned to the Dart side so FFmpeg can perform the splitting. +// audioDir, if non-empty, overrides the directory for audio file resolution. +func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) { + resolveDir := cuePath + if audioDir != "" { + // Create a virtual path in audioDir so ResolveCueAudioPath looks there + resolveDir = filepath.Join(audioDir, filepath.Base(cuePath)) + } + audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName) + if audioPath == "" { + return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName) + } + + info := &CueSplitInfo{ + CuePath: cuePath, + AudioPath: audioPath, + Album: sheet.Title, + Artist: sheet.Performer, + Genre: sheet.Genre, + Date: sheet.Date, + } + + for i, track := range sheet.Tracks { + performer := track.Performer + if performer == "" { + performer = sheet.Performer + } + + composer := track.Composer + if composer == "" { + composer = sheet.Composer + } + + // End time is the start of the next track, or -1 for the last track + endSec := float64(-1) + if i+1 < len(sheet.Tracks) { + nextTrack := sheet.Tracks[i+1] + // Use pre-gap of next track if available, otherwise its start time + if nextTrack.PreGap >= 0 { + endSec = nextTrack.PreGap + } else { + endSec = nextTrack.StartTime + } + } + + info.Tracks = append(info.Tracks, CueSplitTrack{ + Number: track.Number, + Title: track.Title, + Artist: performer, + ISRC: track.ISRC, + Composer: composer, + StartSec: track.StartTime, + EndSec: endSec, + }) + } + + return info, nil +} + +// ParseCueFileJSON parses a .cue file and returns JSON with split info. +// This is the main entry point called from Dart via the platform bridge. +// audioDir, if non-empty, overrides the directory used for resolving the +// referenced audio file (useful when the .cue was copied to a temp dir +// but the audio still lives in the original location, e.g. SAF). +func ParseCueFileJSON(cuePath string, audioDir string) (string, error) { + sheet, err := ParseCueFile(cuePath) + if err != nil { + return "", fmt.Errorf("failed to parse cue file: %w", err) + } + + info, err := BuildCueSplitInfo(cuePath, sheet, audioDir) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(info) + if err != nil { + return "", fmt.Errorf("failed to marshal cue split info: %w", err) + } + + return string(jsonBytes), nil +} + +// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult +// entries, one per track. This is used by the library scanner to populate the +// library with individual track entries from a single CUE+FLAC album. +func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) { + return scanCueFileForLibraryInternal(cuePath, "", "", 0, scanTime) +} + +// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters +// for SAF (Storage Access Framework) scenarios: +// - audioDir: if non-empty, overrides the directory used to find the audio file +// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for +// virtual file paths (e.g. a content:// URI). IDs are also based on this. +// - fileModTime: if > 0, used as the FileModTime for all results instead of +// stat-ing the cuePath on disk (useful when the real file lives behind SAF) +func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { + return scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime) +} + +func scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { + sheet, err := ParseCueFile(cuePath) + if err != nil { + return nil, err + } + + // Resolve audio file — optionally in an overridden directory + resolveBase := cuePath + if audioDir != "" { + resolveBase = filepath.Join(audioDir, filepath.Base(cuePath)) + } + audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName) + if audioPath == "" { + return nil, fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName) + } + + // Try to get quality info from the audio file + var bitDepth, sampleRate int + var totalDurationSec float64 + audioExt := strings.ToLower(filepath.Ext(audioPath)) + switch audioExt { + case ".flac": + quality, qErr := GetAudioQuality(audioPath) + if qErr == nil { + bitDepth = quality.BitDepth + sampleRate = quality.SampleRate + if quality.SampleRate > 0 && quality.TotalSamples > 0 { + totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate) + } + } + case ".mp3": + quality, qErr := GetMP3Quality(audioPath) + if qErr == nil { + sampleRate = quality.SampleRate + totalDurationSec = float64(quality.Duration) + } + } + + // Extract cover from audio file for all tracks + var coverPath string + libraryCoverCacheMu.RLock() + coverCacheDir := libraryCoverCacheDir + libraryCoverCacheMu.RUnlock() + if coverCacheDir != "" { + cp, err := SaveCoverToCache(audioPath, coverCacheDir) + if err == nil && cp != "" { + coverPath = cp + } + } + + // Determine the base path for virtual paths and IDs + pathBase := cuePath + if virtualPathPrefix != "" { + pathBase = virtualPathPrefix + } + + // Determine fileModTime + modTime := fileModTime + if modTime <= 0 { + if info, err := os.Stat(cuePath); err == nil { + modTime = info.ModTime().UnixMilli() + } + } + + var results []LibraryScanResult + for i, track := range sheet.Tracks { + performer := track.Performer + if performer == "" { + performer = sheet.Performer + } + if performer == "" { + performer = "Unknown Artist" + } + + title := track.Title + if title == "" { + title = fmt.Sprintf("Track %02d", track.Number) + } + + album := sheet.Title + if album == "" { + album = "Unknown Album" + } + + // Calculate duration for this track + var duration int + if i+1 < len(sheet.Tracks) { + nextStart := sheet.Tracks[i+1].StartTime + if sheet.Tracks[i+1].PreGap >= 0 { + nextStart = sheet.Tracks[i+1].PreGap + } + duration = int(nextStart - track.StartTime) + } else if totalDurationSec > 0 { + duration = int(totalDurationSec - track.StartTime) + } + + // Use a unique ID based on pathBase + track number + id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number)) + + // Use a virtual file path that includes the track number to ensure + // uniqueness in the database (file_path has a UNIQUE constraint). + // Format: /path/to/album.cue#track01 or content://...album.cue#track01 + virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number) + + result := LibraryScanResult{ + ID: id, + TrackName: title, + ArtistName: performer, + AlbumName: album, + AlbumArtist: sheet.Performer, + FilePath: virtualFilePath, + CoverPath: coverPath, + ScannedAt: scanTime, + ISRC: track.ISRC, + TrackNumber: track.Number, + DiscNumber: 1, + Duration: duration, + ReleaseDate: sheet.Date, + BitDepth: bitDepth, + SampleRate: sampleRate, + Genre: sheet.Genre, + Format: "cue+" + strings.TrimPrefix(audioExt, "."), + } + + result.FileModTime = modTime + + results = append(results, result) + } + + return results, nil +} diff --git a/go_backend/exports.go b/go_backend/exports.go index 08efb1fb..5f83330a 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -901,6 +901,32 @@ func ReadFileMetadata(filePath string) (string, error) { return string(jsonBytes), nil } +// ParseCueSheet parses a .cue file and returns JSON with split information. +// This is called from Dart to get track listing and timing data for CUE splitting. +// audioDir, if non-empty, overrides the directory used for resolving the +// referenced audio file (useful for SAF temp file scenarios). +func ParseCueSheet(cuePath string, audioDir string) (string, error) { + return ParseCueFileJSON(cuePath, audioDir) +} + +// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of +// LibraryScanResult entries (one per track). This is the SAF-friendly variant: +// - audioDir overrides where the referenced audio file is resolved +// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI) +// - fileModTime is stamped on every result (pass 0 to stat cuePath instead) +func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) { + scanTime := time.Now().UTC().Format(time.RFC3339) + results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, 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. diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index d9e53663..34d5c718 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -63,6 +63,7 @@ var supportedAudioFormats = map[string]bool{ ".mp3": true, ".opus": true, ".ogg": true, + ".cue": true, } type libraryAudioFileInfo struct { @@ -166,6 +167,23 @@ func ScanLibraryFolder(folderPath string) (string, error) { scanTime := time.Now().UTC().Format(time.RFC3339) errorCount := 0 + // Track audio files referenced by .cue sheets to avoid duplicates + cueReferencedAudioFiles := make(map[string]bool) + + // First pass: scan .cue files to collect referenced audio paths + for _, filePath := range audioFiles { + ext := strings.ToLower(filepath.Ext(filePath)) + if ext == ".cue" { + sheet, err := ParseCueFile(filePath) + if err == nil && sheet.FileName != "" { + audioPath := ResolveCueAudioPath(filePath, sheet.FileName) + if audioPath != "" { + cueReferencedAudioFiles[audioPath] = true + } + } + } + } + for i, filePath := range audioFiles { select { case <-cancelCh: @@ -179,6 +197,28 @@ func ScanLibraryFolder(folderPath string) (string, error) { libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100 libraryScanProgressMu.Unlock() + ext := strings.ToLower(filepath.Ext(filePath)) + + // Handle .cue files: produce multiple track results + if ext == ".cue" { + cueResults, err := ScanCueFileForLibrary(filePath, scanTime) + if err != nil { + errorCount++ + GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err) + continue + } + results = append(results, cueResults...) + GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults)) + continue + } + + // Skip audio files that are referenced by a .cue sheet + // (they will be represented by the cue sheet's track entries instead) + if cueReferencedAudioFiles[filePath] { + GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath)) + continue + } + result, err := scanAudioFile(filePath, scanTime) if err != nil { errorCount++ @@ -502,9 +542,44 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, var filesToScan []libraryAudioFileInfo skippedCount := 0 + // Build a set of existing CUE virtual path base files for incremental matching. + // CUE tracks are stored with virtual paths like "/path/album.cue#track01". + // We need to match these against the actual .cue file's modTime. + cueBaseModTimes := make(map[string]int64) // base cue path -> modTime from disk + for _, f := range currentFiles { + if strings.ToLower(filepath.Ext(f.path)) == ".cue" { + cueBaseModTimes[f.path] = f.modTime + } + } + for _, f := range currentFiles { existingModTime, exists := existingFiles[f.path] if !exists { + // For .cue files, also check if any virtual path entries exist + if strings.ToLower(filepath.Ext(f.path)) == ".cue" { + hasCueTracks := false + for existingPath := range existingFiles { + if strings.HasPrefix(existingPath, f.path+"#track") { + hasCueTracks = true + break + } + } + if hasCueTracks { + // CUE file exists in DB via virtual paths; check if modTime changed + // Use modTime from any virtual path (they all share the same .cue modTime) + for existingPath, modTime := range existingFiles { + if strings.HasPrefix(existingPath, f.path+"#track") { + if f.modTime == modTime { + skippedCount++ + } else { + filesToScan = append(filesToScan, f) + } + break + } + } + continue + } + } filesToScan = append(filesToScan, f) } else if f.modTime != existingModTime { filesToScan = append(filesToScan, f) @@ -515,7 +590,16 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, var deletedPaths []string for existingPath := range existingFiles { - if !currentPathSet[existingPath] { + // For CUE virtual paths (e.g. "/path/album.cue#track01"), + // check if the base .cue file still exists on disk + if idx := strings.LastIndex(existingPath, "#track"); idx > 0 { + baseCuePath := existingPath[:idx] + if currentPathSet[baseCuePath] { + continue // Base .cue file still exists, not deleted + } + // Base CUE file is gone, mark virtual path as deleted + deletedPaths = append(deletedPaths, existingPath) + } else if !currentPathSet[existingPath] { deletedPaths = append(deletedPaths, existingPath) } } @@ -544,6 +628,21 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, scanTime := time.Now().UTC().Format(time.RFC3339) errorCount := 0 + // Track audio files referenced by .cue sheets to avoid duplicates (incremental) + cueReferencedAudioFilesInc := make(map[string]bool) + for _, f := range filesToScan { + ext := strings.ToLower(filepath.Ext(f.path)) + if ext == ".cue" { + sheet, err := ParseCueFile(f.path) + if err == nil && sheet.FileName != "" { + audioPath := ResolveCueAudioPath(f.path, sheet.FileName) + if audioPath != "" { + cueReferencedAudioFilesInc[audioPath] = true + } + } + } + } + for i, f := range filesToScan { select { case <-cancelCh: @@ -557,6 +656,25 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100 libraryScanProgressMu.Unlock() + ext := strings.ToLower(filepath.Ext(f.path)) + + // Handle .cue files: produce multiple track results + if ext == ".cue" { + cueResults, err := ScanCueFileForLibrary(f.path, scanTime) + if err != nil { + errorCount++ + GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err) + continue + } + results = append(results, cueResults...) + continue + } + + // Skip audio files referenced by .cue sheets + if cueReferencedAudioFilesInc[f.path] { + continue + } + result, err := scanAudioFile(f.path, scanTime) if err != nil { errorCount++ diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 4cb53238..b85020da 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -969,6 +969,15 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + // CUE Sheet Parsing + case "parseCueSheet": + let args = call.arguments as! [String: Any] + let cuePath = args["cue_path"] as! String + let audioDir = args["audio_dir"] as? String ?? "" + let response = GobackendParseCueSheet(cuePath, audioDir, &error) + if let error = error { throw error } + return response + default: throw NSError( domain: "SpotiFLAC", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c059621b..d722f825 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3812,6 +3812,78 @@ abstract class AppLocalizations { /// **'Conversion failed'** String get trackConvertFailed; + /// Title for CUE split bottom sheet + /// + /// In en, this message translates to: + /// **'Split CUE Sheet'** + String get cueSplitTitle; + + /// Subtitle for CUE split menu item + /// + /// In en, this message translates to: + /// **'Split CUE+FLAC into individual tracks'** + String get cueSplitSubtitle; + + /// Album name in CUE split sheet + /// + /// In en, this message translates to: + /// **'Album: {album}'** + String cueSplitAlbum(String album); + + /// Artist name in CUE split sheet + /// + /// In en, this message translates to: + /// **'Artist: {artist}'** + String cueSplitArtist(String artist); + + /// Number of tracks in CUE sheet + /// + /// In en, this message translates to: + /// **'{count} tracks'** + String cueSplitTrackCount(int count); + + /// CUE split confirmation dialog title + /// + /// In en, this message translates to: + /// **'Split CUE Album'** + String get cueSplitConfirmTitle; + + /// CUE split confirmation dialog message + /// + /// In en, this message translates to: + /// **'Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.'** + String cueSplitConfirmMessage(String album, int count); + + /// Snackbar while splitting CUE + /// + /// In en, this message translates to: + /// **'Splitting CUE sheet... ({current}/{total})'** + String cueSplitSplitting(int current, int total); + + /// Snackbar after successful CUE split + /// + /// In en, this message translates to: + /// **'Split into {count} tracks successfully'** + String cueSplitSuccess(int count); + + /// Snackbar when CUE split fails + /// + /// In en, this message translates to: + /// **'CUE split failed'** + String get cueSplitFailed; + + /// Error when CUE audio file is missing + /// + /// In en, this message translates to: + /// **'Audio file not found for this CUE sheet'** + String get cueSplitNoAudioFile; + + /// Button text to start CUE splitting + /// + /// In en, this message translates to: + /// **'Split into Tracks'** + String get cueSplitButton; + /// Generic action button - create /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 246f5314..1bb33ef5 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2164,6 +2164,54 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackConvertFailed => 'Konvertierung fehlgeschlagen'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Erstellen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c583ab11..bd9d4589 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2137,6 +2137,54 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index f4758a27..ed9236ad 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2137,6 +2137,54 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index c93b863f..d6a3b948 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2139,6 +2139,54 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index a12620b5..a461bd5b 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2137,6 +2137,54 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index e408e8ae..b668481b 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2144,6 +2144,54 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 9e898bdf..71d10e1a 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2124,6 +2124,54 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackConvertFailed => '変換に失敗しました'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 66c66e99..43bfbce3 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2117,6 +2117,54 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 6144f9b6..2eeb00cc 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2137,6 +2137,54 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index d5c8d821..9436f6eb 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2137,6 +2137,54 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 909b5842..943b75f9 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2190,6 +2190,54 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackConvertFailed => 'Ошибка конвертации'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Создать'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index b608cff7..223fecd3 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2149,6 +2149,54 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 4104845c..57f92319 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2137,6 +2137,54 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index bb8bac99..bb3036c9 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2820,6 +2820,90 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, + "cueSplitTitle": "Split CUE Sheet", + "@cueSplitTitle": { + "description": "Title for CUE split bottom sheet" + }, + "cueSplitSubtitle": "Split CUE+FLAC into individual tracks", + "@cueSplitSubtitle": { + "description": "Subtitle for CUE split menu item" + }, + "cueSplitAlbum": "Album: {album}", + "@cueSplitAlbum": { + "description": "Album name in CUE split sheet", + "placeholders": { + "album": { + "type": "String" + } + } + }, + "cueSplitArtist": "Artist: {artist}", + "@cueSplitArtist": { + "description": "Artist name in CUE split sheet", + "placeholders": { + "artist": { + "type": "String" + } + } + }, + "cueSplitTrackCount": "{count} tracks", + "@cueSplitTrackCount": { + "description": "Number of tracks in CUE sheet", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "cueSplitConfirmTitle": "Split CUE Album", + "@cueSplitConfirmTitle": { + "description": "CUE split confirmation dialog title" + }, + "cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.", + "@cueSplitConfirmMessage": { + "description": "CUE split confirmation dialog message", + "placeholders": { + "album": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})", + "@cueSplitSplitting": { + "description": "Snackbar while splitting CUE", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "cueSplitSuccess": "Split into {count} tracks successfully", + "@cueSplitSuccess": { + "description": "Snackbar after successful CUE split", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "cueSplitFailed": "CUE split failed", + "@cueSplitFailed": { + "description": "Snackbar when CUE split fails" + }, + "cueSplitNoAudioFile": "Audio file not found for this CUE sheet", + "@cueSplitNoAudioFile": { + "description": "Error when CUE audio file is missing" + }, + "cueSplitButton": "Split into Tracks", + "@cueSplitButton": { + "description": "Button text to start CUE splitting" + }, "actionCreate": "Create", "@actionCreate": { "description": "Generic action button - create" diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index c35f6a00..bcaa14c0 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -24,6 +24,9 @@ class PlaybackController extends Notifier { String coverUrl = '', Track? track, }) async { + if (isCueVirtualPath(path)) { + throw Exception(cueVirtualTrackRequiresSplitMessage); + } _log.d('Opening external player for "$title" by $artist: $path'); await openFile(path); } @@ -32,11 +35,16 @@ class PlaybackController extends Notifier { if (tracks.isEmpty) return; final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex); + var skippedCueVirtualTrack = false; for (final track in orderedTracks) { final resolvedPath = await _resolveTrackPath(track); if (resolvedPath == null) { continue; } + if (isCueVirtualPath(resolvedPath)) { + skippedCueVirtualTrack = true; + continue; + } _log.d( 'Opening first available external track for list playback: ' @@ -46,6 +54,10 @@ class PlaybackController extends Notifier { return; } + if (skippedCueVirtualTrack) { + throw Exception(cueVirtualTrackRequiresSplitMessage); + } + throw Exception( 'No local audio file is available to open. Download the track first.', ); diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 79eca05b..9c560546 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -38,6 +38,14 @@ class _LocalAlbumScreenState extends ConsumerState { final ScrollController _scrollController = ScrollController(); late List _sortedTracksCache; late Map> _discGroupsCache; + + void _showCueVirtualTrackSnackBar() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text(cueVirtualTrackRequiresSplitMessage), + ), + ); + } late List _sortedDiscNumbersCache; late bool _hasMultipleDiscsCache; String? _commonQualityCache; @@ -178,9 +186,11 @@ class _LocalAlbumScreenState extends ConsumerState { for (final id in idsToDelete) { final item = tracksById[id]; if (item != null) { - try { - await deleteFile(item.filePath); - } catch (_) {} + if (!isCueVirtualPath(item.filePath)) { + try { + await deleteFile(item.filePath); + } catch (_) {} + } await libraryNotifier.removeItem(id); deletedCount++; } @@ -203,6 +213,10 @@ class _LocalAlbumScreenState extends ConsumerState { } Future _openFile(LocalLibraryItem track) async { + if (isCueVirtualPath(track.filePath)) { + _showCueVirtualTrackSnackBar(); + return; + } try { await ref .read(playbackProvider.notifier) diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index fdcd3eef..d8693612 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -13,11 +13,14 @@ import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; final _log = AppLogger('TrackMetadata'); @@ -214,10 +217,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _checkFile() async { - var filePath = _filePath; - if (filePath.startsWith('EXISTS:')) { - filePath = filePath.substring(7); - } + final filePath = cleanFilePath; bool exists = false; int? size; @@ -544,11 +544,65 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - String get cleanFilePath { + /// The raw file path, with EXISTS: prefix stripped but #trackNN preserved. + /// Use this when you need the full virtual path (e.g. for display or DB lookups). + String get rawFilePath { final path = _filePath; return path.startsWith('EXISTS:') ? path.substring(7) : path; } + /// The clean file path with both EXISTS: prefix and #trackNN suffix stripped. + /// Use this for actual filesystem/SAF operations. + String get cleanFilePath { + var path = _filePath; + if (path.startsWith('EXISTS:')) path = path.substring(7); + // Strip CUE virtual path suffix for filesystem operations + if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path); + return path; + } + + bool get _isCueVirtualTrack => isCueVirtualPath(rawFilePath); + + String _cueVirtualTrackGuidance(BuildContext context) { + return 'This CUE track is virtual. Use ${context.l10n.cueSplitButton} first.'; + } + + void _showCueVirtualTrackSnackBar(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_cueVirtualTrackGuidance(context))), + ); + } + + void _hideCurrentSnackBar() { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + } + + String get _l10nCueSplitFailed => context.l10n.cueSplitFailed; + String get _l10nCueSplitNoAudioFile => context.l10n.cueSplitNoAudioFile; + + String _l10nCueSplitSplitting(int current, int total) { + return context.l10n.cueSplitSplitting(current, total); + } + + String _l10nCueSplitSuccess(int count) { + return context.l10n.cueSplitSuccess(count); + } + + void _showSnackBarMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + + void _showLongSnackBarMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 60), + ), + ); + } + String _formatPathForDisplay(String pathOrUri) { if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) { return pathOrUri; @@ -1153,8 +1207,8 @@ class _TrackMetadataScreenState extends ConsumerState { bool fileExists, int? fileSize, ) { - final displayFilePath = _formatPathForDisplay(cleanFilePath); - final fileName = _extractFileNameFromPathOrUri(cleanFilePath); + final displayFilePath = _formatPathForDisplay(rawFilePath); + final fileName = _extractFileNameFromPathOrUri(rawFilePath); final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; @@ -2365,7 +2419,7 @@ class _TrackMetadataScreenState extends ConsumerState { flex: 2, child: FilledButton.icon( onPressed: fileExists - ? () => _openFile(context, cleanFilePath) + ? () => _openFile(context, rawFilePath) : null, icon: const Icon(Icons.play_arrow), label: Text(context.l10n.trackMetadataPlay), @@ -2487,6 +2541,16 @@ class _TrackMetadataScreenState extends ConsumerState { _showConvertSheet(context); }, ), + if (_fileExists && _isCueFile) + ListTile( + leading: const Icon(Icons.call_split), + title: Text(context.l10n.cueSplitTitle), + subtitle: Text(context.l10n.cueSplitSubtitle), + onTap: () { + Navigator.pop(context); + _showCueSplitSheet(context); + }, + ), const Divider(height: 1), ListTile( leading: const Icon(Icons.share), @@ -2524,11 +2588,34 @@ class _TrackMetadataScreenState extends ConsumerState { lower.endsWith('.ogg'); } + /// Whether the current file is a CUE sheet (or CUE-referenced) + bool get _isCueFile { + // Check if the raw path has a CUE virtual path suffix + if (isCueVirtualPath(rawFilePath)) return true; + final lower = cleanFilePath.toLowerCase(); + if (lower.endsWith('.cue')) return true; + // Check if local library item has cue+ format + if (_isLocalItem && _localLibraryItem != null) { + final format = _localLibraryItem!.format ?? ''; + if (format.startsWith('cue+')) return true; + } + return false; + } + String get _currentFileFormat { + // For CUE tracks, use the format from the library item (e.g. "cue+flac") + if (_isCueFile && _isLocalItem && _localLibraryItem != null) { + final format = _localLibraryItem!.format ?? ''; + if (format.startsWith('cue+')) { + final audioFmt = format.substring(4).toUpperCase(); + return 'CUE+$audioFmt'; + } + } final lower = cleanFilePath.toLowerCase(); if (lower.endsWith('.flac')) return 'FLAC'; if (lower.endsWith('.mp3')) return 'MP3'; if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus'; + if (lower.endsWith('.cue')) return 'CUE'; return 'Unknown'; } @@ -2766,6 +2853,470 @@ class _TrackMetadataScreenState extends ConsumerState { ); } + void _showCueSplitSheet(BuildContext context) async { + // Strip the #trackNN suffix from virtual CUE paths to get the real .cue path + var cuePath = cleanFilePath; + final trackSuffix = RegExp(r'#track\d+$'); + if (trackSuffix.hasMatch(cuePath)) { + cuePath = cuePath.replaceFirst(trackSuffix, ''); + } + + // Show loading indicator + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Loading CUE sheet...')), + ); + + try { + final cueInfo = await PlatformBridge.parseCueSheet(cuePath); + + if (!mounted) return; + _hideCurrentSnackBar(); + + if (cueInfo.containsKey('error')) { + _showSnackBarMessage(_l10nCueSplitNoAudioFile); + return; + } + + final album = cueInfo['album'] as String? ?? 'Unknown Album'; + final artist = cueInfo['artist'] as String? ?? 'Unknown Artist'; + final audioPath = cueInfo['audio_path'] as String? ?? ''; + final genre = cueInfo['genre'] as String? ?? ''; + final date = cueInfo['date'] as String? ?? ''; + final tracksRaw = cueInfo['tracks'] as List? ?? []; + + if (audioPath.isEmpty) { + _showSnackBarMessage(_l10nCueSplitNoAudioFile); + return; + } + + final tracks = tracksRaw + .map((t) => CueSplitTrackInfo.fromJson(t as Map)) + .toList(); + + if (tracks.isEmpty) { + _showSnackBarMessage(_l10nCueSplitFailed); + return; + } + + if (!mounted) return; + + showModalBottomSheet( + context: this.context, + useRootNavigator: true, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + final colorScheme = Theme.of(sheetContext).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + sheetContext.l10n.cueSplitTitle, + style: Theme.of(sheetContext).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + sheetContext.l10n.cueSplitAlbum(album), + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + sheetContext.l10n.cueSplitArtist(artist), + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + sheetContext.l10n.cueSplitTrackCount(tracks.length), + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + // Track list preview (scrollable, max 200px) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + shrinkWrap: true, + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + final duration = track.endSec > 0 + ? track.endSec - track.startSec + : 0.0; + final durationStr = duration > 0 + ? '${(duration ~/ 60).toString().padLeft(2, '0')}:${(duration.toInt() % 60).toString().padLeft(2, '0')}' + : ''; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + radius: 14, + backgroundColor: colorScheme.primaryContainer, + child: Text( + '${track.number}', + style: TextStyle( + fontSize: 11, + color: colorScheme.onPrimaryContainer, + ), + ), + ), + title: Text( + track.title, + style: const TextStyle(fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: track.artist.isNotEmpty + ? Text( + track.artist, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: durationStr.isNotEmpty + ? Text( + durationStr, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ) + : null, + ); + }, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + Navigator.pop(sheetContext); + _confirmAndSplitCue( + context: this.context, + audioPath: audioPath, + album: album, + artist: artist, + genre: genre, + date: date, + tracks: tracks, + ); + }, + icon: const Icon(Icons.call_split), + label: Text(sheetContext.l10n.cueSplitButton), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ); + } catch (e) { + if (!mounted) return; + _hideCurrentSnackBar(); + _showSnackBarMessage(_l10nCueSplitFailed); + _log.e('Failed to parse CUE sheet: $e'); + } + } + + void _confirmAndSplitCue({ + required BuildContext context, + required String audioPath, + required String album, + required String artist, + required String genre, + required String date, + required List tracks, + }) { + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.cueSplitConfirmTitle), + content: Text( + dialogContext.l10n.cueSplitConfirmMessage(album, tracks.length), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _performCueSplit( + audioPath: audioPath, + album: album, + artist: artist, + genre: genre, + date: date, + tracks: tracks, + ); + }, + child: Text(dialogContext.l10n.cueSplitButton), + ), + ], + ); + }, + ); + } + + Future _resolvePersistentCueSplitOutputDir() async { + final settings = ref.read(settingsProvider); + final queueState = ref.read(downloadQueueProvider); + final configuredOutputDir = queueState.outputDir.trim(); + if (settings.storageMode != 'saf' && + configuredOutputDir.isNotEmpty && + !isContentUri(configuredOutputDir)) { + final dir = Directory(configuredOutputDir); + await dir.create(recursive: true); + return dir; + } + + if (Platform.isAndroid) { + final externalDir = await getExternalStorageDirectory(); + if (externalDir != null) { + final musicDir = Directory( + '${externalDir.parent.parent.parent.parent.path}' + '${Platform.pathSeparator}Music' + '${Platform.pathSeparator}SpotiFLAC', + ); + await musicDir.create(recursive: true); + return musicDir; + } + } + + final docsDir = await getApplicationDocumentsDirectory(); + final fallbackDir = Directory( + '${docsDir.path}${Platform.pathSeparator}SpotiFLAC', + ); + await fallbackDir.create(recursive: true); + return fallbackDir; + } + + Future?> _exportCueSplitOutputsToSaf({ + required List outputPaths, + required String treeUri, + required String relativeDir, + }) async { + final exportedUris = []; + for (final path in outputPaths) { + final fileName = path.split(Platform.pathSeparator).last; + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: fileName, + mimeType: audioMimeTypeForPath(path), + srcPath: path, + ); + if (safUri != null && safUri.isNotEmpty) { + exportedUris.add(safUri); + } + } + return exportedUris.isEmpty ? null : exportedUris; + } + + Future _performCueSplit({ + required String audioPath, + required String album, + required String artist, + required String genre, + required String date, + required List tracks, + }) async { + if (_isConverting) return; + setState(() => _isConverting = true); + + String? safTempAudioPath; + Directory? tempSplitDir; + try { + // For SAF content:// audio paths, copy to temp for FFmpeg processing + String workingAudioPath = audioPath; + final isSafSource = isContentUri(audioPath); + if (isSafSource) { + final tempPath = await PlatformBridge.copyContentUriToTemp(audioPath); + if (tempPath == null || tempPath.isEmpty) { + throw Exception('Failed to copy SAF audio file to temp'); + } + safTempAudioPath = tempPath; + workingAudioPath = tempPath; + } + + // Determine output directory + final String outputDir; + final treeUri = !_isLocalItem ? (_downloadItem?.downloadTreeUri ?? '') : ''; + final relativeDir = !_isLocalItem ? (_downloadItem?.safRelativeDir ?? '') : ''; + final writeBackToSaf = isSafSource && treeUri.isNotEmpty; + if (writeBackToSaf) { + final tempDir = await getTemporaryDirectory(); + tempSplitDir = Directory( + '${tempDir.path}${Platform.pathSeparator}' + 'cue_split_${DateTime.now().millisecondsSinceEpoch}', + ); + await tempSplitDir.create(recursive: true); + outputDir = tempSplitDir.path; + } else if (isSafSource) { + final persistentDir = await _resolvePersistentCueSplitOutputDir(); + outputDir = persistentDir.path; + } else { + outputDir = File(audioPath).parent.path; + } + + if (!mounted) return; + _showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length)); + + // Extract cover from audio file for embedding + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cue_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + workingAudioPath, + coverOutput, + ); + if (coverResult['error'] == null) { + coverPath = coverOutput; + } + } catch (_) {} + + final albumMetadata = { + 'artist': artist, + 'album': album, + 'genre': genre, + 'date': date, + }; + + final outputPaths = await FFmpegService.splitCueToTracks( + audioPath: workingAudioPath, + outputDir: outputDir, + tracks: tracks, + albumMetadata: albumMetadata, + coverPath: coverPath, + onProgress: (current, total) { + if (mounted) { + _hideCurrentSnackBar(); + _showLongSnackBarMessage(_l10nCueSplitSplitting(current, total)); + } + }, + ); + + var finalOutputPaths = outputPaths; + + // Embed cover art into split FLAC files using Go backend + if (coverPath != null && finalOutputPaths != null) { + for (final path in finalOutputPaths) { + if (path.toLowerCase().endsWith('.flac')) { + try { + // Read existing metadata first + final metadata = await PlatformBridge.readFileMetadata(path); + if (metadata['error'] == null) { + final fields = { + 'cover_path': coverPath, + }; + // Preserve existing fields + for (final entry in metadata.entries) { + if (entry.key == 'error' || entry.value == null) continue; + final v = entry.value.toString().trim(); + if (v.isNotEmpty) { + fields[entry.key] = v; + } + } + await PlatformBridge.editFileMetadata(path, fields); + } + } catch (e) { + _log.w('Failed to embed cover to split track: $e'); + } + } + } + } + + if (writeBackToSaf && finalOutputPaths != null) { + final exportedUris = await _exportCueSplitOutputsToSaf( + outputPaths: finalOutputPaths, + treeUri: treeUri, + relativeDir: relativeDir, + ); + finalOutputPaths = exportedUris; + } + + // Cleanup cover temp + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + + if (mounted) { + _hideCurrentSnackBar(); + if (finalOutputPaths != null && finalOutputPaths.isNotEmpty) { + _showSnackBarMessage(_l10nCueSplitSuccess(finalOutputPaths.length)); + } else { + _showSnackBarMessage(_l10nCueSplitFailed); + } + } + } catch (e) { + _log.e('CUE split failed: $e'); + if (mounted) { + _hideCurrentSnackBar(); + _showSnackBarMessage(_l10nCueSplitFailed); + } + } finally { + // Cleanup SAF temp audio copy + if (safTempAudioPath != null) { + try { + await File(safTempAudioPath).delete(); + } catch (_) {} + } + if (tempSplitDir != null) { + try { + await tempSplitDir.delete(recursive: true); + } catch (_) {} + } + if (mounted) { + setState(() => _isConverting = false); + } + } + } + void _confirmAndConvert({ required BuildContext context, required String sourceFormat, @@ -3117,14 +3668,17 @@ class _TrackMetadataScreenState extends ConsumerState { TextButton( onPressed: () async { if (_isLocalItem) { - // For local items, just delete the file - try { - await deleteFile(cleanFilePath); - } catch (e) { - debugPrint('Failed to delete file: $e'); + if (_isCueVirtualTrack && _localLibraryItem != null) { + await ref + .read(localLibraryProvider.notifier) + .removeItem(_localLibraryItem!.id); + } else { + try { + await deleteFile(cleanFilePath); + } catch (e) { + debugPrint('Failed to delete file: $e'); + } } - // Also remove from local library database - // ref.read(localLibraryProvider.notifier).removeItem(_localLibraryItem!.id); } else { try { await deleteFile(cleanFilePath); @@ -3153,6 +3707,10 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _openFile(BuildContext context, String filePath) async { + if (isCueVirtualPath(filePath)) { + _showCueVirtualTrackSnackBar(context); + return; + } try { await ref .read(playbackProvider.notifier) @@ -3185,6 +3743,11 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _shareFile(BuildContext context) async { + if (_isCueVirtualTrack) { + _showCueVirtualTrackSnackBar(context); + return; + } + String sharePath = cleanFilePath; if (!await fileExists(sharePath)) { if (context.mounted) { diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 2b28ca9d..8154f03c 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1353,6 +1353,160 @@ class FFmpegService { return id3Map; } + + /// Split a CUE+audio file into individual track files using FFmpeg. + /// Each track is extracted with `-c copy` (no re-encoding) and metadata is embedded. + /// [audioPath] is the source audio file (FLAC, WAV, etc.) + /// [outputDir] is where individual track files will be saved + /// [tracks] is the list of track split info from the Go CUE parser + /// [albumMetadata] contains album-level metadata (artist, album, genre, date) + /// Returns list of output file paths on success, null on failure. + static Future?> splitCueToTracks({ + required String audioPath, + required String outputDir, + required List tracks, + required Map albumMetadata, + String? coverPath, + void Function(int current, int total)? onProgress, + }) async { + if (tracks.isEmpty) { + _log.e('No tracks to split'); + return null; + } + + final outputPaths = []; + final inputExt = audioPath.toLowerCase().split('.').last; + // For lossless formats, keep as FLAC; for others, keep original format + final outputExt = (inputExt == 'flac' || inputExt == 'wav' || inputExt == 'ape' || inputExt == 'wv') + ? 'flac' + : inputExt; + + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + onProgress?.call(i + 1, tracks.length); + + // Sanitize filename + final sanitizedTitle = track.title + .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + final trackNumStr = track.number.toString().padLeft(2, '0'); + final outputFileName = '$trackNumStr - $sanitizedTitle.$outputExt'; + final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName'; + + // Build FFmpeg command for this track + final StringBuffer cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$audioPath" '); + + // Time range + final startTime = _formatSecondsForFFmpeg(track.startSec); + cmdBuffer.write('-ss $startTime '); + + if (track.endSec > 0) { + final endTime = _formatSecondsForFFmpeg(track.endSec); + cmdBuffer.write('-to $endTime '); + } + + if (outputExt == 'flac') { + cmdBuffer.write('-c:a flac -compression_level 8 '); + } else { + cmdBuffer.write('-c:a copy '); + } + + // Metadata + final artist = track.artist.isNotEmpty ? track.artist : (albumMetadata['artist'] ?? ''); + final album = albumMetadata['album'] ?? ''; + final genre = albumMetadata['genre'] ?? ''; + final date = albumMetadata['date'] ?? ''; + + void addMeta(String key, String value) { + if (value.isNotEmpty) { + final sanitized = value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata $key="$sanitized" '); + } + } + + addMeta('TITLE', track.title); + addMeta('ARTIST', artist); + addMeta('ALBUM', album); + addMeta('ALBUMARTIST', albumMetadata['artist'] ?? ''); + addMeta('TRACKNUMBER', track.number.toString()); + addMeta('GENRE', genre); + addMeta('DATE', date); + if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc); + if (track.composer.isNotEmpty) addMeta('COMPOSER', track.composer); + + cmdBuffer.write('"$outputPath" -y'); + + final command = cmdBuffer.toString(); + _log.d('CUE split track ${track.number}: ${_previewCommandForLog(command)}'); + + final result = await _execute(command); + if (!result.success) { + _log.e('CUE split failed for track ${track.number}: ${result.output}'); + // Continue with remaining tracks instead of failing completely + continue; + } + + // Embed cover art if available (for FLAC output) + if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') { + // Use the Go backend for FLAC cover embedding via PlatformBridge + // (handled by the caller) + } + + outputPaths.add(outputPath); + _log.i('CUE split: track ${track.number} -> $outputFileName'); + } + + if (outputPaths.isEmpty) { + _log.e('CUE split: no tracks were successfully extracted'); + return null; + } + + _log.i('CUE split complete: ${outputPaths.length}/${tracks.length} tracks'); + return outputPaths; + } + + static String _formatSecondsForFFmpeg(double seconds) { + if (seconds < 0) return '0'; + final hours = seconds ~/ 3600; + final mins = (seconds % 3600) ~/ 60; + final secs = seconds - (hours * 3600) - (mins * 60); + return '${hours.toString().padLeft(2, '0')}:${mins.toInt().toString().padLeft(2, '0')}:${secs.toStringAsFixed(3).padLeft(6, '0')}'; + } +} + +/// Track info for CUE splitting, passed from the CUE parser +class CueSplitTrackInfo { + final int number; + final String title; + final String artist; + final String isrc; + final String composer; + final double startSec; + final double endSec; + + CueSplitTrackInfo({ + required this.number, + required this.title, + required this.artist, + this.isrc = '', + this.composer = '', + required this.startSec, + required this.endSec, + }); + + factory CueSplitTrackInfo.fromJson(Map json) { + return CueSplitTrackInfo( + number: json['number'] as int? ?? 0, + title: json['title'] as String? ?? '', + artist: json['artist'] as String? ?? '', + isrc: json['isrc'] as String? ?? '', + composer: json['composer'] as String? ?? '', + startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0, + endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0, + ); + } } class FFmpegResult { diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 86ae6d0d..bf20d621 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1298,4 +1298,20 @@ class PlatformBridge { await _channel.invokeMethod('clearStoreCache'); } + /// Parse a .cue file and return split information (track listing, timing, metadata). + /// Returns a map with: cue_path, audio_path, album, artist, genre, date, tracks[] + /// Each track has: number, title, artist, isrc, composer, start_sec, end_sec + /// [audioDir] optionally overrides the directory for audio file resolution (used for SAF). + static Future> parseCueSheet( + String cuePath, { + String audioDir = '', + }) async { + _log.i('parseCueSheet: $cuePath (audioDir: $audioDir)'); + final result = await _channel.invokeMethod('parseCueSheet', { + 'cue_path': cuePath, + 'audio_dir': audioDir, + }); + return jsonDecode(result as String) as Map; + } + } diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index 1dd4f1fc..026fa57e 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -212,16 +212,39 @@ bool isContentUri(String? path) { return path != null && path.startsWith('content://'); } +/// Pattern matching CUE virtual path suffixes like #track01, #track12, etc. +final _cueTrackSuffix = RegExp(r'#track\d+$'); + +const cueVirtualTrackRequiresSplitMessage = + 'This CUE track is virtual. Use Split into Tracks first.'; + +/// Whether the path is a CUE virtual path (contains #trackNN suffix). +bool isCueVirtualPath(String? path) { + return path != null && _cueTrackSuffix.hasMatch(path); +} + +/// Strip the #trackNN suffix from a CUE virtual path to get the base .cue path. +/// Returns the path unchanged if it's not a CUE virtual path. +String stripCueTrackSuffix(String path) { + return path.replaceFirst(_cueTrackSuffix, ''); +} + Future fileExists(String? path) async { if (path == null || path.isEmpty) return false; - if (isContentUri(path)) { - return PlatformBridge.safExists(path); + // For CUE virtual paths, check if the base .cue file exists + final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path; + if (isContentUri(realPath)) { + return PlatformBridge.safExists(realPath); } - return File(path).exists(); + return File(realPath).exists(); } Future deleteFile(String? path) async { if (path == null || path.isEmpty) return; + // CUE virtual paths should NOT be deleted through this function — + // deleting album.cue would remove ALL tracks. Callers should handle + // CUE deletion specially (e.g. only delete when all tracks are removed). + if (isCueVirtualPath(path)) return; if (isContentUri(path)) { await PlatformBridge.safDelete(path); return; @@ -233,8 +256,10 @@ Future deleteFile(String? path) async { Future fileStat(String? path) async { if (path == null || path.isEmpty) return null; - if (isContentUri(path)) { - final stat = await PlatformBridge.safStat(path); + // For CUE virtual paths, stat the base .cue file + final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path; + if (isContentUri(realPath)) { + final stat = await PlatformBridge.safStat(realPath); final exists = stat['exists'] as bool? ?? true; if (!exists) return null; return FileAccessStat( @@ -245,18 +270,23 @@ Future fileStat(String? path) async { ); } - final stat = await FileStat.stat(path); + final stat = await FileStat.stat(realPath); if (stat.type == FileSystemEntityType.notFound) return null; return FileAccessStat(size: stat.size, modified: stat.modified); } Future openFile(String path) async { - if (isContentUri(path)) { - await PlatformBridge.openContentUri(path, mimeType: ''); + if (isCueVirtualPath(path)) { + throw Exception(cueVirtualTrackRequiresSplitMessage); + } + + final realPath = path; + if (isContentUri(realPath)) { + await PlatformBridge.openContentUri(realPath, mimeType: ''); return; } - final mimeType = audioMimeTypeForPath(path); - final result = await OpenFilex.open(path, type: mimeType); + final mimeType = audioMimeTypeForPath(realPath); + final result = await OpenFilex.open(realPath, type: mimeType); if (result.type != ResultType.done) { throw Exception(result.message); }