Skip to content

Commit

Permalink
music: make playlist export configurable
Browse files Browse the repository at this point in the history
Add configuration options for:
- Using windows-compatible paths with \ separators and C:\\ volume
prefixes
- Switching between relative and absolute paths
  • Loading branch information
OxygenCobalt committed Dec 23, 2023
1 parent c3f67d4 commit 68e4da5
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 34 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.oxycblt.auxio.music.decision

class ExportPlaylistDialog {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand Down Expand Up @@ -70,16 +98,22 @@ 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) {
logE("Failed to export playlist: Could not open output stream")
return false
}
outputStream.use {
m3u.write(playlist, it, filePath.directory)
m3u.write(playlist, it, workingDirectory, config)
true
}
} catch (e: Exception) {
Expand Down
100 changes: 78 additions & 22 deletions app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()) {
Expand All @@ -129,20 +155,46 @@ 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.
writer.writeLine("#EXTM3U")
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()
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]:\\\\")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 34 additions & 5 deletions app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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))
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/layout/dialog_playlist_export.xml
Original file line number Diff line number Diff line change
@@ -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>

0 comments on commit 68e4da5

Please sign in to comment.