Replaces in-memory album store with H2 DB for faster subsequent startups, and future features
This commit is contained in:
parent
9ff2de36df
commit
139d40f1e0
@ -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")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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<String, AlbumMetadata> = linkedMapOf()
|
||||
}
|
||||
interface AlbumRepository : JpaRepository<AlbumRecord, String>
|
||||
|
||||
@Entity
|
||||
@Table(name = "album")
|
||||
data class AlbumRecord(
|
||||
@Id
|
||||
val id: String,
|
||||
val albumTitle: String,
|
||||
val albumFolder: String,
|
||||
val albumArtist: String,
|
||||
val albumYear: Int
|
||||
)
|
||||
@ -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<String, AlbumMetadata> {
|
||||
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<File>) {
|
||||
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<File>) {
|
||||
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<File>) {
|
||||
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<Pair<File, Mp3File>> {
|
||||
return compareBy({ it.second.id3v2Tag.albumArtist }, {it.second.id3v2Tag.album}, {it.second.id3v2Tag.year})
|
||||
}
|
||||
|
||||
fun convertAlbumListToMapForCache(list: List<Pair<String, AlbumMetadata>>): java.util.LinkedHashMap<String, AlbumMetadata> {
|
||||
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/"
|
||||
}
|
||||
|
||||
@ -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<AlbumMetadata> {
|
||||
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 }
|
||||
|
||||
@ -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") }
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user