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.content.Context
import android.net.Uri import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.oxycblt.auxio.music.Playlist
import javax.inject.Inject 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.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
@ -36,10 +37,37 @@ import org.oxycblt.auxio.util.logE
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface ExternalPlaylistManager { 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 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. * 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 filePath = documentPathFactory.unpackDocumentUri(uri) ?: return false
val workingDirectory =
if (config.absolute) {
filePath.directory
} else {
Path(filePath.volume, Components.parseUnix("/"))
}
return try { return try {
val outputStream = context.contentResolverSafe.openOutputStream(uri) val outputStream = context.contentResolverSafe.openOutputStream(uri)
if (outputStream == null) { if (outputStream == null) {
@ -79,7 +113,7 @@ constructor(
return false return false
} }
outputStream.use { outputStream.use {
m3u.write(playlist, it, filePath.directory) m3u.write(playlist, it, workingDirectory, config)
true true
} }
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -22,7 +22,6 @@ import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.BufferedReader import java.io.BufferedReader
import java.io.BufferedWriter import java.io.BufferedWriter
import java.io.File
import java.io.InputStream import java.io.InputStream
import java.io.InputStreamReader import java.io.InputStreamReader
import java.io.OutputStream import java.io.OutputStream
@ -58,8 +57,19 @@ interface M3U {
* @param outputStream The stream to write the M3U file to. * @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 * @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. * 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 { 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 break@consumeFile
} }
// The path may be relative to the directory that the M3U file is contained in, // There is basically no formal specification of file paths in M3U, and it differs
// signified by either the typical ./ or the absence of any separator at all. // based on the US that generated it. These are the paths though that I assume most
// so we may need to resolve it into an absolute path before moving ahead. // programs will generate.
val components = Components.parse(path) val components =
val absoluteComponents = when {
if (path.startsWith(File.separatorChar)) { path.startsWith('/') -> {
// Already an absolute path, do nothing. Theres still some relative-ness here, // Unix absolute path. Note that we still assume this absolute path is in
// as we assume that the path is still in the same volume as the working // the same volume as the M3U file. There's no sane way to map the volume
// directory. // to the phone's volumes, so this is the only thing we can do.
// Unsure if any program goes as far as writing out the full unobfuscated Components.parseUnix(path)
// absolute path. }
components path.startsWith("./") -> {
} else { // Unix relative path, resolve it
// Relative path, resolve it Components.parseUnix(path).absoluteTo(workingDirectory.components)
components.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()) { 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() val writer = outputStream.bufferedWriter()
// Try to be as compliant to the spec as possible while also cramming it full of extensions // Try to be as compliant to the spec as possible while also cramming it full of extensions
// I imagine other players will use. // 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("#EXTENC:UTF-8")
writer.writeLine("#PLAYLIST:${playlist.name.resolve(context)}") writer.writeLine("#PLAYLIST:${playlist.name.resolve(context)}")
for (song in playlist.songs) { for (song in playlist.songs) {
val relativePath = song.path.components.relativeTo(workingDirectory.components)
writer.writeLine("#EXTINF:${song.durationMs},${song.name.resolve(context)}") writer.writeLine("#EXTINF:${song.durationMs},${song.name.resolve(context)}")
writer.writeLine("#EXTALB:${song.album.name.resolve(context)}") writer.writeLine("#EXTALB:${song.album.name.resolve(context)}")
writer.writeLine("#EXTART:${song.artists.resolveNames(context)}") writer.writeLine("#EXTART:${song.artists.resolveNames(context)}")
writer.writeLine("#EXTGEN:${song.genres.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() writer.flush()
} }
@ -179,7 +231,7 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte
++commonIndex ++commonIndex
} }
var relativeComponents = Components.parse(".") var relativeComponents = Components.parseUnix(".")
// TODO: Simplify this logic // TODO: Simplify this logic
when { when {
@ -208,4 +260,8 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte
return relativeComponents 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] } volumeManager.getVolumes().find { it is Volume.External && it.id == split[0] }
} }
val relativePath = split.getOrNull(1) ?: return null 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 { private companion object {

View file

@ -98,7 +98,15 @@ value class Components private constructor(val components: List<String>) {
val name: String? val name: String?
get() = components.lastOrNull() 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" * 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 { 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. * @param path The path string to parse.
* @return The [Components] instance. * @return The [Components] instance.
*/ */
fun parse(path: String) = fun parseUnix(path: String) =
Components(path.trimSlashes().split(File.separatorChar).filter { it.isNotEmpty() }) 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) 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 get() = storageVolume.mediaStoreVolumeNameCompat
override val components override val components
get() = storageVolume.directoryCompat?.let(Components::parse) get() = storageVolume.directoryCompat?.let(Components::parseUnix)
override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context) override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context)
} }
@ -201,7 +230,7 @@ class VolumeManagerImpl @Inject constructor(private val storageManager: StorageM
get() = storageVolume.mediaStoreVolumeNameCompat get() = storageVolume.mediaStoreVolumeNameCompat
override val components override val components
get() = storageVolume.directoryCompat?.let(Components::parse) get() = storageVolume.directoryCompat?.let(Components::parseUnix)
override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context) 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 volumePath = (volume.components ?: continue).toString()
val strippedPath = rawPath.removePrefix(volumePath) val strippedPath = rawPath.removePrefix(volumePath)
if (strippedPath != rawPath) { if (strippedPath != rawPath) {
rawSong.directory = Path(volume, Components.parse(strippedPath)) rawSong.directory = Path(volume, Components.parseUnix(strippedPath))
break break
} }
} }
@ -497,7 +497,7 @@ private abstract class BaseApi29MediaStoreExtractor(context: Context) :
val relativePath = cursor.getString(relativePathIndex) val relativePath = cursor.getString(relativePathIndex)
val volume = volumes.find { it.mediaStoreName == volumeName } val volume = volumes.find { it.mediaStoreName == volumeName }
if (volume != null) { 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>