music: add m3u exporting backend

Add the backend for exporting playlists to m3u files.
This commit is contained in:
Alexander Capehart 2023-12-20 22:49:02 -07:00
parent 3d92bdab6f
commit 771009d4ff
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
5 changed files with 99 additions and 91 deletions

View file

@ -28,7 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.music.external.PlaylistImporter
import org.oxycblt.auxio.music.external.ExternalPlaylistManager
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD
@ -44,7 +44,7 @@ class MusicViewModel
constructor(
private val listSettings: ListSettings,
private val musicRepository: MusicRepository,
private val playlistImporter: PlaylistImporter
private val externalPlaylistManager: ExternalPlaylistManager
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
private val _indexingState = MutableStateFlow<IndexingState?>(null)
@ -128,11 +128,11 @@ constructor(
* Import a playlist from a file [Uri]. Errors pushed to [importError].
*
* @param uri The [Uri] of the file to import.
* @see PlaylistImporter
* @see ExternalPlaylistManager
*/
fun importPlaylist(uri: Uri) =
viewModelScope.launch(Dispatchers.IO) {
val importedPlaylist = playlistImporter.import(uri)
val importedPlaylist = externalPlaylistManager.import(uri)
if (importedPlaylist == null) {
_importError.put(Unit)
return@launch

View file

@ -26,7 +26,7 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface ExternalModule {
@Binds fun playlistImporter(playlistImporter: PlaylistImporterImpl): PlaylistImporter
@Binds fun playlistImporter(playlistImporter: ExternalPlaylistManagerImpl): ExternalPlaylistManager
@Binds fun m3u(m3u: M3UImpl): M3U
}

View file

@ -18,15 +18,21 @@
package org.oxycblt.auxio.music.external
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import org.oxycblt.auxio.music.Playlist
import java.io.BufferedReader
import java.io.File
import java.io.InputStream
import java.io.InputStreamReader
import javax.inject.Inject
import org.oxycblt.auxio.music.fs.Components
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.util.logW
import java.io.File
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.logE
import java.io.BufferedWriter
import java.io.OutputStream
/**
* Minimal M3U file format implementation.
@ -44,9 +50,18 @@ interface M3U {
* @return An [ImportedPlaylist] containing the paths to the files listed in the M3U file,
*/
fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist?
/**
* Writes the given [playlist] to the given [outputStream] in the M3U format,.
* @param playlist The playlist to write.
* @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.
*/
fun write(playlist: Playlist, outputStream: OutputStream, workingDirectory: Path)
}
class M3UImpl @Inject constructor() : M3U {
class M3UImpl @Inject constructor(@ApplicationContext private val context: Context) : M3U {
override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
val reader = BufferedReader(InputStreamReader(stream))
val paths = mutableListOf<Path>()
@ -81,7 +96,7 @@ class M3UImpl @Inject constructor() : M3U {
}
if (path == null) {
logW("Expected a path, instead got an EOF")
logE("Expected a path, instead got an EOF")
break@consumeFile
}
@ -89,15 +104,17 @@ class M3UImpl @Inject constructor() : M3U {
// 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)) {
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.
// 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
resolveRelativePath(components, workingDirectory.components)
components.absoluteTo(workingDirectory.components)
}
paths.add(Path(workingDirectory.volume, absoluteComponents))
@ -111,22 +128,62 @@ class M3UImpl @Inject constructor() : M3U {
}
}
private fun resolveRelativePath(
relative: Components,
override fun write(
playlist: Playlist,
outputStream: OutputStream,
workingDirectory: Path
) {
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}")
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())
}
}
private fun BufferedWriter.writeLine(line: String) {
write(line)
newLine()
}
private fun Components.absoluteTo(
workingDirectory: Components
): Components {
var components = workingDirectory
for (component in relative.components) {
var absoluteComponents = workingDirectory
for (component in components) {
when (component) {
// Parent specifier, go "back" one directory (in practice cleave off the last
// component)
".." -> components = components.parent()
".." -> absoluteComponents = absoluteComponents.parent()
// Current directory, the components are already there.
"." -> {}
// New directory, add it
else -> components = components.child(component)
else -> absoluteComponents = absoluteComponents.child(component)
}
}
return components
return absoluteComponents
}
private fun Components.relativeTo(
workingDirectory: Components
): Components {
var relativeComponents = Components.parse(".")
var commonIndex = 0
while (commonIndex < components.size &&
commonIndex < workingDirectory.components.size &&
components[commonIndex] == workingDirectory.components[commonIndex]) {
++commonIndex
relativeComponents = relativeComponents.child("..")
}
return relativeComponents.concat(depth(commonIndex))
}
}

View file

@ -1,63 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistImporter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.external
import android.content.Context
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.contentResolverSafe
/**
* Generic playlist file importing abstraction.
*
* @see ImportedPlaylist
* @see M3U
* @author Alexander Capehart (OxygenCobalt)
*/
interface PlaylistImporter {
suspend fun import(uri: Uri): ImportedPlaylist?
}
/**
* A playlist that has been imported.
*
* @property name The name of the playlist. May be null if not provided.
* @property paths The paths of the files in the playlist.
* @see PlaylistImporter
* @see M3U
*/
data class ImportedPlaylist(val name: String?, val paths: List<Path>)
class PlaylistImporterImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
private val documentPathFactory: DocumentPathFactory,
private val m3u: M3U
) : PlaylistImporter {
override suspend fun import(uri: Uri): ImportedPlaylist? {
val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return null
return context.contentResolverSafe.openInputStream(uri)?.use {
return m3u.read(it, filePath.directory)
}
}
}

View file

@ -26,7 +26,6 @@ import android.webkit.MimeTypeMap
import java.io.File
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.logD
/**
* An abstraction of an android file system path, including the volume and relative path.
@ -117,11 +116,26 @@ value class Components private constructor(val components: List<String>) {
*/
fun child(name: String) =
if (name.isNotEmpty()) {
Components(components + name.trimSlashes()).also { logD(it.components) }
Components(components + name.trimSlashes())
} else {
this
}
/**
* Removes the first [n] elements of the path, effectively resulting in a path that is n
* levels deep.
* @param n The number of elements to remove.
* @return The new [Components] instance.
*/
fun depth(n: Int) = Components(components.drop(n))
/**
* Concatenates this [Components] instance with another.
* @param other The [Components] instance to concatenate with.
* @return The new [Components] instance.
*/
fun concat(other: Components) = Components(components + other.components)
companion object {
/**
* Parses a path string into a [Components] instance by the system path separator.