mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-17 09:37:20 +02:00
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
This commit is contained in:
@@ -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<Pair<DocumentFile, String>>()
|
||||
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
|
||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
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<String>()
|
||||
|
||||
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<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
|
||||
// CUE files to scan: (cueDoc, parentDir, lastModified)
|
||||
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
||||
// Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set
|
||||
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val currentUris = mutableSetOf<String>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
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<String, MutableList<String>>() // 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<Pair<DocumentFile, String>> = 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<String>()
|
||||
|
||||
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<String>("cue_path") ?: ""
|
||||
val audioDir = call.argument<String>("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) {
|
||||
|
||||
Reference in New Issue
Block a user