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
This commit is contained in:
Alexander Capehart 2023-12-23 12:12:51 -07:00
parent c3f67d4dc5
commit 68e4da5e7e
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
7 changed files with 163 additions and 34 deletions

View file

@ -0,0 +1,4 @@
package org.oxycblt.auxio.music.decision
class ExportPlaylistDialog {
}

View file

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

View file

@ -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]:\\\\")
}
}

View file

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

View file

@ -98,7 +98,15 @@ value class Components private constructor(val components: List<String>) {
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<String>) {
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)
}

View file

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

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
</androidx.constraintlayout.widget.ConstraintLayout>