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:
parent
c3f67d4dc5
commit
68e4da5e7e
7 changed files with 163 additions and 34 deletions
|
@ -0,0 +1,4 @@
|
||||||
|
package org.oxycblt.auxio.music.decision
|
||||||
|
|
||||||
|
class ExportPlaylistDialog {
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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]:\\\\")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
6
app/src/main/res/layout/dialog_playlist_export.xml
Normal file
6
app/src/main/res/layout/dialog_playlist_export.xml
Normal 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>
|
Loading…
Reference in a new issue