From 139d40f1e02d312c9e86fe6b873160d7a754e21c Mon Sep 17 00:00:00 2001 From: aarbit Date: Fri, 28 Jun 2024 16:05:48 -0500 Subject: [PATCH] Replaces in-memory album store with H2 DB for faster subsequent startups, and future features --- build.gradle.kts | 9 +- .../controller/CacheController.kt | 7 +- .../repository/AlbumRepository.kt | 20 ++- .../service/AlbumCacheService.kt | 126 ++++++++++-------- .../prettyplayerapi/service/AlbumService.kt | 22 +-- .../prettyplayerapi/service/MusicService.kt | 8 +- .../prettyplayerapi/service/StartupService.kt | 28 +++- src/main/resources/application.properties | 5 + 8 files changed, 140 insertions(+), 85 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index c5bfbef..2718b1c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,12 +3,13 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id("org.springframework.boot") version "3.2.5" id("io.spring.dependency-management") version "1.1.4" - kotlin("jvm") version "1.9.23" - kotlin("plugin.spring") version "1.9.23" + id("org.jetbrains.kotlin.plugin.jpa") version "2.0.0" + kotlin("jvm") version "2.0.0" + kotlin("plugin.spring") version "2.0.0" } group = "net.halfbinary" -version = "0.0.12-SNAPSHOT" +version = "0.0.13-SNAPSHOT" java { sourceCompatibility = JavaVersion.VERSION_21 @@ -33,6 +34,7 @@ val coroutinesVersion = "1.8.1" dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-data-jpa:3.3.0") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("org.jetbrains.kotlin:kotlin-reflect") @@ -45,6 +47,7 @@ dependencies { implementation("ch.qos.logback:logback-core:$logbackVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0") + runtimeOnly("com.h2database:h2") developmentOnly("org.springframework.boot:spring-boot-devtools") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/src/main/kotlin/net/halfbinary/prettyplayerapi/controller/CacheController.kt b/src/main/kotlin/net/halfbinary/prettyplayerapi/controller/CacheController.kt index 579ae3b..a01d522 100644 --- a/src/main/kotlin/net/halfbinary/prettyplayerapi/controller/CacheController.kt +++ b/src/main/kotlin/net/halfbinary/prettyplayerapi/controller/CacheController.kt @@ -12,11 +12,6 @@ import org.springframework.web.bind.annotation.RestController class CacheController(private val albumCacheService: AlbumCacheService) { @GetMapping suspend fun refreshAlbumCache(@RequestParam type: CacheType) { - when(type) { - CacheType.ART -> albumCacheService.doAlbumCacheOnlyNewArt() - CacheType.NEW -> albumCacheService.doAddNewAlbumCache() // TODO: This will make more sense with a DB backing - CacheType.ALL -> albumCacheService.refreshAlbumCache() - } - + albumCacheService.cacheAlbums(type) } } \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/prettyplayerapi/repository/AlbumRepository.kt b/src/main/kotlin/net/halfbinary/prettyplayerapi/repository/AlbumRepository.kt index f09cb85..2babc16 100644 --- a/src/main/kotlin/net/halfbinary/prettyplayerapi/repository/AlbumRepository.kt +++ b/src/main/kotlin/net/halfbinary/prettyplayerapi/repository/AlbumRepository.kt @@ -1,9 +1,21 @@ package net.halfbinary.prettyplayerapi.repository -import net.halfbinary.prettyplayerapi.model.AlbumMetadata +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @Repository -class AlbumRepository { - var albumCache: LinkedHashMap = linkedMapOf() -} \ No newline at end of file +interface AlbumRepository : JpaRepository + +@Entity +@Table(name = "album") +data class AlbumRecord( + @Id + val id: String, + val albumTitle: String, + val albumFolder: String, + val albumArtist: String, + val albumYear: Int +) \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/prettyplayerapi/service/AlbumCacheService.kt b/src/main/kotlin/net/halfbinary/prettyplayerapi/service/AlbumCacheService.kt index 6ea051e..591c727 100644 --- a/src/main/kotlin/net/halfbinary/prettyplayerapi/service/AlbumCacheService.kt +++ b/src/main/kotlin/net/halfbinary/prettyplayerapi/service/AlbumCacheService.kt @@ -4,9 +4,10 @@ import com.mpatric.mp3agic.Mp3File import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.* import net.halfbinary.prettyplayerapi.exception.EnvironmentVariableNotFoundException -import net.halfbinary.prettyplayerapi.model.AlbumMetadata import net.halfbinary.prettyplayerapi.model.CacheType +import net.halfbinary.prettyplayerapi.repository.AlbumRecord import net.halfbinary.prettyplayerapi.repository.AlbumRepository +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import java.awt.Image import java.awt.image.BufferedImage @@ -18,60 +19,81 @@ import kotlin.io.path.name private val logger = KotlinLogging.logger {} @Service -class AlbumCacheService(private val albumRepository: AlbumRepository, - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { - - - suspend fun doAlbumCacheOnlyNewArt() { - coroutineScope { - launch { cacheAlbums(CacheType.ART) } - } - } - suspend fun refreshAlbumCache() { - coroutineScope{ - launch { cacheAlbums(CacheType.ALL)} - } - } - - suspend fun doAddNewAlbumCache() { - coroutineScope { - launch { cacheAlbums(CacheType.NEW) } - } - } - - fun getAlbumCache(): LinkedHashMap { - return albumRepository.albumCache - } - +class AlbumCacheService( + private val albumRepository: AlbumRepository, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO +) { suspend fun cacheAlbums(type: CacheType = CacheType.ART) { logger.debug { "Starting the cache process with type ${type.name}" } createAlbumArtRootDir() - val list = getAlbumRootDir() + val fileList = getAlbumRootDir() .walkTopDown() .filter { it.isFile && it.extension == "mp3" && it.name.startsWith("01.") } .toList() - .map { Pair(it, Mp3File(it)) } - .sortedWith(getMusicComparator()) - .map { - val folder = it.first.parentFile.toPath() - val folderHash = folder.name.hashCode().toString() - val refresh = type == CacheType.ALL - coroutineScope { - launch {getAndWriteAlbumImageFile(it.first, folderHash, folder, refresh)} - } - Pair(folderHash, AlbumMetadata(it.second.id3v2Tag.album, it.first.parent, folderHash)) - } + when (type) { + CacheType.ART -> cacheArt(fileList) + CacheType.NEW -> cacheNew(fileList) + CacheType.ALL -> cacheAll(fileList) + } + } - albumRepository.albumCache = convertAlbumListToMapForCache(list) - //TODO: Write cache to file for later reading...or get a DB going? - logger.info { "Albums cached: ${albumRepository.albumCache.size}" } + suspend fun cacheAll(fileList: List) { + fileList.forEach { + val mp3File = Mp3File(it) + val folderPath = it.parentFile.toPath() + val folderHash = folderPath.name.hashCode().toString() + coroutineScope { + launch { getAndWriteAlbumImageFile(it, folderHash, folderPath) } + } + albumRepository.save( + AlbumRecord( + folderHash, + mp3File.id3v2Tag.album, + it.parent, + mp3File.id3v2Tag.albumArtist, + mp3File.id3v2Tag.year.toInt() + ) + ) + } + } + + suspend fun cacheNew(fileList: List) { + fileList.forEach { + val folderPath = it.parentFile.toPath() + val folderHash = folderPath.name.hashCode().toString() + if (albumRepository.findByIdOrNull(folderHash) == null) { + coroutineScope { + launch { getAndWriteAlbumImageFile(it, folderHash, folderPath) } + } + val mp3File = Mp3File(it) + albumRepository.save( + AlbumRecord( + folderHash, + mp3File.id3v2Tag.album, + it.parent, + mp3File.id3v2Tag.albumArtist, + mp3File.id3v2Tag.year.toInt() + ) + ) + } + } + } + + suspend fun cacheArt(fileList: List) { + fileList.forEach { + val folderPath = it.parentFile.toPath() + val folderHash = folderPath.name.hashCode().toString() + coroutineScope { + launch { getAndWriteAlbumImageFile(it, folderHash, folderPath) } + } + } } fun createAlbumArtRootDir() { val root = File(ALBUM_ART_ROOT_DIR) - if(!root.exists()) { + if (!root.exists()) { root.mkdirs() } logger.info { "Album Art Directory: $root" } @@ -83,20 +105,15 @@ class AlbumCacheService(private val albumRepository: AlbumRepository, return root } - fun getMusicComparator(): Comparator> { - return compareBy({ it.second.id3v2Tag.albumArtist }, {it.second.id3v2Tag.album}, {it.second.id3v2Tag.year}) - } - - fun convertAlbumListToMapForCache(list: List>): java.util.LinkedHashMap { - val array = list.toTypedArray() - return linkedMapOf(*array) - } - - suspend fun getAndWriteAlbumImageFile(file: File, folderHash: String, folder: Path, refresh: Boolean = false) { + suspend fun getAndWriteAlbumImageFile( + file: File, + folderHash: String, + folder: Path + ) { logger.info { "Checking album $folder with hash $folderHash" } val hashedFile = File("$ALBUM_ART_ROOT_DIR/$folderHash.jpg") - if(refresh || !hashedFile.exists()) { - logger.debug { "No image for $folder, making one..." } + if (!hashedFile.exists()) { + logger.debug { "Making image for $folder with hash $folderHash..." } val imageByteStream = Mp3File(file).id3v2Tag.albumImage.inputStream() val bufferedImage = withContext(ioDispatcher) { ImageIO.read(imageByteStream) @@ -116,6 +133,7 @@ class AlbumCacheService(private val albumRepository: AlbumRepository, return System.getenv(envName) ?: throw EnvironmentVariableNotFoundException(envName) } + val ALBUM_ROOT_DIR = getEnv("ALBUM_ROOT_DIR") //"q:\\CDs" val ALBUM_ART_ROOT_DIR = getEnv("ALBUM_ART_ROOT_DIR") //"src/main/resources/images/" } diff --git a/src/main/kotlin/net/halfbinary/prettyplayerapi/service/AlbumService.kt b/src/main/kotlin/net/halfbinary/prettyplayerapi/service/AlbumService.kt index 40ae801..47f4041 100644 --- a/src/main/kotlin/net/halfbinary/prettyplayerapi/service/AlbumService.kt +++ b/src/main/kotlin/net/halfbinary/prettyplayerapi/service/AlbumService.kt @@ -8,25 +8,29 @@ import net.halfbinary.prettyplayerapi.model.AlbumInfo import net.halfbinary.prettyplayerapi.model.AlbumMetadata import net.halfbinary.prettyplayerapi.model.AlbumTrackInfo import net.halfbinary.prettyplayerapi.model.TrackInfo +import net.halfbinary.prettyplayerapi.repository.AlbumRepository +import org.springframework.data.domain.Sort +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import java.io.File private val logger = KotlinLogging.logger {} @Service -class AlbumService(private val albumCacheService: AlbumCacheService) { +class AlbumService(private val albumRepository: AlbumRepository) { fun getAlbumList(): List { - return albumCacheService.getAlbumCache().map { (_,v) -> v } + return albumRepository + .findAll(Sort.by(Sort.Direction.ASC, "albumArtist", "albumYear", "albumTitle")) + .map { AlbumMetadata(it.albumTitle, it.albumFolder, it.id) } } fun getAlbumInfo(albumHash: String): AlbumInfo { - val albumInfo = albumCacheService.getAlbumCache()[albumHash] - val trackFileList = if(albumInfo != null) { - File(albumInfo.albumFolder).listFiles() + val albumRecordResponse = albumRepository.findByIdOrNull(albumHash) + val trackFileList = if (albumRecordResponse != null) { + File(albumRecordResponse.albumFolder).listFiles() ?.filter { it.name.endsWith(".mp3") } ?.sortedBy { it.name } - ?: throw AlbumFolderNotFoundException(albumInfo.albumFolder) - + ?: throw AlbumFolderNotFoundException(albumRecordResponse.albumFolder) } else { throw AlbumHashNotFoundException(albumHash) } @@ -41,8 +45,8 @@ class AlbumService(private val albumCacheService: AlbumCacheService) { } fun getTrackInfo(albumHash: String, trackNumber: Int): TrackInfo { - val albumInfo = albumCacheService.getAlbumCache()[albumHash] - val file = if(albumInfo != null) { + val albumInfo = albumRepository.findByIdOrNull(albumHash) + val file = if (albumInfo != null) { File(albumInfo.albumFolder).listFiles() ?.filter { it.name.endsWith(".mp3") } ?.sortedBy { it.name } diff --git a/src/main/kotlin/net/halfbinary/prettyplayerapi/service/MusicService.kt b/src/main/kotlin/net/halfbinary/prettyplayerapi/service/MusicService.kt index bd4df03..d89b7ea 100644 --- a/src/main/kotlin/net/halfbinary/prettyplayerapi/service/MusicService.kt +++ b/src/main/kotlin/net/halfbinary/prettyplayerapi/service/MusicService.kt @@ -2,14 +2,16 @@ package net.halfbinary.prettyplayerapi.service import net.halfbinary.prettyplayerapi.exception.AlbumFolderNotFoundException import net.halfbinary.prettyplayerapi.exception.AlbumHashNotFoundException +import net.halfbinary.prettyplayerapi.repository.AlbumRepository +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import java.io.File @Service -class MusicService(private val albumCacheService: AlbumCacheService) { +class MusicService(private val albumRepository: AlbumRepository) { fun getMusic(albumHash: String, trackNumber: Int): File { - val albumInfo = albumCacheService.getAlbumCache()[albumHash] - return if(albumInfo != null) { + val albumInfo = albumRepository.findByIdOrNull(albumHash) + return if (albumInfo != null) { File(albumInfo.albumFolder) .listFiles() ?.filter { it.name.endsWith(".mp3") } diff --git a/src/main/kotlin/net/halfbinary/prettyplayerapi/service/StartupService.kt b/src/main/kotlin/net/halfbinary/prettyplayerapi/service/StartupService.kt index d28c8ea..964e17b 100644 --- a/src/main/kotlin/net/halfbinary/prettyplayerapi/service/StartupService.kt +++ b/src/main/kotlin/net/halfbinary/prettyplayerapi/service/StartupService.kt @@ -1,23 +1,39 @@ package net.halfbinary.prettyplayerapi.service import io.github.oshai.kotlinlogging.KotlinLogging -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.* +import net.halfbinary.prettyplayerapi.model.CacheType +import net.halfbinary.prettyplayerapi.repository.AlbumRepository import org.springframework.boot.context.event.ApplicationStartedEvent import org.springframework.context.event.EventListener import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Component private val logger = KotlinLogging.logger {} + @Component -class StartupService(private val albumCacheService: AlbumCacheService) { +class StartupService( + private val albumRepository: AlbumRepository, + private val albumCacheService: AlbumCacheService, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO +) { @Async @EventListener(ApplicationStartedEvent::class) suspend fun onStartup() { - coroutineScope { - launch { - albumCacheService.doAlbumCacheOnlyNewArt() + if (withContext(ioDispatcher) { + albumRepository.count() + } == 0L) { + coroutineScope { + launch { + albumCacheService.cacheAlbums(CacheType.ALL) + } + } + } else { + coroutineScope { + launch { + albumCacheService.cacheAlbums(CacheType.NEW) + } } } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 37607f2..a261c1c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,6 @@ spring.application.name=prettyplayerapi +spring.datasource.url=jdbc:h2:file:./data/prettyplayerdata +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=pp +spring.datasource.password=pppass +spring.jpa.hibernate.ddl-auto=update