Skip to content

Commit

Permalink
music: more m3u support
Browse files Browse the repository at this point in the history
- Turns out path extraction via MediaStore doesn't work, have to grok
the URI format.
- Added playlist name extraction
- Proactively handling whitespace
  • Loading branch information
OxygenCobalt committed Dec 20, 2023
1 parent 634ff0d commit c66a9b1
Show file tree
Hide file tree
Showing 9 changed files with 102 additions and 46 deletions.
12 changes: 5 additions & 7 deletions app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.dirs.DocumentTreePathFactory
import org.oxycblt.auxio.music.dirs.MusicDirectories
import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
Expand Down Expand Up @@ -57,18 +57,16 @@ interface MusicSettings : Settings<MusicSettings.Listener> {

class MusicSettingsImpl
@Inject
constructor(
@ApplicationContext context: Context,
val documentTreePathFactory: DocumentTreePathFactory
) : Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
constructor(@ApplicationContext context: Context, val documentPathFactory: DocumentPathFactory) :
Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
private val storageManager = context.getSystemServiceCompat(StorageManager::class)

override var musicDirs: MusicDirectories
get() {
val dirs =
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
?: emptySet())
.mapNotNull(documentTreePathFactory::deserializeDocumentTreePath)
.mapNotNull(documentPathFactory::fromDocumentId)
return MusicDirectories(
dirs,
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
Expand All @@ -77,7 +75,7 @@ constructor(
sharedPreferences.edit {
putStringSet(
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)
apply()
}
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ constructor(
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath)

if (songs.isEmpty()) {
_importError.put(Unit)
return@launch
}

createPlaylist(importedPlaylist.name, songs)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,8 @@

package org.oxycblt.auxio.music.dirs

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
interface DirectoryModule {
@Binds
fun documentTreePathFactory(factory: DocumentTreePathFactoryImpl): DocumentTreePathFactory
}
@Module @InstallIn(SingletonComponent::class) interface DirectoryModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.logD
Expand All @@ -48,7 +49,7 @@ class MusicDirsDialog :
ViewBindingMaterialDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
private val dirAdapter = DirectoryAdapter(this)
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
@Inject lateinit var documentTreePathFactory: DocumentTreePathFactory
@Inject lateinit var documentPathFactory: DocumentPathFactory
@Inject lateinit var musicSettings: MusicSettings

override fun onCreateBinding(inflater: LayoutInflater) =
Expand Down Expand Up @@ -101,8 +102,7 @@ class MusicDirsDialog :
if (pendingDirs != null) {
dirs =
MusicDirectories(
pendingDirs.mapNotNull(
documentTreePathFactory::deserializeDocumentTreePath),
pendingDirs.mapNotNull(documentPathFactory::fromDocumentId),
savedInstanceState.getBoolean(KEY_PENDING_MODE))
}
}
Expand All @@ -126,8 +126,7 @@ class MusicDirsDialog :
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putStringArrayList(
KEY_PENDING_DIRS,
ArrayList(dirAdapter.dirs.map(documentTreePathFactory::serializeDocumentTreePath)))
KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map(documentPathFactory::toDocumentId)))
outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding()))
}

Expand Down Expand Up @@ -155,7 +154,7 @@ class MusicDirsDialog :
return
}

val dir = documentTreePathFactory.unpackDocumentTreeUri(uri)
val dir = documentPathFactory.unpackDocumentTreeUri(uri)

if (dir != null) {
dirAdapter.add(dir)
Expand Down
62 changes: 54 additions & 8 deletions app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,39 +24,81 @@ import java.io.InputStreamReader
import javax.inject.Inject
import org.oxycblt.auxio.music.fs.Components
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.util.logW

/**
* Minimal M3U file format implementation.
*
* @author Alexander Capehart (OxygenCobalt)
*/
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 {
override fun read(stream: InputStream, workingDirectory: Path): List<Path>? {
override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
val reader = BufferedReader(InputStreamReader(stream))
val media = mutableListOf<Path>()
val paths = mutableListOf<Path>()
var name: String? = null

consumeFile@ while (true) {
var path: String?
collectMetadata@ while (true) {
val line = reader.readLine() ?: break@consumeFile
if (!line.startsWith("#")) {
// The M3U format consists of "entries" that begin with a bunch of metadata
// 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
}
}

val path = reader.readLine()
if (path == null) {
logW("Expected a path, instead got an EOF")
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 absoluteComponents =
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(
Expand All @@ -66,8 +108,12 @@ class M3UImpl @Inject constructor() : M3U {
var components = workingDirectory
for (component in relative.components) {
when (component) {
// Parent specifier, go "back" one directory (in practice cleave off the last
// component)
".." -> components = components.parent()
// Current directory, the components are already there.
"." -> {}
// New directory, add it
else -> components = components.child(component)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,42 @@ import android.content.Context
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
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.contentResolverSafe

/**
* Generic playlist file importing abstraction.
*
* @see ImportedPlaylist
* @see M3U
* @author Alexander Capehart (OxygenCobalt)
*/
interface PlaylistImporter {
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>)

class PlaylistImporterImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
private val contentPathResolver: ContentPathResolver,
private val documentPathFactory: DocumentPathFactory,
private val m3u: M3U
) : PlaylistImporter {
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 {
val paths = m3u.read(it, workingDirectory) ?: return null
return ImportedPlaylist(null, paths)
return m3u.read(it, filePath.directory)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,13 @@ import android.net.Uri
import android.provider.DocumentsContract
import java.io.File
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
* (i.e directory) folder.
* A factory for parsing the reverse-engineered format of the URIs obtained from document picker.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface DocumentTreePathFactory {
interface DocumentPathFactory {
/**
* Unpacks a document URI into a [Path] instance, using [fromDocumentId].
*
Expand Down Expand Up @@ -67,8 +62,8 @@ interface DocumentTreePathFactory {
fun fromDocumentId(path: String): Path?
}

class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) :
DocumentTreePathFactory {
class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) :
DocumentPathFactory {
override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri))

override fun unpackDocumentTreeUri(uri: Uri): Path? {
Expand Down
12 changes: 8 additions & 4 deletions app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.fs
import android.content.ContentResolver
import android.content.Context
import android.os.storage.StorageManager
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
Expand All @@ -39,11 +40,14 @@ class FsModule {
fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) =
MediaStoreExtractor.from(context, volumeManager)

@Provides
fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) =
ContentPathResolver.from(context, volumeManager)

@Provides
fun contentResolver(@ApplicationContext context: Context): ContentResolver =
context.contentResolverSafe
}

@Module
@InstallIn(SingletonComponent::class)
interface FsBindsModule {
@Binds
fun documentPathFactory(documentTreePathFactory: DocumentPathFactoryImpl): DocumentPathFactory
}
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@
<string name="err_no_music">No music found</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_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>
<!-- No folders in the "Music Folders" setting -->
<string name="err_no_dirs">No folders</string>
Expand Down

0 comments on commit c66a9b1

Please sign in to comment.