music: more m3u support

- Turns out path extraction via MediaStore doesn't work, have to grok
the URI format.
- Added playlist name extraction
- Proactively handling whitespace
This commit is contained in:
Alexander Capehart 2023-12-20 13:28:36 -07:00
parent 634ff0d823
commit c66a9b19b5
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 102 additions and 46 deletions

View file

@ -24,8 +24,8 @@ import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.dirs.DocumentTreePathFactory
import org.oxycblt.auxio.music.dirs.MusicDirectories import org.oxycblt.auxio.music.dirs.MusicDirectories
import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -57,10 +57,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
class MusicSettingsImpl class MusicSettingsImpl
@Inject @Inject
constructor( constructor(@ApplicationContext context: Context, val documentPathFactory: DocumentPathFactory) :
@ApplicationContext context: Context, Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
val documentTreePathFactory: DocumentTreePathFactory
) : Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
private val storageManager = context.getSystemServiceCompat(StorageManager::class) private val storageManager = context.getSystemServiceCompat(StorageManager::class)
override var musicDirs: MusicDirectories override var musicDirs: MusicDirectories
@ -68,7 +66,7 @@ constructor(
val dirs = val dirs =
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null) (sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
?: emptySet()) ?: emptySet())
.mapNotNull(documentTreePathFactory::deserializeDocumentTreePath) .mapNotNull(documentPathFactory::fromDocumentId)
return MusicDirectories( return MusicDirectories(
dirs, dirs,
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false)) sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
@ -77,7 +75,7 @@ constructor(
sharedPreferences.edit { sharedPreferences.edit {
putStringSet( putStringSet(
getString(R.string.set_key_music_dirs), getString(R.string.set_key_music_dirs),
value.dirs.map(documentTreePathFactory::serializeDocumentTreePath).toSet()) value.dirs.map(documentPathFactory::toDocumentId).toSet())
putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude) putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude)
apply() apply()
} }

View file

@ -141,6 +141,11 @@ constructor(
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath)
if (songs.isEmpty()) {
_importError.put(Unit)
return@launch
}
createPlaylist(importedPlaylist.name, songs) createPlaylist(importedPlaylist.name, songs)
} }

View file

@ -18,14 +18,8 @@
package org.oxycblt.auxio.music.dirs package org.oxycblt.auxio.music.dirs
import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
@Module @Module @InstallIn(SingletonComponent::class) interface DirectoryModule {}
@InstallIn(SingletonComponent::class)
interface DirectoryModule {
@Binds
fun documentTreePathFactory(factory: DocumentTreePathFactoryImpl): DocumentTreePathFactory
}

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -48,7 +49,7 @@ class MusicDirsDialog :
ViewBindingMaterialDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener { ViewBindingMaterialDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
private val dirAdapter = DirectoryAdapter(this) private val dirAdapter = DirectoryAdapter(this)
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
@Inject lateinit var documentTreePathFactory: DocumentTreePathFactory @Inject lateinit var documentPathFactory: DocumentPathFactory
@Inject lateinit var musicSettings: MusicSettings @Inject lateinit var musicSettings: MusicSettings
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
@ -101,8 +102,7 @@ class MusicDirsDialog :
if (pendingDirs != null) { if (pendingDirs != null) {
dirs = dirs =
MusicDirectories( MusicDirectories(
pendingDirs.mapNotNull( pendingDirs.mapNotNull(documentPathFactory::fromDocumentId),
documentTreePathFactory::deserializeDocumentTreePath),
savedInstanceState.getBoolean(KEY_PENDING_MODE)) savedInstanceState.getBoolean(KEY_PENDING_MODE))
} }
} }
@ -126,8 +126,7 @@ class MusicDirsDialog :
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putStringArrayList( outState.putStringArrayList(
KEY_PENDING_DIRS, KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map(documentPathFactory::toDocumentId)))
ArrayList(dirAdapter.dirs.map(documentTreePathFactory::serializeDocumentTreePath)))
outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding())) outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding()))
} }
@ -155,7 +154,7 @@ class MusicDirsDialog :
return return
} }
val dir = documentTreePathFactory.unpackDocumentTreeUri(uri) val dir = documentPathFactory.unpackDocumentTreeUri(uri)
if (dir != null) { if (dir != null) {
dirAdapter.add(dir) dirAdapter.add(dir)

View file

@ -24,39 +24,81 @@ import java.io.InputStreamReader
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.fs.Components import org.oxycblt.auxio.music.fs.Components
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
/**
* Minimal M3U file format implementation.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface M3U { interface M3U {
fun read(stream: InputStream, workingDirectory: Path): List<Path>? /**
* Reads an M3U file from the given [stream] and returns a [ImportedPlaylist] containing the
* paths to the files listed in the M3U file.
*
* @param stream The stream to read the M3U file from.
* @param workingDirectory The directory that the M3U file is contained in. This is used to
* resolve relative paths.
* @return An [ImportedPlaylist] containing the paths to the files listed in the M3U file,
*/
fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist?
} }
class M3UImpl @Inject constructor() : M3U { class M3UImpl @Inject constructor() : M3U {
override fun read(stream: InputStream, workingDirectory: Path): List<Path>? { override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
val reader = BufferedReader(InputStreamReader(stream)) val reader = BufferedReader(InputStreamReader(stream))
val media = mutableListOf<Path>() val paths = mutableListOf<Path>()
var name: String? = null
consumeFile@ while (true) { consumeFile@ while (true) {
var path: String?
collectMetadata@ while (true) { collectMetadata@ while (true) {
val line = reader.readLine() ?: break@consumeFile // The M3U format consists of "entries" that begin with a bunch of metadata
if (!line.startsWith("#")) { // prefixed with "#", and then a relative/absolute path or url to the file.
// We don't really care about the metadata except for the playlist name, so
// we discard everything but that.
val currentLine =
(reader.readLine() ?: break@consumeFile).correctWhitespace()
?: continue@collectMetadata
if (currentLine.startsWith("#")) {
// Metadata entries are roughly structured
val split = currentLine.split(":", limit = 2)
when (split[0]) {
// Playlist name
"#PLAYLIST" -> name = split.getOrNull(1)?.correctWhitespace()
// Add more metadata handling here if needed.
else -> {}
}
} else {
// Something that isn't a metadata entry, assume it's a path. It could be
// a URL, but it'll just get mangled really badly and not match with anything,
// so it's okay.
path = currentLine
break@collectMetadata break@collectMetadata
} }
} }
val path = reader.readLine()
if (path == null) { if (path == null) {
logW("Expected a path, instead got an EOF") logW("Expected a path, instead got an EOF")
break@consumeFile break@consumeFile
} }
// The path may be relative to the directory that the M3U file is contained in,
// so we may need to resolve it into an absolute path before moving ahead.
val relativeComponents = Components.parse(path) val relativeComponents = Components.parse(path)
val absoluteComponents = val absoluteComponents =
resolveRelativePath(relativeComponents, workingDirectory.components) resolveRelativePath(relativeComponents, workingDirectory.components)
media.add(Path(workingDirectory.volume, absoluteComponents)) paths.add(Path(workingDirectory.volume, absoluteComponents))
} }
return media.ifEmpty { null } return if (paths.isNotEmpty()) {
ImportedPlaylist(name, paths)
} else {
// Couldn't get anything useful out of this file.
null
}
} }
private fun resolveRelativePath( private fun resolveRelativePath(
@ -66,8 +108,12 @@ class M3UImpl @Inject constructor() : M3U {
var components = workingDirectory var components = workingDirectory
for (component in relative.components) { for (component in relative.components) {
when (component) { when (component) {
// Parent specifier, go "back" one directory (in practice cleave off the last
// component)
".." -> components = components.parent() ".." -> components = components.parent()
// Current directory, the components are already there.
"." -> {} "." -> {}
// New directory, add it
else -> components = components.child(component) else -> components = components.child(component)
} }
} }

View file

@ -22,28 +22,42 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.fs.ContentPathResolver import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.contentResolverSafe
/**
* Generic playlist file importing abstraction.
*
* @see ImportedPlaylist
* @see M3U
* @author Alexander Capehart (OxygenCobalt)
*/
interface PlaylistImporter { interface PlaylistImporter {
suspend fun import(uri: Uri): ImportedPlaylist? suspend fun import(uri: Uri): ImportedPlaylist?
} }
/**
* A playlist that has been imported.
*
* @property name The name of the playlist. May be null if not provided.
* @property paths The paths of the files in the playlist.
* @see PlaylistImporter
* @see M3U
*/
data class ImportedPlaylist(val name: String?, val paths: List<Path>) data class ImportedPlaylist(val name: String?, val paths: List<Path>)
class PlaylistImporterImpl class PlaylistImporterImpl
@Inject @Inject
constructor( constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val contentPathResolver: ContentPathResolver, private val documentPathFactory: DocumentPathFactory,
private val m3u: M3U private val m3u: M3U
) : PlaylistImporter { ) : PlaylistImporter {
override suspend fun import(uri: Uri): ImportedPlaylist? { override suspend fun import(uri: Uri): ImportedPlaylist? {
val workingDirectory = contentPathResolver.resolve(uri) ?: return null val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return null
return context.contentResolverSafe.openInputStream(uri)?.use { return context.contentResolverSafe.openInputStream(uri)?.use {
val paths = m3u.read(it, workingDirectory) ?: return null return m3u.read(it, filePath.directory)
return ImportedPlaylist(null, paths)
} }
} }
} }

View file

@ -22,18 +22,13 @@ import android.net.Uri
import android.provider.DocumentsContract import android.provider.DocumentsContract
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.fs.Components
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.Volume
import org.oxycblt.auxio.music.fs.VolumeManager
/** /**
* A factory for parsing the reverse-engineered format of the URIs obtained from the document tree * A factory for parsing the reverse-engineered format of the URIs obtained from document picker.
* (i.e directory) folder.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface DocumentTreePathFactory { interface DocumentPathFactory {
/** /**
* Unpacks a document URI into a [Path] instance, using [fromDocumentId]. * Unpacks a document URI into a [Path] instance, using [fromDocumentId].
* *
@ -67,8 +62,8 @@ interface DocumentTreePathFactory {
fun fromDocumentId(path: String): Path? fun fromDocumentId(path: String): Path?
} }
class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) : class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) :
DocumentTreePathFactory { DocumentPathFactory {
override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri)) override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri))
override fun unpackDocumentTreeUri(uri: Uri): Path? { override fun unpackDocumentTreeUri(uri: Uri): Path? {

View file

@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.fs
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.storage.StorageManager import android.os.storage.StorageManager
import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -39,11 +40,14 @@ class FsModule {
fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) = fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) =
MediaStoreExtractor.from(context, volumeManager) MediaStoreExtractor.from(context, volumeManager)
@Provides
fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) =
ContentPathResolver.from(context, volumeManager)
@Provides @Provides
fun contentResolver(@ApplicationContext context: Context): ContentResolver = fun contentResolver(@ApplicationContext context: Context): ContentResolver =
context.contentResolverSafe context.contentResolverSafe
} }
@Module
@InstallIn(SingletonComponent::class)
interface FsBindsModule {
@Binds
fun documentPathFactory(documentTreePathFactory: DocumentPathFactoryImpl): DocumentPathFactory
}

View file

@ -310,6 +310,7 @@
<string name="err_no_music">No music found</string> <string name="err_no_music">No music found</string>
<string name="err_index_failed">Music loading failed</string> <string name="err_index_failed">Music loading failed</string>
<string name="err_no_perms">Auxio needs permission to read your music library</string> <string name="err_no_perms">Auxio needs permission to read your music library</string>
<string name="err_import_failed">Could not import a playlist from this file</string>
<string name="err_no_app">No app found that can handle this task</string> <string name="err_no_app">No app found that can handle this task</string>
<!-- No folders in the "Music Folders" setting --> <!-- No folders in the "Music Folders" setting -->
<string name="err_no_dirs">No folders</string> <string name="err_no_dirs">No folders</string>