music: add importing backend
Add basic importing infrastructure and an M3U parser to the backend.
This commit is contained in:
parent
364675b252
commit
fff8212b0a
5 changed files with 252 additions and 0 deletions
|
@ -0,0 +1,111 @@
|
|||
package org.oxycblt.auxio.music.fs
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.database.getStringOrNull
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Resolves a content URI into a [Path] instance.
|
||||
* TODO: Integrate this with [MediaStoreExtractor].
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface ContentPathResolver {
|
||||
/**
|
||||
* Resolve a content [Uri] into it's corresponding [Path].
|
||||
* @param uri The content [Uri] to resolve.
|
||||
* @return The corresponding [Path], or null if the [Uri] is invalid.
|
||||
*/
|
||||
fun resolve(uri: Uri): Path?
|
||||
|
||||
companion object {
|
||||
fun from(context: Context, volumeManager: VolumeManager) =
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
|
||||
Api29ContentPathResolverImpl(context.contentResolverSafe, volumeManager)
|
||||
|
||||
else -> Api21ContentPathResolverImpl(context.contentResolverSafe, volumeManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Api21ContentPathResolverImpl(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val volumeManager: VolumeManager
|
||||
) : ContentPathResolver {
|
||||
override fun resolve(uri: Uri): Path? {
|
||||
val rawPath = contentResolver.useQuery(
|
||||
uri, arrayOf(MediaStore.MediaColumns.DATA)
|
||||
) { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getStringOrNull(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA))
|
||||
}
|
||||
|
||||
if (rawPath == null) {
|
||||
logE("No data available for uri $uri")
|
||||
return null
|
||||
}
|
||||
|
||||
val volumes = volumeManager.getVolumes()
|
||||
for (volume in volumes) {
|
||||
val volumePath = (volume.components ?: continue).toString()
|
||||
val strippedPath = rawPath.removePrefix(volumePath)
|
||||
if (strippedPath != rawPath) {
|
||||
return Path(volume, Components.parse(strippedPath))
|
||||
}
|
||||
}
|
||||
|
||||
logE("No volume found for uri $uri")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private class Api29ContentPathResolverImpl(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val volumeManager: VolumeManager
|
||||
) : ContentPathResolver {
|
||||
private data class RawPath(val volumeName: String?, val relativePath: String?)
|
||||
|
||||
override fun resolve(uri: Uri): Path? {
|
||||
val rawPath = contentResolver.useQuery(
|
||||
uri, arrayOf(
|
||||
MediaStore.MediaColumns.VOLUME_NAME,
|
||||
MediaStore.MediaColumns.RELATIVE_PATH
|
||||
)
|
||||
) { cursor ->
|
||||
cursor.moveToFirst()
|
||||
RawPath(
|
||||
cursor.getStringOrNull(
|
||||
cursor.getColumnIndexOrThrow(
|
||||
MediaStore.MediaColumns.VOLUME_NAME
|
||||
)
|
||||
),
|
||||
cursor.getStringOrNull(
|
||||
cursor.getColumnIndexOrThrow(
|
||||
MediaStore.MediaColumns.RELATIVE_PATH
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (rawPath.volumeName == null || rawPath.relativePath == null) {
|
||||
logE("No data available for uri $uri (raw path obtained: $rawPath)")
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the StorageVolume whose MediaStore name corresponds to this song.
|
||||
// This is combined with the plain relative path column to create the directory.
|
||||
val volume = volumeManager.getVolumes().find { it.mediaStoreName == rawPath.volumeName }
|
||||
if (volume != null) {
|
||||
return Path(volume, Components.parse(rawPath.relativePath))
|
||||
}
|
||||
|
||||
logE("No volume found for uri $uri")
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -37,4 +37,7 @@ class FsModule {
|
|||
@Provides
|
||||
fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) =
|
||||
MediaStoreExtractor.from(context, volumeManager)
|
||||
|
||||
@Provides
|
||||
fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) = ContentPathResolver.from(context, volumeManager)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* ForeignModule.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.import
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface ImportModule {
|
||||
@Binds fun playlistImporter(playlistImporter: PlaylistImporterImpl): PlaylistImporter
|
||||
|
||||
@Binds fun m3u(m3u: M3UImpl): M3U
|
||||
}
|
73
app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt
Normal file
73
app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* M3U.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.import
|
||||
|
||||
import org.oxycblt.auxio.music.fs.Components
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStream
|
||||
import java.io.InputStreamReader
|
||||
import org.oxycblt.auxio.music.fs.Path
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import javax.inject.Inject
|
||||
|
||||
interface M3U {
|
||||
fun read(stream: InputStream, workingDirectory: Path): List<Path>?
|
||||
}
|
||||
|
||||
class M3UImpl @Inject constructor() : M3U {
|
||||
override fun read(stream: InputStream, workingDirectory: Path): List<Path>? {
|
||||
val reader = BufferedReader(InputStreamReader(stream))
|
||||
val media = mutableListOf<Path>()
|
||||
|
||||
consumeFile@ while (true) {
|
||||
collectMetadata@ while (true) {
|
||||
val line = reader.readLine() ?: break@consumeFile
|
||||
if (!line.startsWith("#")) {
|
||||
break@collectMetadata
|
||||
}
|
||||
}
|
||||
|
||||
val path = reader.readLine()
|
||||
if (path == null) {
|
||||
logW("Expected a path, instead got an EOF")
|
||||
break@consumeFile
|
||||
}
|
||||
|
||||
val relativeComponents = Components.parse(path)
|
||||
val absoluteComponents =
|
||||
resolveRelativePath(relativeComponents, workingDirectory.components)
|
||||
|
||||
media.add(Path(workingDirectory.volume, absoluteComponents))
|
||||
}
|
||||
|
||||
return media.ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun resolveRelativePath(relative: Components, workingDirectory: Components): Components {
|
||||
var components = workingDirectory
|
||||
for (component in relative.components) {
|
||||
when (component) {
|
||||
".." -> components = components.parent()
|
||||
"." -> {}
|
||||
else -> components = components.child(component)
|
||||
}
|
||||
}
|
||||
return components
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package org.oxycblt.auxio.music.import
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.music.fs.ContentPathResolver
|
||||
import org.oxycblt.auxio.music.fs.Path
|
||||
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
||||
import javax.inject.Inject
|
||||
|
||||
interface PlaylistImporter {
|
||||
suspend fun import(uri: Uri): ImportedPlaylist?
|
||||
}
|
||||
|
||||
data class ImportedPlaylist(val name: String?, val paths: List<Path>)
|
||||
|
||||
class PlaylistImporterImpl @Inject constructor(
|
||||
@ApplicationContext private val contentResolver: ContentResolver,
|
||||
private val contentPathResolver: ContentPathResolver,
|
||||
private val m3u: M3U
|
||||
) : PlaylistImporter {
|
||||
override suspend fun import(uri: Uri): ImportedPlaylist? {
|
||||
val workingDirectory = contentPathResolver.resolve(uri) ?: return null
|
||||
return contentResolver.openInputStream(uri)?.use {
|
||||
val paths = m3u.read(it, workingDirectory) ?: return null
|
||||
return ImportedPlaylist(null, paths)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue