feat: folder import creates a folder-named playlist (Library tab) + Imported/<folder>/ subdir

This commit is contained in:
dev 2026-09-07 11:44:12 -05:00
commit e6f88d5740
203 changed files with 205 additions and 73 deletions

View file

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

View file

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

View file

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

View file

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

View file

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