diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 452e3954a..e31908e92 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -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(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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt index a1824fadc..e2f56a21c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt @@ -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 } diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index f656aefd2..1ef194819 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -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() @@ -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,16 +104,18 @@ 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)) { - // 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 - resolveRelativePath(components, workingDirectory.components) - } + 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) + } 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)) + } + } diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt b/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt deleted file mode 100644 index d402e901e..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt +++ /dev/null @@ -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 . - */ - -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) - -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) - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index 1f152ed7e..989c72263 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -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) { */ 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.