Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ class Settings(private val symphony: Symphony) {
"last_used_artists_vertical_grid_columns",
ResponsiveGridColumns.DEFAULT_VERTICAL_COLUMNS,
)
val lastUsedArtistAlbumsSortBy = EnumEntry(
"last_used_artist_albums_sort_by",
enumEntries<AlbumRepository.SortBy>(),
AlbumRepository.SortBy.YEAR,
)
val lastUsedAlbumArtistsSortBy = EnumEntry(
"last_used_album_artists_sort_by",
enumEntries<AlbumArtistRepository.SortBy>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.github.zyrouge.symphony.services.database

import io.github.zyrouge.symphony.Symphony
import io.github.zyrouge.symphony.services.database.store.ArtworkCacheStore
import io.github.zyrouge.symphony.services.database.store.DirectoryArtworkCacheStore
import io.github.zyrouge.symphony.services.database.store.LyricsCacheStore

class Database(symphony: Symphony) {
Expand All @@ -10,6 +11,7 @@ class Database(symphony: Symphony) {

val artworkCache = ArtworkCacheStore(symphony)
val lyricsCache = LyricsCacheStore(symphony)
val directoryArtworkCache =DirectoryArtworkCacheStore(symphony)
val songCache get() = cache.songs()
val playlists get() = persistent.playlists()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package io.github.zyrouge.symphony.services.database.store

import android.net.Uri
import android.util.Log
import io.github.zyrouge.symphony.Symphony
import io.github.zyrouge.symphony.services.database.adapters.FileTreeDatabaseAdapter
import java.nio.file.Paths
import java.security.MessageDigest
import java.math.BigInteger

// Helper function for MD5 Hashing (can be private to this file or class)
private fun String.toMd5(): String {
val md = MessageDigest.getInstance("MD5")
val bigInt = BigInteger(1, md.digest(this.toByteArray(Charsets.UTF_8)))
return String.format("%032x", bigInt)
}

class DirectoryArtworkCacheStore(symphony: Symphony) {
private val adapter = FileTreeDatabaseAdapter(
Paths
.get(symphony.applicationContext.dataDir.absolutePath, "directory_artwork_cache")
.toFile()
)
private val logTag = "DirArtworkCache"

fun insert(directoryPathKey: String, imageUri: Uri) {
if (directoryPathKey.isBlank()) {
Log.w(logTag, "Attempted to insert blank directoryPathKey.")
return
}
val hashedFilename = directoryPathKey.toMd5()
val keyFile = adapter.get(hashedFilename)

if (!keyFile.exists()) {
try {
// Store original path on the first line, URI on the second
val content = "$directoryPathKey\n${imageUri.toString()}"
keyFile.writeText(content)
} catch (e: Exception) {
Log.e(logTag, "Error writing to cache. Original key: '$directoryPathKey', Hashed key: '$hashedFilename'", e)
}
}
}

fun get(directoryPathKey: String): Uri? {
if (directoryPathKey.isBlank()) {
Log.w(logTag, "Attempted to get blank directoryPathKey.")
return null
}
val hashedFilename = directoryPathKey.toMd5()
val keyFile = adapter.get(hashedFilename)

if (keyFile.exists() && keyFile.isFile) {
try {
val lines = keyFile.readLines()
if (lines.size >= 2) {
val uriString = lines[1]
if (uriString.isNotBlank()) {
return Uri.parse(uriString)
} else {
Log.w(logTag, "URI string in cache file is blank. Original key: '$directoryPathKey', Hashed key: '$hashedFilename'")
}
} else {
Log.w(logTag, "Cache file does not have enough lines. Original key: '$directoryPathKey', Hashed key: '$hashedFilename'")
}
} catch (e: Exception) {
Log.e(logTag, "Error reading from cache. Original key: '$directoryPathKey', Hashed key: '$hashedFilename'", e)
}
}
return null
}

// Returns a Set of ORIGINAL directory path keys
fun keys(): Set<String> {
val originalKeys = mutableSetOf<String>()
val hashedFilenames = adapter.list() // Gets List<String> of filenames (hashes)

for (hashedFilename in hashedFilenames) {
val keyFile = adapter.get(hashedFilename)
if (keyFile.exists() && keyFile.isFile) {
try {
val lines = keyFile.readLines()
if (lines.isNotEmpty()) {
val originalPath = lines[0]
if (originalPath.isNotBlank()) {
originalKeys.add(originalPath)
} else {
Log.w(logTag, "Original path in cache file is blank. Hashed key: '$hashedFilename'")
}
} else {
Log.w(logTag, "Cache file is empty. Hashed key: '$hashedFilename'")
}
} catch (e: Exception) {
Log.e(logTag, "Error reading original key from cache file. Hashed key: '$hashedFilename'", e)
}
}
}
return originalKeys
}

fun delete(directoryPathKeys: Collection<String>) {
directoryPathKeys.forEach { originalPath ->
if (originalPath.isNotBlank()) {
val hashedFilename = originalPath.toMd5()
val keyFile = adapter.get(hashedFilename)
if (keyFile.exists()) {
if (!keyFile.delete()) {
Log.w(logTag, "Failed to delete cache file. Original key: '$originalPath', Hashed key: '$hashedFilename'")
}
}
} else {
Log.w(logTag, "Attempted to delete blank directoryPathKey from collection.")
}
}
}

fun clear() {
adapter.clear() // Deletes all files (hashed entries) in the cache directory
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ class Groove(private val symphony: Symphony) : Symphony.Hooks {
}.join()
}

private suspend fun fetchFromCache() {
coroutineScope.launch {
awaitAll(
async { exposer.loadFromCache() },
)
}.join()
}

private suspend fun reset() {
coroutineScope.launch {
awaitAll(
Expand Down Expand Up @@ -83,7 +91,7 @@ class Groove(private val symphony: Symphony) : Symphony.Hooks {

override fun onSymphonyReady() {
coroutineScope.launch {
fetch()
fetchFromCache()
readyDeferred.complete(true)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class MediaExposer(private val symphony: Symphony) {
val songCacheUnused: ConcurrentSet<String>,
val artworkCacheUnused: ConcurrentSet<String>,
val lyricsCacheUnused: ConcurrentSet<String>,
val directoryArtworkCacheUnused: ConcurrentSet<String>,
val filter: MediaFilter,
val songParseOptions: Song.ParseOptions,
) {
Expand All @@ -46,6 +47,7 @@ class MediaExposer(private val symphony: Symphony) {
val songCacheUnused = concurrentSetOf(songCache.map { it.value.id })
val artworkCacheUnused = concurrentSetOf(symphony.database.artworkCache.all())
val lyricsCacheUnused = concurrentSetOf(symphony.database.lyricsCache.keys())
val directoryArtworkCacheUnused = concurrentSetOf(symphony.database.directoryArtworkCache.keys())
val filter = MediaFilter(
symphony.settings.songsFilterPattern.value,
symphony.settings.blacklistFolders.value.toSortedSet(),
Expand All @@ -56,6 +58,7 @@ class MediaExposer(private val symphony: Symphony) {
songCacheUnused = songCacheUnused,
artworkCacheUnused = artworkCacheUnused,
lyricsCacheUnused = lyricsCacheUnused,
directoryArtworkCacheUnused = directoryArtworkCacheUnused,
filter = filter,
songParseOptions = Song.ParseOptions.create(symphony),
)
Expand Down Expand Up @@ -107,13 +110,34 @@ class MediaExposer(private val symphony: Symphony) {
Logger.error("MediaExposer", "scan media tree failed", err)
}
}
suspend fun loadFromCache() {
emitUpdate(true)
try {
uris.clear()
explorer = SimpleFileSystem.Folder()

val cachedSongs = symphony.database.songCache.entriesPathMapped().values.toList()

if (cachedSongs.isEmpty()) {
Logger.warn("MediaExposer", "No songs found in cache to load.")
} else {
emitSongs(cachedSongs)
}
} catch (err: Exception) {
Logger.error("MediaExposer", "loadFromCache failed", err)
}
emitUpdate(false)
emitFinish()
}
private suspend fun scanMediaFile(cycle: ScanCycle, path: SimplePath, file: DocumentFileX) {
try {
when {
//this is for lyrics files
path.extension == "lrc" -> scanLrcFile(cycle, path, file)
//this is for playlist files
file.mimeType == MIMETYPE_M3U -> scanM3UFile(cycle, path, file)
file.mimeType.startsWith("audio/") -> scanAudioFile(cycle, path, file)
file.mimeType.startsWith("image/") -> scanImageFile(cycle, path, file)
}
} catch (err: Exception) {
Logger.error("MediaExposer", "scan media file failed", err)
Expand Down Expand Up @@ -172,6 +196,22 @@ class MediaExposer(private val symphony: Symphony) {
explorer.addChildFile(path)
}

private fun scanImageFile(
cycle: ScanCycle,
path: SimplePath,
file: DocumentFileX,
) {
val pathString = path.pathString
uris[pathString] = file.uri
explorer.addChildFile(path)

val parentPath = path.parent?.pathString ?: return
if (symphony.database.directoryArtworkCache.get(parentPath) == null) {
symphony.database.directoryArtworkCache.insert(parentPath, file.uri)
}
cycle.directoryArtworkCacheUnused.remove(parentPath)
}

private suspend fun trimCache(cycle: ScanCycle) {
try {
symphony.database.songCache.delete(cycle.songCacheUnused)
Expand All @@ -190,13 +230,19 @@ class MediaExposer(private val symphony: Symphony) {
} catch (err: Exception) {
Logger.warn("MediaExposer", "trim lyrics cache failed", err)
}
try {
symphony.database.directoryArtworkCache.delete(cycle.directoryArtworkCacheUnused)
} catch (err: Exception) {
Logger.warn("MediaExposer", "trim directory artwork cache failed", err)
}
}

suspend fun reset() {
emitUpdate(true)
uris.clear()
explorer = SimpleFileSystem.Folder()
symphony.database.songCache.clear()
symphony.database.directoryArtworkCache.clear()
emitUpdate(false)
}

Expand All @@ -208,6 +254,14 @@ class MediaExposer(private val symphony: Symphony) {
symphony.groove.song.onSong(song)
}

private fun emitSongs(songs: List<Song>) {
// symphony.groove.albumArtist.rebuildFromSongs(songs) // Skipped as per request
symphony.groove.album.rebuildFromSongs(songs)
symphony.groove.artist.rebuildFromSongs(songs)
symphony.groove.song.setSongs(songs)
symphony.groove.genre.rebuildFromSongs(songs)
}

private fun emitFinish() {
symphony.groove.playlist.onScanFinish()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import io.github.zyrouge.symphony.utils.ImagePreserver
import io.github.zyrouge.symphony.utils.Logger
import io.github.zyrouge.symphony.utils.SimplePath
import me.zyrouge.symphony.metaphony.AudioMetadataParser
import java.io.File
import java.io.FileOutputStream
import java.math.RoundingMode
import java.time.LocalDate
Expand Down Expand Up @@ -127,7 +128,13 @@ data class Song(
?.use { AudioMetadataParser.parse(file.name, it.detachFd()) }
?: return null
val id = symphony.groove.song.idGenerator.next()
//START cover art logic
val coverFile = metadata.pictures.firstOrNull()?.let {
val cacheKey = "${metadata.album}_${metadata.artists.hashCode()}_${metadata.date.hashCode()}"
if (symphony.database.artworkCache.get(cacheKey).exists()){
cacheKey
}

val extension = when (it.mimeType) {
"image/jpg", "image/jpeg" -> "jpg"
"image/png" -> "png"
Expand All @@ -143,14 +150,14 @@ data class Song(
return@let name
}
val bitmap = BitmapFactory.decodeByteArray(it.data, 0, it.data.size)
val name = "$id.jpg"
FileOutputStream(symphony.database.artworkCache.get(name)).use { writer ->
FileOutputStream(symphony.database.artworkCache.get(cacheKey)).use { writer ->
ImagePreserver
.resize(bitmap, quality)
.compress(Bitmap.CompressFormat.JPEG, 100, writer)
}
name
cacheKey
}
// END cover art logic
metadata.lyrics?.let {
symphony.database.lyricsCache.put(id, it)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,48 @@ class AlbumRepository(private val symphony: Symphony) {
}
}

internal fun rebuildFromSongs(songs: List<Song>) {
val newAlbumIds = mutableSetOf<String>()
songs.forEach { song ->
val albumId = getIdFromSong(song) ?: return@forEach
songIdsCache.compute(albumId) { _, value ->
value?.apply { add(song.id) } ?: concurrentSetOf(song.id)
}
cache.compute(albumId) { _, value ->
value?.apply {
artists.addAll(song.artists)
song.year?.let {
startYear = startYear?.let { old -> min(old, it) } ?: it
endYear = endYear?.let { old -> max(old, it) } ?: it
}
numberOfTracks++
duration += song.duration.milliseconds
} ?: run {
newAlbumIds.add(albumId)
Album(
id = albumId,
name = song.album!!,
artists = mutableSetOf<String>().apply {
// ensure that album artists are first
addAll(song.albumArtists)
addAll(song.artists)
},
startYear = song.year,
endYear = song.year,
numberOfTracks = 1,
duration = song.duration.milliseconds,
)
}
}
}
if (newAlbumIds.isNotEmpty()) {
_all.update {
it + newAlbumIds
}
}
emitCount()
}

fun reset() {
cache.clear()
songIdsCache.clear()
Expand Down
Loading