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:
zarzet 2026-03-11 00:31:20 +07:00
parent 64408c8d8b
commit 76fe8dbc69
26 changed files with 2844 additions and 40 deletions

View file

@ -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) {

577
go_backend/cue_parser.go Normal file
View file

@ -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
}

View file

@ -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.

View file

@ -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++

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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 => 'Создать';

View file

@ -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';

View file

@ -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';

View file

@ -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"

View file

@ -24,6 +24,9 @@ class PlaybackController extends Notifier<PlaybackState> {
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<PlaybackState> {
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<PlaybackState> {
return;
}
if (skippedCueVirtualTrack) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
throw Exception(
'No local audio file is available to open. Download the track first.',
);

View file

@ -38,6 +38,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final ScrollController _scrollController = ScrollController();
late List<LocalLibraryItem> _sortedTracksCache;
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
void _showCueVirtualTrackSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(cueVirtualTrackRequiresSplitMessage),
),
);
}
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
@ -178,9 +186,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
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<LocalAlbumScreen> {
}
Future<void> _openFile(LocalLibraryItem track) async {
if (isCueVirtualPath(track.filePath)) {
_showCueVirtualTrackSnackBar();
return;
}
try {
await ref
.read(playbackProvider.notifier)

View file

@ -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<TrackMetadataScreen> {
}
Future<void> _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<TrackMetadataScreen> {
);
}
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
_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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
);
}
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<dynamic>? ?? [];
if (audioPath.isEmpty) {
_showSnackBarMessage(_l10nCueSplitNoAudioFile);
return;
}
final tracks = tracksRaw
.map((t) => CueSplitTrackInfo.fromJson(t as Map<String, dynamic>))
.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<CueSplitTrackInfo> 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<Directory> _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<List<String>?> _exportCueSplitOutputsToSaf({
required List<String> outputPaths,
required String treeUri,
required String relativeDir,
}) async {
final exportedUris = <String>[];
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<void> _performCueSplit({
required String audioPath,
required String album,
required String artist,
required String genre,
required String date,
required List<CueSplitTrackInfo> 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 = <String, String>{
'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 = <String, String>{
'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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
}
Future<void> _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<TrackMetadataScreen> {
}
Future<void> _shareFile(BuildContext context) async {
if (_isCueVirtualTrack) {
_showCueVirtualTrackSnackBar(context);
return;
}
String sharePath = cleanFilePath;
if (!await fileExists(sharePath)) {
if (context.mounted) {

View file

@ -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<List<String>?> splitCueToTracks({
required String audioPath,
required String outputDir,
required List<CueSplitTrackInfo> tracks,
required Map<String, String> albumMetadata,
String? coverPath,
void Function(int current, int total)? onProgress,
}) async {
if (tracks.isEmpty) {
_log.e('No tracks to split');
return null;
}
final outputPaths = <String>[];
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<String, dynamic> 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 {

View file

@ -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<Map<String, dynamic>> 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<String, dynamic>;
}
}

View file

@ -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<bool> 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<void> 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<void> deleteFile(String? path) async {
Future<FileAccessStat?> 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<FileAccessStat?> 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<void> 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);
}