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

View file

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

View file

@ -18,15 +18,21 @@
package org.oxycblt.auxio.music.external 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.BufferedReader
import java.io.File
import java.io.InputStream import java.io.InputStream
import java.io.InputStreamReader import java.io.InputStreamReader
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.fs.Components import org.oxycblt.auxio.music.fs.Components
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.music.resolveNames
import java.io.File import org.oxycblt.auxio.util.logE
import java.io.BufferedWriter
import java.io.OutputStream
/** /**
* Minimal M3U file format implementation. * 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, * @return An [ImportedPlaylist] containing the paths to the files listed in the M3U file,
*/ */
fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? 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? { override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
val reader = BufferedReader(InputStreamReader(stream)) val reader = BufferedReader(InputStreamReader(stream))
val paths = mutableListOf<Path>() val paths = mutableListOf<Path>()
@ -81,7 +96,7 @@ class M3UImpl @Inject constructor() : M3U {
} }
if (path == null) { if (path == null) {
logW("Expected a path, instead got an EOF") logE("Expected a path, instead got an EOF")
break@consumeFile break@consumeFile
} }
@ -89,16 +104,18 @@ class M3UImpl @Inject constructor() : M3U {
// signified by either the typical ./ or the absence of any separator at all. // 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. // so we may need to resolve it into an absolute path before moving ahead.
val components = Components.parse(path) val components = Components.parse(path)
val absoluteComponents = if (path.startsWith(File.separatorChar)) { val absoluteComponents =
// Already an absolute path, do nothing. Theres still some relative-ness here, if (path.startsWith(File.separatorChar)) {
// as we assume that the path is still in the same volume as the working directory. // Already an absolute path, do nothing. Theres still some relative-ness here,
// Unsure if any program goes as far as writing out the full unobfuscated // as we assume that the path is still in the same volume as the working
// absolute path. // directory.
components // Unsure if any program goes as far as writing out the full unobfuscated
} else { // absolute path.
// Relative path, resolve it components
resolveRelativePath(components, workingDirectory.components) } else {
} // Relative path, resolve it
components.absoluteTo(workingDirectory.components)
}
paths.add(Path(workingDirectory.volume, absoluteComponents)) paths.add(Path(workingDirectory.volume, absoluteComponents))
} }
@ -111,22 +128,62 @@ class M3UImpl @Inject constructor() : M3U {
} }
} }
private fun resolveRelativePath( override fun write(
relative: Components, 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 workingDirectory: Components
): Components { ): Components {
var components = workingDirectory var absoluteComponents = workingDirectory
for (component in relative.components) { for (component in components) {
when (component) { when (component) {
// Parent specifier, go "back" one directory (in practice cleave off the last // Parent specifier, go "back" one directory (in practice cleave off the last
// component) // component)
".." -> components = components.parent() ".." -> absoluteComponents = absoluteComponents.parent()
// Current directory, the components are already there. // Current directory, the components are already there.
"." -> {} "." -> {}
// New directory, add it // 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 java.io.File
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R 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. * 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) = fun child(name: String) =
if (name.isNotEmpty()) { if (name.isNotEmpty()) {
Components(components + name.trimSlashes()).also { logD(it.components) } Components(components + name.trimSlashes())
} else { } else {
this 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 { 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 system path separator.