feat: folder import creates a folder-named playlist (Library tab) + Imported/<folder>/ subdir
This commit is contained in:
parent
153a132656
commit
e6f88d5740
203 changed files with 205 additions and 73 deletions
|
|
@ -18,6 +18,9 @@ interface PlaylistDao {
|
|||
@Query("SELECT * FROM playlists WHERE id = :playlistId")
|
||||
suspend fun getPlaylistById(playlistId: Long): PlaylistEntity?
|
||||
|
||||
@Query("SELECT * FROM playlists WHERE name = :name LIMIT 1")
|
||||
suspend fun getPlaylistByName(name: String): PlaylistEntity?
|
||||
|
||||
@Insert
|
||||
suspend fun createPlaylist(playlist: PlaylistEntity): Long
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ class PlaylistRepository @Inject constructor(
|
|||
|
||||
suspend fun getPlaylistById(id: Long): PlaylistEntity? = playlistDao.getPlaylistById(id)
|
||||
|
||||
suspend fun getPlaylistByName(name: String): PlaylistEntity? = playlistDao.getPlaylistByName(name)
|
||||
|
||||
suspend fun createPlaylist(name: String): Long {
|
||||
return playlistDao.createPlaylist(PlaylistEntity(name = name))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,26 +15,33 @@ import javax.inject.Singleton
|
|||
data class ImportResult(
|
||||
val imported: Int,
|
||||
val skipped: Int,
|
||||
val folderPlaylists: List<String> = emptyList(),
|
||||
val errors: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Manual song import: copies user-picked files or whole folders (via SAF)
|
||||
* into the library directory and registers them in Room.
|
||||
* Nothing is scanned automatically.
|
||||
*
|
||||
* A folder pick additionally creates a playlist named after the folder
|
||||
* (shown as a tab in the Library) and files are stored in a subdirectory
|
||||
* mirroring the folder name. Re-importing the same folder adds to the
|
||||
* existing playlist rather than duplicating it.
|
||||
*/
|
||||
@Singleton
|
||||
class SongImporter @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val playlistRepository: PlaylistRepository,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Entry point. Each uri may be a single document or a picked tree/folder;
|
||||
* trees are walked recursively for audio files.
|
||||
*/
|
||||
/** Entry point. Each uri may be a single document or a picked tree/folder. */
|
||||
suspend fun import(uris: List<Uri>): ImportResult {
|
||||
val filesToImport = mutableListOf<Uri>()
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
val folders = mutableListOf<String>()
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
for (uri in uris) {
|
||||
// Folder picks arrive as tree URIs (…/tree/primary:…); file picks
|
||||
// arrive as document URIs. fromSingleUri() misreads tree URIs as
|
||||
|
|
@ -47,16 +54,30 @@ class SongImporter @Inject constructor(
|
|||
null
|
||||
}
|
||||
when {
|
||||
doc == null && !isTreeUri -> filesToImport += uri
|
||||
doc == null && !isTreeUri -> {
|
||||
val r = importFiles(listOf(uri), subDir = null, playlistName = null)
|
||||
imported += r.imported; skipped += r.skipped; errors += r.errors
|
||||
}
|
||||
doc == null -> Log.w(TAG, "Cannot open picked tree: $uri")
|
||||
doc.isDirectory -> collectAudioFromTree(doc, filesToImport)
|
||||
else -> filesToImport += uri
|
||||
doc.isDirectory -> {
|
||||
val folderName = doc.name?.takeIf { it.isNotBlank() } ?: "Imported folder"
|
||||
val audio = mutableListOf<Uri>()
|
||||
collectAudioFromTree(doc, audio)
|
||||
if (audio.isEmpty()) {
|
||||
errors += "$folderName: no audio files found"
|
||||
continue
|
||||
}
|
||||
val r = importFiles(audio, subDir = folderName, playlistName = folderName)
|
||||
imported += r.imported; skipped += r.skipped; errors += r.errors
|
||||
if (r.imported > 0) folders += folderName
|
||||
}
|
||||
else -> {
|
||||
val r = importFiles(listOf(uri), subDir = null, playlistName = null)
|
||||
imported += r.imported; skipped += r.skipped; errors += r.errors
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filesToImport.isEmpty()) {
|
||||
return ImportResult(0, 0, listOf("No audio files found in the picked selection."))
|
||||
}
|
||||
return importFiles(filesToImport)
|
||||
return ImportResult(imported, skipped, folders, errors)
|
||||
}
|
||||
|
||||
private fun collectAudioFromTree(dir: DocumentFile, out: MutableList<Uri>) {
|
||||
|
|
@ -82,13 +103,30 @@ class SongImporter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun importFiles(uris: List<Uri>): ImportResult {
|
||||
/**
|
||||
* Copies [uris] into the library. When [subDir] is set (folder import),
|
||||
* files live under RadAmp/Imported/<subDir>/ and every successful song
|
||||
* is added to the (created-if-missing) playlist named [playlistName].
|
||||
*/
|
||||
private suspend fun importFiles(
|
||||
uris: List<Uri>,
|
||||
subDir: String?,
|
||||
playlistName: String?,
|
||||
): ImportResult {
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
val errors = mutableListOf<String>()
|
||||
val dir = getImportDirectory()
|
||||
val baseDir = getImportDirectory()
|
||||
val dir = if (subDir != null) File(baseDir, subDir.replace('/', '_')) else baseDir
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
return ImportResult(0, 0, listOf("Cannot create import directory: ${dir.absolutePath}"))
|
||||
return ImportResult(0, 0, errors = listOf("Cannot create import directory: ${dir.absolutePath}"))
|
||||
}
|
||||
|
||||
// Resolve the target playlist once per folder import.
|
||||
var playlistId: Long? = null
|
||||
if (playlistName != null) {
|
||||
playlistId = playlistRepository.getPlaylistByName(playlistName)?.id
|
||||
?: playlistRepository.createPlaylist(playlistName)
|
||||
}
|
||||
|
||||
for (uri in uris) {
|
||||
|
|
@ -112,7 +150,7 @@ class SongImporter @Inject constructor(
|
|||
input.use { src -> target.outputStream().use { out -> src.copyTo(out) } }
|
||||
|
||||
val (artist, title, album, durationMs) = readTags(target, base)
|
||||
val id = libraryRepository.insert(
|
||||
val songId = libraryRepository.insert(
|
||||
SavedSongEntity(
|
||||
fileName = target.name,
|
||||
filePath = target.absolutePath,
|
||||
|
|
@ -125,13 +163,22 @@ class SongImporter @Inject constructor(
|
|||
format = format,
|
||||
)
|
||||
)
|
||||
if (id > 0) imported++ else skipped++
|
||||
if (songId > 0) {
|
||||
imported++
|
||||
if (playlistId != null) {
|
||||
if (!playlistRepository.isSongInPlaylist(playlistId, songId)) {
|
||||
playlistRepository.addSongToPlaylist(playlistId, songId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Import failed for $displayName", e)
|
||||
errors += "$displayName: ${e.message ?: e.javaClass.simpleName}"
|
||||
}
|
||||
}
|
||||
return ImportResult(imported, skipped, errors)
|
||||
return ImportResult(imported, skipped, errors = errors)
|
||||
}
|
||||
|
||||
private fun readTags(file: File, fallbackBase: String): TagInfo {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ class LibraryViewModel @Inject constructor(
|
|||
_importMessage.value = buildString {
|
||||
append("Imported ${result.imported} song")
|
||||
if (result.imported != 1) append("s")
|
||||
if (result.folderPlaylists.isNotEmpty()) {
|
||||
append(" → folder")
|
||||
if (result.folderPlaylists.size > 1) append("s")
|
||||
append(" ${result.folderPlaylists.joinToString()}")
|
||||
}
|
||||
if (result.errors.isNotEmpty()) {
|
||||
append(" (${result.errors.size} failed: ${result.errors.first()}")
|
||||
if (result.errors.size > 1) append(", +${result.errors.size - 1} more")
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ class PlayerViewModel @Inject constructor(
|
|||
_importMessage.value = buildString {
|
||||
append("Imported ${result.imported} song")
|
||||
if (result.imported != 1) append("s")
|
||||
if (result.folderPlaylists.isNotEmpty()) {
|
||||
append(" → folder")
|
||||
if (result.folderPlaylists.size > 1) append("s")
|
||||
append(" ${result.folderPlaylists.joinToString()}")
|
||||
}
|
||||
if (result.errors.isNotEmpty()) {
|
||||
append(" (${result.errors.size} failed: ${result.errors.first()}")
|
||||
if (result.errors.size > 1) append(", +${result.errors.size - 1} more")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue