all: eliminate refactor errors

This commit is contained in:
Alexander Capehart 2024-11-19 17:50:56 -07:00
parent f76eafc9d4
commit 556c5d5e0a
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
19 changed files with 300 additions and 396 deletions

View file

@ -30,12 +30,12 @@ import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.stack.fs.MimeType
import org.oxycblt.auxio.music.stack.fs.Path
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.stack.fs.MimeType
import org.oxycblt.auxio.music.stack.fs.Path
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.toUuidOrNull

View file

@ -21,27 +21,20 @@ package org.oxycblt.auxio.music
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import java.util.LinkedList
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.cache.CacheRepository
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.stack.Indexer
import org.oxycblt.auxio.music.user.MutableUserLibrary
import org.oxycblt.auxio.music.user.UserLibrary
import org.oxycblt.auxio.util.DEFAULT_TIMEOUT
import org.oxycblt.auxio.util.forEachWithTimeout
import timber.log.Timber as L
/**
@ -220,9 +213,7 @@ interface MusicRepository {
class MusicRepositoryImpl
@Inject
constructor(
private val cacheRepository: CacheRepository,
private val mediaStoreExtractor: MediaStoreExtractor,
private val tagExtractor: TagExtractor,
private val indexer: Indexer,
private val deviceLibraryFactory: DeviceLibrary.Factory,
private val userLibraryFactory: UserLibrary.Factory,
private val musicSettings: MusicSettings
@ -355,9 +346,6 @@ constructor(
}
private suspend fun indexImpl(context: Context, scope: CoroutineScope, withCache: Boolean) {
// TODO: Find a way to break up this monster of a method, preferably as another class.
val start = System.currentTimeMillis()
// Make sure we have permissions before going forward. Theoretically this would be better
// done at the UI level, but that intertwines logic and display too much.
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
@ -367,8 +355,6 @@ constructor(
}
// Obtain configuration information
val constraints =
MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs)
val separators = Separators.from(musicSettings.separators)
val nameFactory =
if (musicSettings.intelligentSorting) {
@ -377,174 +363,7 @@ constructor(
Name.Known.SimpleFactory
}
// Begin with querying MediaStore and the music cache. The former is needed for Auxio
// to figure out what songs are (probably) on the device, and the latter will be needed
// for discovery (described later). These have no shared state, so they are done in
// parallel.
L.d("Starting MediaStore query")
emitIndexingProgress(IndexingProgress.Indeterminate)
val mediaStoreQueryJob =
scope.async {
val query =
try {
mediaStoreExtractor.query(constraints)
} catch (e: Exception) {
// Normally, errors in an async call immediately bubble up to the Looper
// and crash the app. Thus, we have to wrap any error into a Result
// and then manually forward it to the try block that indexImpl is
// called from.
return@async Result.failure(e)
}
Result.success(query)
}
// Since this main thread is a co-routine, we can do operations in parallel in a way
// identical to calling async.
val cache =
if (withCache) {
L.d("Reading cache")
cacheRepository.readCache()
} else {
null
}
L.d("Awaiting MediaStore query")
val query = mediaStoreQueryJob.await().getOrThrow()
// We now have all the information required to start the "discovery" process. This
// is the point at which Auxio starts scanning each file given from MediaStore and
// transforming it into a music library. MediaStore normally
L.d("Starting discovery")
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED) // Not fully populated w/metadata
val completeSongs = Channel<RawSong>(Channel.UNLIMITED) // Populated with quality metadata
val processedSongs = Channel<RawSong>(Channel.UNLIMITED) // Transformed into SongImpl
// MediaStoreExtractor discovers all music on the device, and forwards them to either
// DeviceLibrary if cached metadata exists for it, or TagExtractor if cached metadata
// does not exist. In the latter situation, it also applies it's own (inferior) metadata.
L.d("Starting MediaStore discovery")
val mediaStoreJob =
scope.async {
try {
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
} catch (e: Exception) {
// To prevent a deadlock, we want to close the channel with an exception
// to cascade to and cancel all other routines before finally bubbling up
// to the main extractor loop.
L.e("MediaStore extraction failed: $e")
incompleteSongs.close(
Exception("MediaStore extraction failed: ${e.stackTraceToString()}"))
return@async
}
incompleteSongs.close()
}
// TagExtractor takes the incomplete songs from MediaStoreExtractor, parses up-to-date
// metadata for them, and then forwards it to DeviceLibrary.
L.d("Starting tag extraction")
val tagJob =
scope.async {
try {
tagExtractor.consume(incompleteSongs, completeSongs)
} catch (e: Exception) {
L.e("Tag extraction failed: $e")
completeSongs.close(
Exception("Tag extraction failed: ${e.stackTraceToString()}"))
return@async
}
completeSongs.close()
}
// DeviceLibrary constructs music parent instances as song information is provided,
// and then forwards them to the primary loading loop.
L.d("Starting DeviceLibrary creation")
val deviceLibraryJob =
scope.async(Dispatchers.Default) {
val deviceLibrary =
try {
deviceLibraryFactory.create(
completeSongs, processedSongs, separators, nameFactory)
} catch (e: Exception) {
L.e("DeviceLibrary creation failed: $e")
processedSongs.close(
Exception("DeviceLibrary creation failed: ${e.stackTraceToString()}"))
return@async Result.failure(e)
}
processedSongs.close()
Result.success(deviceLibrary)
}
// We could keep track of a total here, but we also need to collate this RawSong information
// for when we write the cache later on in the finalization step.
val rawSongs = LinkedList<RawSong>()
// Use a longer timeout so that dependent components can timeout and throw errors that
// provide more context than if we timed out here.
processedSongs.forEachWithTimeout(DEFAULT_TIMEOUT * 2) {
rawSongs.add(it)
// Since discovery takes up the bulk of the music loading process, we switch to
// indicating a defined amount of loaded songs in comparison to the projected amount
// of songs that were queried.
emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
}
withTimeout(DEFAULT_TIMEOUT) {
mediaStoreJob.await()
tagJob.await()
}
// Deliberately done after the involved initialization step to make it less likely
// that the short-circuit occurs so quickly as to break the UI.
// TODO: Do not error, instead just wipe the entire library.
if (rawSongs.isEmpty()) {
L.e("Music library was empty")
throw NoMusicException()
}
// Now that the library is effectively loaded, we can start the finalization step, which
// involves writing new cache information and creating more music data that is derived
// from the library (e.g playlists)
L.d("Discovered ${rawSongs.size} songs, starting finalization")
// We have no idea how long the cache will take, and the playlist construction
// will be too fast to indicate, so switch back to an indeterminate state.
emitIndexingProgress(IndexingProgress.Indeterminate)
// The UserLibrary job is split into a query and construction step, a la MediaStore.
// This way, we can start working on playlists even as DeviceLibrary might still be
// working on parent information.
L.d("Starting UserLibrary query")
val userLibraryQueryJob =
scope.async {
val rawPlaylists =
try {
userLibraryFactory.query()
} catch (e: Exception) {
return@async Result.failure(e)
}
Result.success(rawPlaylists)
}
// The cache might not exist, or we might have encountered a song not present in it.
// Both situations require us to rewrite the cache in bulk. This is also done parallel
// since the playlist read will probably take some time.
// TODO: Read/write from the cache incrementally instead of in bulk?
if (cache == null || cache.invalidated) {
L.d("Writing cache [why=${cache?.invalidated}]")
cacheRepository.writeCache(rawSongs)
}
// Create UserLibrary once we finally get the required components for it.
L.d("Awaiting UserLibrary query")
val rawPlaylists = userLibraryQueryJob.await().getOrThrow()
L.d("Awaiting DeviceLibrary creation")
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
L.d("Starting UserLibrary creation")
val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory)
// Loading process is functionally done, indicate such
L.d(
"Successfully indexed music library [device=$deviceLibrary " +
"user=$userLibrary time=${System.currentTimeMillis() - start}]")
emitIndexingCompletion(null)
val (deviceLibrary, userLibrary) = indexer.run(listOf(), separators, nameFactory)
val deviceLibraryChanged: Boolean
val userLibraryChanged: Boolean

View file

@ -20,10 +20,8 @@ package org.oxycblt.auxio.music.device
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@ -32,13 +30,9 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.stack.fs.Path
import org.oxycblt.auxio.music.stack.fs.contentResolverSafe
import org.oxycblt.auxio.music.stack.fs.useQuery
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.util.forEachWithTimeout
import org.oxycblt.auxio.util.sendWithTimeout
import org.oxycblt.auxio.music.stack.fs.Path
import org.oxycblt.auxio.util.unlikelyToBeNull
import timber.log.Timber as L
@ -372,6 +366,7 @@ class DeviceLibraryImpl(
// // We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
// // song. Do what we can to hopefully find the song the user wanted to open.
// val displayName =
//
// cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
// val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
// songs.find { it.path.name == displayName && it.size == size }

View file

@ -28,16 +28,13 @@ import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.stack.fs.MimeType
import org.oxycblt.auxio.music.stack.fs.toAlbumCoverUri
import org.oxycblt.auxio.music.stack.fs.toAudioUri
import org.oxycblt.auxio.music.stack.fs.toSongCoverUri
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.stack.extractor.parseId3GenreNames
import org.oxycblt.auxio.music.stack.fs.MimeType
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
import org.oxycblt.auxio.util.positiveOrNull
import org.oxycblt.auxio.util.toUuidOrNull
@ -76,31 +73,28 @@ class SongImpl(
}
override val name =
nameFactory.parse(
requireNotNull(rawSong.name) { "Invalid raw ${rawSong.path}: No title" },
requireNotNull(rawSong.name) { "Invalid raw ${rawSong.file.path}: No title" },
rawSong.sortName)
override val track = rawSong.track
override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
override val date = rawSong.date
override val uri =
requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }.toAudioUri()
override val path = requireNotNull(rawSong.path) { "Invalid raw ${rawSong.path}: No path" }
override val mimeType =
MimeType(
fromExtension =
requireNotNull(rawSong.extensionMimeType) {
"Invalid raw ${rawSong.path}: No mime type"
},
fromFormat = null)
override val size = requireNotNull(rawSong.size) { "Invalid raw ${rawSong.path}: No size" }
override val uri = rawSong.file.uri
override val path = rawSong.file.path
override val mimeType = MimeType(fromExtension = rawSong.file.mimeType, fromFormat = null)
override val size =
requireNotNull(rawSong.file.size) { "Invalid raw ${rawSong.file.path}: No size" }
override val durationMs =
requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.path}: No duration" }
requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.file.path}: No duration" }
override val replayGainAdjustment =
ReplayGainAdjustment(
track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment)
// TODO: See what we want to do with date added now that we can't get it anymore.
override val dateAdded =
requireNotNull(rawSong.dateAdded) { "Invalid raw ${rawSong.path}: No date added" }
requireNotNull(rawSong.file.lastModified) {
"Invalid raw ${rawSong.file.path}: No date added"
}
private var _album: AlbumImpl? = null
override val album: Album
@ -114,18 +108,8 @@ class SongImpl(
override val genres: List<Genre>
get() = _genres
override val cover =
rawSong.coverPerceptualHash?.let {
// We were able to confirm that the song had a parsable cover and can be used on
// a per-song basis. Otherwise, just fall back to a per-album cover instead, as
// it implies either a cover.jpg pattern is used (likely) or ExoPlayer does not
// support the cover metadata of a given spec (unlikely).
Cover.Embedded(
requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }
.toSongCoverUri(),
uri,
it)
} ?: Cover.External(requireNotNull(rawSong.albumMediaStoreId).toAlbumCoverUri())
// TODO: Rebuild cover system
override val cover = Cover.External(rawSong.file.uri)
/**
* The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
@ -184,14 +168,10 @@ class SongImpl(
rawAlbum =
RawAlbum(
mediaStoreId =
requireNotNull(rawSong.albumMediaStoreId) {
"Invalid raw ${rawSong.path}: No album id"
},
musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(),
name =
requireNotNull(rawSong.albumName) {
"Invalid raw ${rawSong.path}: No album name"
"Invalid raw ${rawSong.file.path}: No album name"
},
sortName = rawSong.albumSortName,
releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)),

View file

@ -22,9 +22,9 @@ import java.util.UUID
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.stack.fs.DeviceFile
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.stack.fs.DeviceFile
/**
* Raw information about a [SongImpl] obtained from the filesystem/Extractor instances.
@ -53,10 +53,6 @@ data class RawSong(
var subtitle: String? = null,
/** @see Song.date */
var date: Date? = null,
/** @see Song.cover */
var coverPerceptualHash: String? = null,
/** @see RawAlbum.mediaStoreId */
var albumMediaStoreId: Long? = null,
/** @see RawAlbum.musicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RawAlbum.name */
@ -87,11 +83,6 @@ data class RawSong(
* @author Alexander Capehart (OxygenCobalt)
*/
data class RawAlbum(
/**
* The ID of the [AlbumImpl]'s grouping, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the system-provided cover art.
*/
val mediaStoreId: Long,
/** @see Music.uid */
override val musicBrainzId: UUID?,
/** @see Music.name */

View file

@ -27,12 +27,12 @@ import java.io.InputStreamReader
import java.io.OutputStream
import javax.inject.Inject
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.music.stack.extractor.correctWhitespace
import org.oxycblt.auxio.music.stack.fs.Components
import org.oxycblt.auxio.music.stack.fs.Path
import org.oxycblt.auxio.music.stack.fs.Volume
import org.oxycblt.auxio.music.stack.fs.VolumeManager
import org.oxycblt.auxio.music.stack.extractor.correctWhitespace
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.unlikelyToBeNull
import timber.log.Timber as L

View file

@ -119,7 +119,6 @@ constructor(@ApplicationContext private val context: Context) : AudioProperties.
return AudioProperties(
bitrate,
sampleRate,
MimeType(fromExtension = song.mimeType.fromExtension, fromFormat = formatMimeType)
)
MimeType(fromExtension = song.mimeType.fromExtension, fromFormat = formatMimeType))
}
}

View file

@ -22,19 +22,10 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.oxycblt.auxio.music.stack.extractor.ExoPlayerTagExtractor
import org.oxycblt.auxio.music.stack.extractor.ExoPlayerTagExtractorImpl
import org.oxycblt.auxio.music.stack.extractor.TagInterpreter2
import org.oxycblt.auxio.music.stack.extractor.TagInterpreter2Impl
@Module
@InstallIn(SingletonComponent::class)
interface MetadataModule {
@Binds fun tagInterpreter(interpreter: TagInterpreterImpl): TagInterpreter
@Binds fun tagInterpreter2(interpreter: TagInterpreter2Impl): TagInterpreter2
@Binds fun exoPlayerTagExtractor(extractor: ExoPlayerTagExtractorImpl): ExoPlayerTagExtractor
@Binds fun audioPropertiesFactory(factory: AudioPropertiesFactoryImpl): AudioProperties.Factory
@Binds
fun audioPropertiesFactory(interpreter: AudioPropertiesFactoryImpl): AudioProperties.Factory
}

View file

@ -1,3 +1,21 @@
/*
* Copyright (c) 2024 Auxio Project
* Separators.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.metadata
import org.oxycblt.auxio.music.stack.extractor.correctWhitespace

View file

@ -1,6 +1,25 @@
/*
* Copyright (c) 2024 Auxio Project
* Indexer.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.stack
import android.net.Uri
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@ -14,7 +33,6 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.toList
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.info.Name
@ -24,44 +42,51 @@ import org.oxycblt.auxio.music.stack.extractor.ExoPlayerTagExtractor
import org.oxycblt.auxio.music.stack.extractor.TagResult
import org.oxycblt.auxio.music.stack.fs.DeviceFile
import org.oxycblt.auxio.music.stack.fs.DeviceFiles
import org.oxycblt.auxio.music.user.MutableUserLibrary
import org.oxycblt.auxio.music.user.UserLibrary
import javax.inject.Inject
interface Indexer {
suspend fun run(uris: List<Uri>, separators: Separators, nameFactory: Name.Known.Factory): LibraryResult
suspend fun run(
uris: List<Uri>,
separators: Separators,
nameFactory: Name.Known.Factory
): LibraryResult
}
data class LibraryResult(val deviceLibrary: DeviceLibrary, val userLibrary: UserLibrary)
data class LibraryResult(val deviceLibrary: DeviceLibrary, val userLibrary: MutableUserLibrary)
class IndexerImpl @Inject constructor(
class IndexerImpl
@Inject
constructor(
private val deviceFiles: DeviceFiles,
private val tagCache: TagCache,
private val tagExtractor: ExoPlayerTagExtractor,
private val deviceLibraryFactory: DeviceLibrary.Factory,
private val userLibraryFactory: UserLibrary.Factory
) : Indexer {
override suspend fun run(uris: List<Uri>, separators: Separators, nameFactory: Name.Known.Factory) = coroutineScope {
val deviceFiles = deviceFiles.explore(uris.asFlow())
.flowOn(Dispatchers.IO)
.buffer()
val tagRead = tagCache.read(deviceFiles)
.flowOn(Dispatchers.IO)
.buffer()
override suspend fun run(
uris: List<Uri>,
separators: Separators,
nameFactory: Name.Known.Factory
) = coroutineScope {
val deviceFiles = deviceFiles.explore(uris.asFlow()).flowOn(Dispatchers.IO).buffer()
val tagRead = tagCache.read(deviceFiles).flowOn(Dispatchers.IO).buffer()
val (cacheFiles, cacheSongs) = tagRead.split()
val tagExtractor =
tagExtractor.process(cacheFiles)
.flowOn(Dispatchers.IO)
.buffer()
val tagExtractor = tagExtractor.process(cacheFiles).flowOn(Dispatchers.IO).buffer()
val (_, extractorSongs) = tagExtractor.split()
val sharedExtractorSongs = extractorSongs.shareIn(
val sharedExtractorSongs =
extractorSongs.shareIn(
CoroutineScope(Dispatchers.Main),
started = SharingStarted.WhileSubscribed(),
replay = Int.MAX_VALUE
)
val tagWrite = async(Dispatchers.IO) { tagCache.write(merge(cacheSongs, sharedExtractorSongs)) }
replay = Int.MAX_VALUE)
val tagWrite =
async(Dispatchers.IO) { tagCache.write(merge(cacheSongs, sharedExtractorSongs)) }
val rawPlaylists = async(Dispatchers.IO) { userLibraryFactory.query() }
val deviceLibrary = deviceLibraryFactory.create(merge(cacheSongs, sharedExtractorSongs), {}, separators, nameFactory)
val userLibrary = userLibraryFactory.create(rawPlaylists.await(), deviceLibrary, nameFactory)
val deviceLibrary =
deviceLibraryFactory.create(
merge(cacheSongs, sharedExtractorSongs), {}, separators, nameFactory)
val userLibrary =
userLibraryFactory.create(rawPlaylists.await(), deviceLibrary, nameFactory)
tagWrite.await()
LibraryResult(deviceLibrary, userLibrary)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2024 Auxio Project
* StackModule.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.stack
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface StackModule {
@Binds fun indexer(impl: IndexerImpl): Indexer
}

View file

@ -40,8 +40,7 @@ class TagDatabaseModule {
@Singleton
@Provides
fun database(@ApplicationContext context: Context) =
Room.databaseBuilder(
context.applicationContext, TagDatabase::class.java, "music_cache.db")
Room.databaseBuilder(context.applicationContext, TagDatabase::class.java, "music_cache.db")
.fallbackToDestructiveMigration()
.build()

View file

@ -1,20 +1,37 @@
/*
* Copyright (c) 2024 Auxio Project
* TagCache.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.stack.cache
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.transform
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.stack.fs.DeviceFile
import org.oxycblt.auxio.music.stack.extractor.TagResult
import javax.inject.Inject
import org.oxycblt.auxio.music.stack.fs.DeviceFile
interface TagCache {
fun read(files: Flow<DeviceFile>): Flow<TagResult>
suspend fun write(rawSongs: Flow<RawSong>)
}
class TagCacheImpl @Inject constructor(
private val tagDao: TagDao
) : TagCache {
class TagCacheImpl @Inject constructor(private val tagDao: TagDao) : TagCache {
override fun read(files: Flow<DeviceFile>) =
files.transform<DeviceFile, TagResult> { file ->
val tags = tagDao.selectTags(file.uri.toString(), file.lastModified)
@ -28,8 +45,6 @@ class TagCacheImpl @Inject constructor(
}
override suspend fun write(rawSongs: Flow<RawSong>) {
rawSongs.collect { rawSong ->
tagDao.updateTags(Tags.fromRaw(rawSong))
}
rawSongs.collect { rawSong -> tagDao.updateTags(Tags.fromRaw(rawSong)) }
}
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* CacheDatabase.kt is part of Auxio.
* TagDatabase.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
@ -43,16 +43,15 @@ interface TagDao {
@Query("SELECT * FROM Tags WHERE uri = :uri AND dateModified = :dateModified")
suspend fun selectTags(uri: String, dateModified: Long): Tags?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateTags(tags: Tags)
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun updateTags(tags: Tags)
}
@Entity
@TypeConverters(Tags.Converters::class)
data class Tags(
/**
* The Uri of the [RawSong]'s audio file, obtained from SAF.
* This should ideally be a black box only used for comparison.
* The Uri of the [RawSong]'s audio file, obtained from SAF. This should ideally be a black box
* only used for comparison.
*/
@PrimaryKey val uri: String,
/** The latest date the [RawSong]'s audio file was modified, as a unix epoch timestamp. */
@ -77,8 +76,6 @@ data class Tags(
var subtitle: String? = null,
/** @see RawSong.date */
var date: Date? = null,
/** @see RawSong.coverPerceptualHash */
var coverPerceptualHash: String? = null,
/** @see RawSong.albumMusicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RawSong.albumName */
@ -117,8 +114,6 @@ data class Tags(
rawSong.subtitle = subtitle
rawSong.date = date
rawSong.coverPerceptualHash = coverPerceptualHash
rawSong.albumMusicBrainzId = albumMusicBrainzId
rawSong.albumName = albumName
rawSong.albumSortName = albumSortName
@ -163,7 +158,6 @@ data class Tags(
disc = rawSong.disc,
subtitle = rawSong.subtitle,
date = rawSong.date,
coverPerceptualHash = rawSong.coverPerceptualHash,
albumMusicBrainzId = rawSong.albumMusicBrainzId,
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
albumSortName = rawSong.albumSortName,

View file

@ -1,3 +1,21 @@
/*
* Copyright (c) 2024 Auxio Project
* ExoPlayerTagExtractor.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.stack.extractor
import android.os.HandlerThread
@ -5,17 +23,18 @@ import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray
import java.util.concurrent.Future
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flow
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.stack.fs.DeviceFile
import java.util.concurrent.Future
import javax.inject.Inject
import timber.log.Timber as L
interface TagResult {
class Hit(val rawSong: RawSong) : TagResult
class Miss(val file: DeviceFile) : TagResult
}
@ -23,27 +42,23 @@ interface ExoPlayerTagExtractor {
fun process(deviceFiles: Flow<DeviceFile>): Flow<TagResult>
}
class ExoPlayerTagExtractorImpl @Inject constructor(
class ExoPlayerTagExtractorImpl
@Inject
constructor(
private val mediaSourceFactory: MediaSource.Factory,
private val tagInterpreter2: TagInterpreter2,
private val tagInterpreter2: TagInterpreter,
) : ExoPlayerTagExtractor {
override fun process(deviceFiles: Flow<DeviceFile>) = flow {
val threadPool = ThreadPool(8, Handler(this))
deviceFiles.collect { file ->
threadPool.enqueue(file)
}
deviceFiles.collect { file -> threadPool.enqueue(file) }
threadPool.empty()
}
private inner class Handler(
private val collector: FlowCollector<TagResult>
) : ThreadPool.Handler<DeviceFile, TrackGroupArray> {
private inner class Handler(private val collector: FlowCollector<TagResult>) :
ThreadPool.Handler<DeviceFile, TrackGroupArray> {
override suspend fun produce(thread: HandlerThread, input: DeviceFile) =
MetadataRetriever.retrieveMetadata(
mediaSourceFactory,
MediaItem.fromUri(input.uri),
thread
)
mediaSourceFactory, MediaItem.fromUri(input.uri), thread)
override suspend fun consume(input: DeviceFile, output: TrackGroupArray) {
if (output.isEmpty) {
@ -76,10 +91,7 @@ class ExoPlayerTagExtractorImpl @Inject constructor(
private class ThreadPool<I, O>(size: Int, private val handler: Handler<I, O>) {
private val slots =
Array<Slot<I, O>>(size) {
Slot(
thread = HandlerThread("Auxio:ThreadPool:$it"),
task = null
)
Slot(thread = HandlerThread("Auxio:ThreadPool:$it"), task = null)
}
suspend fun enqueue(input: I) {
@ -114,25 +126,19 @@ private class ThreadPool<I, O>(size: Int, private val handler: Handler<I, O>) {
// In-practice this should never block, as all clients
// check if the future is done before calling this function.
// If you don't maintain that invariant, this will explode.
@Suppress("BlockingMethodInNonBlockingContext")
handler.consume(input, future.get())
@Suppress("BlockingMethodInNonBlockingContext") handler.consume(input, future.get())
} catch (e: Exception) {
L.e("Failed to complete task for $input, ${e.stackTraceToString()}")
}
}
private data class Slot<I, O>(
val thread: HandlerThread,
var task: Task<I, O>?
)
private data class Slot<I, O>(val thread: HandlerThread, var task: Task<I, O>?)
private data class Task<I, O>(
val input: I,
val future: Future<O>
)
private data class Task<I, O>(val input: I, val future: Future<O>)
interface Handler<I, O> {
suspend fun produce(thread: HandlerThread, input: I): Future<O>
suspend fun consume(input: I, output: O)
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 Auxio Project
* ExtractorModule.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.stack.extractor
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface MetadataModule {
@Binds fun tagInterpreter(interpreter: TagInterpreterImpl): TagInterpreter
@Binds fun exoPlayerTagExtractor(extractor: ExoPlayerTagExtractorImpl): ExoPlayerTagExtractor
}

View file

@ -21,7 +21,6 @@ package org.oxycblt.auxio.music.stack.extractor
import androidx.core.text.isDigitsOnly
import androidx.media3.exoplayer.MetadataRetriever
import javax.inject.Inject
import org.oxycblt.auxio.image.extractor.CoverExtractor
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.util.nonZeroOrNull
@ -32,7 +31,7 @@ import org.oxycblt.auxio.util.nonZeroOrNull
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface TagInterpreter2 {
interface TagInterpreter {
/**
* Poll to see if this worker is done processing.
*
@ -41,8 +40,7 @@ interface TagInterpreter2 {
fun interpretOn(textTags: TextTags, rawSong: RawSong)
}
class TagInterpreter2Impl @Inject constructor(private val coverExtractor: CoverExtractor) :
TagInterpreter2 {
class TagInterpreterImpl @Inject constructor() : TagInterpreter {
override fun interpretOn(textTags: TextTags, rawSong: RawSong) {
populateWithId3v2(rawSong, textTags.id3v2)
populateWithVorbis(rawSong, textTags.vorbis)
@ -198,15 +196,13 @@ class TagInterpreter2Impl @Inject constructor(private val coverExtractor: CoverE
// Track.
parseVorbisPositionField(
comments["tracknumber"]?.first(),
(comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first()
)
(comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first())
?.let { rawSong.track = it }
// Disc and it's subtitle name.
parseVorbisPositionField(
comments["discnumber"]?.first(),
(comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first()
)
(comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first())
?.let { rawSong.disc = it }
comments["discsubtitle"]?.let { rawSong.subtitle = it.first() }

View file

@ -37,8 +37,12 @@ interface DeviceFiles {
}
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceFilesImpl @Inject constructor(@ApplicationContext private val context: Context) :
DeviceFiles {
class DeviceFilesImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
private val volumeManager: VolumeManager
) : DeviceFiles {
private val contentResolver = context.contentResolverSafe
override fun explore(uris: Flow<Uri>): Flow<DeviceFile> =
@ -49,6 +53,9 @@ class DeviceFilesImpl @Inject constructor(@ApplicationContext private val contex
uri: Uri,
relativePath: Components
): Flow<DeviceFile> = flow {
// TODO: Temporary to maintain path api parity
// Figure out what we actually want to do to paths now in saf world.
val external = volumeManager.getInternalVolume()
contentResolver.useQuery(uri, PROJECTION) { cursor ->
val childUriIndex =
cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
@ -77,7 +84,7 @@ class DeviceFilesImpl @Inject constructor(@ApplicationContext private val contex
// rather than just being a glorified async.
val lastModified = cursor.getLong(lastModifiedIndex)
val size = cursor.getLong(sizeIndex)
emit(DeviceFile(childUri, mimeType, path, size, lastModified))
emit(DeviceFile(childUri, mimeType, Path(external, path), size, lastModified))
}
}
// Hypothetically, we could just emitAll as we recurse into a new directory,
@ -96,9 +103,14 @@ class DeviceFilesImpl @Inject constructor(@ApplicationContext private val contex
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED
)
DocumentsContract.Document.COLUMN_LAST_MODIFIED)
}
}
data class DeviceFile(val uri: Uri, val mimeType: String, val path: Components, val size: Long, val lastModified: Long)
data class DeviceFile(
val uri: Uri,
val mimeType: String,
val path: Path,
val size: Long,
val lastModified: Long
)

View file

@ -51,4 +51,6 @@ class FsModule {
interface FsBindsModule {
@Binds
fun documentPathFactory(documentTreePathFactory: DocumentPathFactoryImpl): DocumentPathFactory
@Binds fun deviceFiles(deviceFilesImpl: DeviceFilesImpl): DeviceFiles
}