From 68e4da5e7e65202592652ffb5ebc390d72dd6de3 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 23 Dec 2023 12:12:51 -0700 Subject: [PATCH] music: make playlist export configurable Add configuration options for: - Using windows-compatible paths with \ separators and C:\\ volume prefixes - Switching between relative and absolute paths --- .../music/decision/ExportPlaylistDialog.kt | 4 + .../music/external/ExternalPlaylistManager.kt | 42 +++++++- .../org/oxycblt/auxio/music/external/M3U.kt | 100 ++++++++++++++---- .../auxio/music/fs/DocumentPathFactory.kt | 2 +- .../java/org/oxycblt/auxio/music/fs/Fs.kt | 39 ++++++- .../auxio/music/fs/MediaStoreExtractor.kt | 4 +- .../res/layout/dialog_playlist_export.xml | 6 ++ 7 files changed, 163 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt create mode 100644 app/src/main/res/layout/dialog_playlist_export.xml diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt new file mode 100644 index 000000000..f01bf6da0 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt @@ -0,0 +1,4 @@ +package org.oxycblt.auxio.music.decision + +class ExportPlaylistDialog { +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt index abb3e672e..1a7eed2be 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt @@ -21,8 +21,9 @@ package org.oxycblt.auxio.music.external import android.content.Context import android.net.Uri import dagger.hilt.android.qualifiers.ApplicationContext -import org.oxycblt.auxio.music.Playlist import javax.inject.Inject +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.fs.Components import org.oxycblt.auxio.music.fs.DocumentPathFactory import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.contentResolverSafe @@ -36,10 +37,37 @@ import org.oxycblt.auxio.util.logE * @author Alexander Capehart (OxygenCobalt) */ interface ExternalPlaylistManager { + /** + * Import the playlist file at the given [uri]. + * + * @param uri The [Uri] of the playlist file to import. + * @return An [ImportedPlaylist] containing the paths to the files listed in the playlist file, + * or null if the playlist could not be imported. + */ suspend fun import(uri: Uri): ImportedPlaylist? - suspend fun export(playlist: Playlist, uri: Uri): Boolean + + /** + * Export the given [playlist] to the given [uri]. + * + * @param playlist The playlist to export. + * @param uri The [Uri] to export the playlist to. + * @param config The configuration to use when exporting the playlist. + * @return True if the playlist was successfully exported, false otherwise. + */ + suspend fun export(playlist: Playlist, uri: Uri, config: ExportConfig): Boolean } +/** + * Configuration to use when exporting playlists. + * + * @property absolute Whether or not to use absolute paths when exporting. If not, relative paths + * will be used. + * @property windowsPaths Whether or not to use Windows-style paths when exporting (i.e prefixed + * with C:\\ and using \). If not, Unix-style paths will be used (i.e prefixed with /). + * @see ExternalPlaylistManager.export + */ +data class ExportConfig(val absolute: Boolean, val windowsPaths: Boolean) + /** * A playlist that has been imported. * @@ -70,8 +98,14 @@ constructor( } } - override suspend fun export(playlist: Playlist, uri: Uri): Boolean { + override suspend fun export(playlist: Playlist, uri: Uri, config: ExportConfig): Boolean { val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return false + val workingDirectory = + if (config.absolute) { + filePath.directory + } else { + Path(filePath.volume, Components.parseUnix("/")) + } return try { val outputStream = context.contentResolverSafe.openOutputStream(uri) if (outputStream == null) { @@ -79,7 +113,7 @@ constructor( return false } outputStream.use { - m3u.write(playlist, it, filePath.directory) + m3u.write(playlist, it, workingDirectory, config) true } } catch (e: Exception) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 6e99d0925..1139cf87b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -22,7 +22,6 @@ import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import java.io.BufferedReader import java.io.BufferedWriter -import java.io.File import java.io.InputStream import java.io.InputStreamReader import java.io.OutputStream @@ -58,8 +57,19 @@ interface M3U { * @param outputStream The stream to write the M3U file to. * @param workingDirectory The directory that the M3U file is contained in. This is used to * create relative paths to where the M3U file is assumed to be stored. + * @param config The configuration to use when exporting the playlist. */ - fun write(playlist: Playlist, outputStream: OutputStream, workingDirectory: Path) + fun write( + playlist: Playlist, + outputStream: OutputStream, + workingDirectory: Path, + config: ExportConfig + ) + + companion object { + /** The mime type used for M3U files by the android system. */ + const val MIME_TYPE = "audio/x-mpegurl" + } } class M3UImpl @Inject constructor(@ApplicationContext private val context: Context) : M3U { @@ -101,24 +111,40 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte break@consumeFile } - // The path may be relative to the directory that the M3U file is contained in, - // signified by either the typical ./ or the absence of any separator at all. - // so we may need to resolve it into an absolute path before moving ahead. - val components = Components.parse(path) - val absoluteComponents = - if (path.startsWith(File.separatorChar)) { - // Already an absolute path, do nothing. Theres still some relative-ness here, - // as we assume that the path is still in the same volume as the working - // directory. - // Unsure if any program goes as far as writing out the full unobfuscated - // absolute path. - components - } else { - // Relative path, resolve it - components.absoluteTo(workingDirectory.components) + // There is basically no formal specification of file paths in M3U, and it differs + // based on the US that generated it. These are the paths though that I assume most + // programs will generate. + val components = + when { + path.startsWith('/') -> { + // Unix absolute path. Note that we still assume this absolute path is in + // the same volume as the M3U file. There's no sane way to map the volume + // to the phone's volumes, so this is the only thing we can do. + Components.parseUnix(path) + } + path.startsWith("./") -> { + // Unix relative path, resolve it + Components.parseUnix(path).absoluteTo(workingDirectory.components) + } + path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> { + // Windows absolute path, we should get rid of the volume prefix, but + // otherwise + // the rest should be fine. Again, we have to disregard what the volume + // actually + // is since there's no sane way to map it to the phone's volumes. + Components.parseWindows(path.substring(2)) + } + path.startsWith(".\\") -> { + // Windows relative path, we need to remove the .\\ prefix + Components.parseWindows(path).absoluteTo(workingDirectory.components) + } + else -> { + // No clue, parse by all separators and assume it's relative. + Components.parseAny(path).absoluteTo(workingDirectory.components) + } } - paths.add(Path(workingDirectory.volume, absoluteComponents)) + paths.add(Path(workingDirectory.volume, components)) } return if (paths.isNotEmpty()) { @@ -129,7 +155,12 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte } } - override fun write(playlist: Playlist, outputStream: OutputStream, workingDirectory: Path) { + override fun write( + playlist: Playlist, + outputStream: OutputStream, + workingDirectory: Path, + config: ExportConfig + ) { val writer = outputStream.bufferedWriter() // Try to be as compliant to the spec as possible while also cramming it full of extensions // I imagine other players will use. @@ -137,12 +168,33 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte writer.writeLine("#EXTENC:UTF-8") writer.writeLine("#PLAYLIST:${playlist.name.resolve(context)}") for (song in playlist.songs) { - val relativePath = song.path.components.relativeTo(workingDirectory.components) writer.writeLine("#EXTINF:${song.durationMs},${song.name.resolve(context)}") writer.writeLine("#EXTALB:${song.album.name.resolve(context)}") writer.writeLine("#EXTART:${song.artists.resolveNames(context)}") writer.writeLine("#EXTGEN:${song.genres.resolveNames(context)}") - writer.writeLine(relativePath.toString()) + + val formattedPath = + if (config.absolute) { + // The path is already absolute in this case, but we need to prefix and separate + // it differently depending on the setting. + if (config.windowsPaths) { + // Assume the plain windows C volume, since that's probably where most music + // libraries are on a windows PC. + "C:\\\\${song.path.components.windowsString}" + } else { + "/${song.path.components.unixString}" + } + } else { + // First need to make this path relative to the working directory of the M3U + // file, and then format it with the correct separators. + val relativePath = song.path.components.relativeTo(workingDirectory.components) + if (config.windowsPaths) { + relativePath.windowsString + } else { + relativePath.unixString + } + } + writer.writeLine(formattedPath) } writer.flush() } @@ -179,7 +231,7 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte ++commonIndex } - var relativeComponents = Components.parse(".") + var relativeComponents = Components.parseUnix(".") // TODO: Simplify this logic when { @@ -208,4 +260,8 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte return relativeComponents } + + private companion object { + val WINDOWS_VOLUME_PREFIX_REGEX = Regex("^[A-Za-z]:\\\\") + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt index 9505ac28a..7643603c7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt @@ -100,7 +100,7 @@ class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: Vol volumeManager.getVolumes().find { it is Volume.External && it.id == split[0] } } val relativePath = split.getOrNull(1) ?: return null - return Path(volume ?: return null, Components.parse(relativePath)) + return Path(volume ?: return null, Components.parseUnix(relativePath)) } private companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index 78e173f8d..c8b26523c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -98,7 +98,15 @@ value class Components private constructor(val components: List) { val name: String? get() = components.lastOrNull() - override fun toString() = components.joinToString(File.separator) + override fun toString() = unixString + + /** Formats these components using the unix file separator (/) */ + val unixString: String + get() = components.joinToString(File.separator) + + /** Formats these components using the windows file separator (\). */ + val windowsString: String + get() = components.joinToString("\\") /** * Returns a new [Components] instance with the last element of the path removed as a "parent" @@ -140,14 +148,35 @@ value class Components private constructor(val components: List) { companion object { /** - * Parses a path string into a [Components] instance by the system path separator. + * Parses a path string into a [Components] instance by the unix path separator (/). * * @param path The path string to parse. * @return The [Components] instance. */ - fun parse(path: String) = + fun parseUnix(path: String) = Components(path.trimSlashes().split(File.separatorChar).filter { it.isNotEmpty() }) + /** + * Parses a path string into a [Components] instance by the windows path separator. + * + * @param path The path string to parse. + * @return The [Components] instance. + */ + fun parseWindows(path: String) = + Components(path.trimSlashes().split('\\').filter { it.isNotEmpty() }) + + /** + * Parses a path string into a [Components] instance by any path separator, either unix or + * windows. This is useful for parsing paths when you can't determine the separators any + * other way, however also risks mangling the paths if they use unix-style escapes. + * + * @param path The path string to parse. + * @return The [Components] instance. + */ + fun parseAny(path: String) = + Components( + path.trimSlashes().split(File.separatorChar, '\\').filter { it.isNotEmpty() }) + private fun String.trimSlashes() = trimStart(File.separatorChar).trimEnd(File.separatorChar) } } @@ -188,7 +217,7 @@ class VolumeManagerImpl @Inject constructor(private val storageManager: StorageM get() = storageVolume.mediaStoreVolumeNameCompat override val components - get() = storageVolume.directoryCompat?.let(Components::parse) + get() = storageVolume.directoryCompat?.let(Components::parseUnix) override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context) } @@ -201,7 +230,7 @@ class VolumeManagerImpl @Inject constructor(private val storageManager: StorageM get() = storageVolume.mediaStoreVolumeNameCompat override val components - get() = storageVolume.directoryCompat?.let(Components::parse) + get() = storageVolume.directoryCompat?.let(Components::parseUnix) override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 1c0b58a7c..df9a979a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -422,7 +422,7 @@ private class Api21MediaStoreExtractor(context: Context, private val volumeManag val volumePath = (volume.components ?: continue).toString() val strippedPath = rawPath.removePrefix(volumePath) if (strippedPath != rawPath) { - rawSong.directory = Path(volume, Components.parse(strippedPath)) + rawSong.directory = Path(volume, Components.parseUnix(strippedPath)) break } } @@ -497,7 +497,7 @@ private abstract class BaseApi29MediaStoreExtractor(context: Context) : val relativePath = cursor.getString(relativePathIndex) val volume = volumes.find { it.mediaStoreName == volumeName } if (volume != null) { - rawSong.directory = Path(volume, Components.parse(relativePath)) + rawSong.directory = Path(volume, Components.parseUnix(relativePath)) } } } diff --git a/app/src/main/res/layout/dialog_playlist_export.xml b/app/src/main/res/layout/dialog_playlist_export.xml new file mode 100644 index 000000000..cdc89f25a --- /dev/null +++ b/app/src/main/res/layout/dialog_playlist_export.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file