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