mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 00:30:02 +00:00
Refactor library scanning, enable multithreading using virtual threads
This commit is contained in:
@@ -53,7 +53,7 @@ dependencies {
|
|||||||
|
|
||||||
// Persistence & I/O
|
// Persistence & I/O
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||||
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.15")
|
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17")
|
||||||
implementation("commons-io:commons-io:2.18.0")
|
implementation("commons-io:commons-io:2.18.0")
|
||||||
|
|
||||||
// SSO
|
// SSO
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.grimsi.gameyfin.games
|
||||||
|
|
||||||
|
import de.grimsi.gameyfin.games.entities.Company
|
||||||
|
import de.grimsi.gameyfin.games.repositories.CompanyRepository
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class CompanyService(
|
||||||
|
private val companyRepository: CompanyRepository
|
||||||
|
) {
|
||||||
|
fun createOrGet(company: Company): Company {
|
||||||
|
companyRepository.findByNameAndType(company.name, company.type)?.let { return it }
|
||||||
|
|
||||||
|
val company = Company(name = company.name, type = company.type)
|
||||||
|
return companyRepository.save(company)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,9 @@ import de.grimsi.gameyfin.core.plugins.management.PluginManagementService
|
|||||||
import de.grimsi.gameyfin.games.dto.GameDto
|
import de.grimsi.gameyfin.games.dto.GameDto
|
||||||
import de.grimsi.gameyfin.games.dto.GameMetadataDto
|
import de.grimsi.gameyfin.games.dto.GameMetadataDto
|
||||||
import de.grimsi.gameyfin.games.entities.*
|
import de.grimsi.gameyfin.games.entities.*
|
||||||
import de.grimsi.gameyfin.games.repositories.CompanyRepository
|
|
||||||
import de.grimsi.gameyfin.games.repositories.GameRepository
|
import de.grimsi.gameyfin.games.repositories.GameRepository
|
||||||
import de.grimsi.gameyfin.games.repositories.ImageContentStore
|
import de.grimsi.gameyfin.libraries.Library
|
||||||
import de.grimsi.gameyfin.games.repositories.ImageRepository
|
import de.grimsi.gameyfin.media.ImageService
|
||||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
|
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
|
||||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
@@ -22,8 +21,7 @@ import org.apache.commons.io.FilenameUtils
|
|||||||
import org.pf4j.PluginManager
|
import org.pf4j.PluginManager
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.net.URI
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.net.URLConnection
|
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -31,11 +29,12 @@ class GameService(
|
|||||||
private val pluginManager: PluginManager,
|
private val pluginManager: PluginManager,
|
||||||
private val pluginManagementService: PluginManagementService,
|
private val pluginManagementService: PluginManagementService,
|
||||||
private val gameRepository: GameRepository,
|
private val gameRepository: GameRepository,
|
||||||
private val companyRepository: CompanyRepository,
|
private val companyService: CompanyService,
|
||||||
private val imageRepository: ImageRepository,
|
private val imageService: ImageService
|
||||||
private val imageContentStore: ImageContentStore
|
|
||||||
) {
|
) {
|
||||||
private val log = KotlinLogging.logger {}
|
companion object {
|
||||||
|
private val log = KotlinLogging.logger {}
|
||||||
|
}
|
||||||
|
|
||||||
private val metadataPlugins: List<GameMetadataProvider>
|
private val metadataPlugins: List<GameMetadataProvider>
|
||||||
get() = pluginManager.getExtensions(GameMetadataProvider::class.java)
|
get() = pluginManager.getExtensions(GameMetadataProvider::class.java)
|
||||||
@@ -45,7 +44,21 @@ class GameService(
|
|||||||
return gameRepository.save(game)
|
return gameRepository.save(game)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createFromFile(path: Path): Game? {
|
@Transactional
|
||||||
|
fun create(games: Collection<Game>): Collection<Game> {
|
||||||
|
val gamesToBePersisted = games.filter { it.id == null }
|
||||||
|
|
||||||
|
gamesToBePersisted.forEach { game ->
|
||||||
|
game.publishers = game.publishers.map { companyService.createOrGet(it) }.toSet()
|
||||||
|
game.developers = game.developers.map { companyService.createOrGet(it) }.toSet()
|
||||||
|
game
|
||||||
|
}
|
||||||
|
|
||||||
|
return gameRepository.saveAll(gamesToBePersisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun matchFromFile(path: Path, library: Library): Game? {
|
||||||
val query = FilenameUtils.removeExtension(path.fileName.toString())
|
val query = FilenameUtils.removeExtension(path.fileName.toString())
|
||||||
|
|
||||||
// Step 0: Query all metadata plugins for metadata on the provided game title
|
// Step 0: Query all metadata plugins for metadata on the provided game title
|
||||||
@@ -67,10 +80,10 @@ class GameService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Merge results into a single Game entity
|
// Step 4: Merge results into a single Game entity
|
||||||
val mergedGame = mergeResults(sortedResults, path)
|
val mergedGame = mergeResults(sortedResults, path, library)
|
||||||
|
|
||||||
// Step 5: Save the new game
|
// Step 5: Save the new game
|
||||||
return createOrUpdate(mergedGame)
|
return mergedGame
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAllGames(): Collection<GameDto> {
|
fun getAllGames(): Collection<GameDto> {
|
||||||
@@ -123,7 +136,7 @@ class GameService(
|
|||||||
val availableTitles = results.map { it.value.title }
|
val availableTitles = results.map { it.value.title }
|
||||||
val bestMatchingTitle = FuzzySearch.extractOne(originalQuery, availableTitles).string
|
val bestMatchingTitle = FuzzySearch.extractOne(originalQuery, availableTitles).string
|
||||||
|
|
||||||
log.info { "Best matching title: '$bestMatchingTitle' for '$originalQuery' determined from $availableTitles" }
|
log.debug { "Best matching title: '$bestMatchingTitle' for '$originalQuery' determined from $availableTitles" }
|
||||||
|
|
||||||
return results.filter { it.value.title.alphaNumeric() == bestMatchingTitle.alphaNumeric() }
|
return results.filter { it.value.title.alphaNumeric() == bestMatchingTitle.alphaNumeric() }
|
||||||
}
|
}
|
||||||
@@ -133,8 +146,12 @@ class GameService(
|
|||||||
* The merging is done by taking the first non-null value for each field
|
* The merging is done by taking the first non-null value for each field
|
||||||
* The plugin with the highest possible priority is used as the source for each field
|
* The plugin with the highest possible priority is used as the source for each field
|
||||||
*/
|
*/
|
||||||
private fun mergeResults(results: List<Map.Entry<GameMetadataProvider, GameMetadata?>>, path: Path): Game {
|
private fun mergeResults(
|
||||||
val mergedGame = Game(path = path.toString())
|
results: List<Map.Entry<GameMetadataProvider, GameMetadata?>>,
|
||||||
|
path: Path,
|
||||||
|
library: Library
|
||||||
|
): Game {
|
||||||
|
val mergedGame = Game(path = path.toString(), library = library)
|
||||||
val metadataMap = mutableMapOf<String, FieldMetadata>()
|
val metadataMap = mutableMapOf<String, FieldMetadata>()
|
||||||
val originalIdsMap = mutableMapOf<PluginManagementEntry, String>()
|
val originalIdsMap = mutableMapOf<PluginManagementEntry, String>()
|
||||||
|
|
||||||
@@ -163,7 +180,7 @@ class GameService(
|
|||||||
}
|
}
|
||||||
metadata.coverUrl?.let { coverUrl ->
|
metadata.coverUrl?.let { coverUrl ->
|
||||||
if (!metadataMap.containsKey("coverImage")) {
|
if (!metadataMap.containsKey("coverImage")) {
|
||||||
mergedGame.coverImage = downloadAndPersist(coverUrl, ImageType.COVER)
|
mergedGame.coverImage = Image(originalUrl = coverUrl.toURL(), type = ImageType.COVER)
|
||||||
metadataMap["coverImage"] = FieldMetadata(sourcePlugin)
|
metadataMap["coverImage"] = FieldMetadata(sourcePlugin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,14 +205,14 @@ class GameService(
|
|||||||
metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy ->
|
metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy ->
|
||||||
if (!metadataMap.containsKey("publishers")) {
|
if (!metadataMap.containsKey("publishers")) {
|
||||||
mergedGame.publishers =
|
mergedGame.publishers =
|
||||||
publishedBy.map { name -> toEntity(name, CompanyType.PUBLISHER) }.toSet()
|
publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }.toSet()
|
||||||
metadataMap["publishers"] = FieldMetadata(sourcePlugin)
|
metadataMap["publishers"] = FieldMetadata(sourcePlugin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy ->
|
metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy ->
|
||||||
if (!metadataMap.containsKey("developers")) {
|
if (!metadataMap.containsKey("developers")) {
|
||||||
mergedGame.developers =
|
mergedGame.developers =
|
||||||
developedBy.map { name -> toEntity(name, CompanyType.DEVELOPER) }.toSet()
|
developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }.toSet()
|
||||||
metadataMap["developers"] = FieldMetadata(sourcePlugin)
|
metadataMap["developers"] = FieldMetadata(sourcePlugin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,9 +248,9 @@ class GameService(
|
|||||||
}
|
}
|
||||||
metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls ->
|
metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls ->
|
||||||
if (!metadataMap.containsKey("images")) {
|
if (!metadataMap.containsKey("images")) {
|
||||||
mergedGame.images =
|
mergedGame.images = runBlocking {
|
||||||
screenshotUrls.map { url -> downloadAndPersist(url, ImageType.SCREENSHOT) }.toSet()
|
screenshotUrls.map { Image(originalUrl = it.toURL(), type = ImageType.SCREENSHOT) }.toSet()
|
||||||
metadataMap["images"] = FieldMetadata(sourcePlugin)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadata.videoUrls?.takeIf { it.isNotEmpty() }?.let { videoUrls ->
|
metadata.videoUrls?.takeIf { it.isNotEmpty() }?.let { videoUrls ->
|
||||||
@@ -247,30 +264,34 @@ class GameService(
|
|||||||
|
|
||||||
mergedGame.metadata = metadataMap
|
mergedGame.metadata = metadataMap
|
||||||
mergedGame.originalIds = originalIdsMap
|
mergedGame.originalIds = originalIdsMap
|
||||||
|
|
||||||
return mergedGame
|
return mergedGame
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toDto(game: Game): GameDto {
|
fun toDto(game: Game): GameDto {
|
||||||
val gameId = game.id ?: throw IllegalArgumentException("Game ID is null")
|
val gameId = game.id ?: throw IllegalArgumentException("Game ID is null")
|
||||||
|
val gameLibraryId = game.library.id ?: throw IllegalArgumentException("Game library ID is null")
|
||||||
|
val gameTitle = game.title ?: throw IllegalArgumentException("Game title is null")
|
||||||
|
|
||||||
return GameDto(
|
return GameDto(
|
||||||
id = gameId,
|
id = gameId,
|
||||||
title = game.title!!,
|
libraryId = gameLibraryId,
|
||||||
|
title = gameTitle,
|
||||||
coverId = game.coverImage?.id,
|
coverId = game.coverImage?.id,
|
||||||
comment = game.comment,
|
comment = game.comment,
|
||||||
summary = game.summary,
|
summary = game.summary,
|
||||||
release = game.release,
|
release = game.release,
|
||||||
userRating = game.userRating,
|
userRating = game.userRating,
|
||||||
criticRating = game.criticRating,
|
criticRating = game.criticRating,
|
||||||
publishers = game.publishers?.map { it.name },
|
publishers = game.publishers.map { it.name },
|
||||||
developers = game.developers?.map { it.name },
|
developers = game.developers.map { it.name },
|
||||||
genres = game.genres?.map { it.name },
|
genres = game.genres.map { it.name },
|
||||||
themes = game.themes?.map { it.name },
|
themes = game.themes.map { it.name },
|
||||||
keywords = game.keywords?.toList(),
|
keywords = game.keywords.toList(),
|
||||||
features = game.features?.map { it.name },
|
features = game.features.map { it.name },
|
||||||
perspectives = game.perspectives?.map { it.name },
|
perspectives = game.perspectives?.map { it.name },
|
||||||
imageIds = game.images?.mapNotNull { it.id },
|
imageIds = game.images.mapNotNull { it.id },
|
||||||
videoUrls = game.videoUrls?.map { it.toString() },
|
videoUrls = game.videoUrls.map { it.toString() },
|
||||||
path = game.path,
|
path = game.path,
|
||||||
metadata = toDto(game.metadata),
|
metadata = toDto(game.metadata),
|
||||||
originalIds = game.originalIds.mapKeys { it.key.pluginId }
|
originalIds = game.originalIds.mapKeys { it.key.pluginId }
|
||||||
@@ -287,22 +308,4 @@ class GameService(
|
|||||||
lastUpdated = metadata.lastUpdated
|
lastUpdated = metadata.lastUpdated
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toEntity(companyName: String, companyType: CompanyType): Company {
|
|
||||||
companyRepository.findByNameAndType(companyName, companyType)?.let { return it }
|
|
||||||
val company = Company(name = companyName, type = companyType)
|
|
||||||
return companyRepository.save(company)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun downloadAndPersist(imageUrl: URI, type: ImageType): Image {
|
|
||||||
val parsedUrl = imageUrl.toURL()
|
|
||||||
imageRepository.findByOriginalUrl(parsedUrl)?.let { return it }
|
|
||||||
|
|
||||||
val image = Image(originalUrl = parsedUrl, type = type)
|
|
||||||
parsedUrl.openStream().use { input ->
|
|
||||||
image.mimeType = URLConnection.guessContentTypeFromName(parsedUrl.file)
|
|
||||||
imageContentStore.setContent(image, input)
|
|
||||||
}
|
|
||||||
return imageRepository.save(image)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import java.time.Instant
|
|||||||
|
|
||||||
class GameDto(
|
class GameDto(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
|
val libraryId: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
val coverId: Long?,
|
val coverId: Long?,
|
||||||
val comment: String?,
|
val comment: String?,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.grimsi.gameyfin.games.entities
|
package de.grimsi.gameyfin.games.entities
|
||||||
|
|
||||||
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
|
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
|
||||||
|
import de.grimsi.gameyfin.libraries.Library
|
||||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameFeature
|
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameFeature
|
||||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.Genre
|
import de.grimsi.gameyfin.pluginapi.gamemetadata.Genre
|
||||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.PlayerPerspective
|
import de.grimsi.gameyfin.pluginapi.gamemetadata.PlayerPerspective
|
||||||
@@ -15,6 +16,10 @@ class Game(
|
|||||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
var id: Long? = null,
|
var id: Long? = null,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "library_id")
|
||||||
|
val library: Library,
|
||||||
|
|
||||||
var title: String? = null,
|
var title: String? = null,
|
||||||
|
|
||||||
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
|
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
|
||||||
@@ -34,32 +39,32 @@ class Game(
|
|||||||
|
|
||||||
var criticRating: Int? = null,
|
var criticRating: Int? = null,
|
||||||
|
|
||||||
@ManyToMany
|
@ManyToMany(cascade = [CascadeType.ALL])
|
||||||
var publishers: Set<Company>? = null,
|
var publishers: Set<Company> = emptySet(),
|
||||||
|
|
||||||
@ManyToMany
|
@ManyToMany(cascade = [CascadeType.ALL])
|
||||||
var developers: Set<Company>? = null,
|
var developers: Set<Company> = emptySet(),
|
||||||
|
|
||||||
@ElementCollection(targetClass = Genre::class)
|
@ElementCollection(targetClass = Genre::class)
|
||||||
var genres: Set<Genre>? = null,
|
var genres: Set<Genre> = emptySet(),
|
||||||
|
|
||||||
@ElementCollection(targetClass = Theme::class)
|
@ElementCollection(targetClass = Theme::class)
|
||||||
var themes: Set<Theme>? = null,
|
var themes: Set<Theme> = emptySet(),
|
||||||
|
|
||||||
@ElementCollection
|
@ElementCollection
|
||||||
var keywords: Set<String>? = null,
|
var keywords: Set<String> = emptySet(),
|
||||||
|
|
||||||
@ElementCollection(targetClass = GameFeature::class)
|
@ElementCollection(targetClass = GameFeature::class)
|
||||||
var features: Set<GameFeature>? = null,
|
var features: Set<GameFeature> = emptySet(),
|
||||||
|
|
||||||
@ElementCollection(targetClass = PlayerPerspective::class)
|
@ElementCollection(targetClass = PlayerPerspective::class)
|
||||||
var perspectives: Set<PlayerPerspective>? = null,
|
var perspectives: Set<PlayerPerspective>? = null,
|
||||||
|
|
||||||
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
|
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
|
||||||
var images: Set<Image>? = null,
|
var images: Set<Image> = emptySet(),
|
||||||
|
|
||||||
@ElementCollection
|
@ElementCollection
|
||||||
var videoUrls: Set<URI>? = null,
|
var videoUrls: Set<URI> = emptySet(),
|
||||||
|
|
||||||
@Column(unique = true)
|
@Column(unique = true)
|
||||||
val path: String,
|
val path: String,
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
package de.grimsi.gameyfin.libraries
|
package de.grimsi.gameyfin.libraries
|
||||||
|
|
||||||
import de.grimsi.gameyfin.core.filesystem.FilesystemService
|
import de.grimsi.gameyfin.core.filesystem.FilesystemService
|
||||||
|
import de.grimsi.gameyfin.games.CompanyService
|
||||||
import de.grimsi.gameyfin.games.GameService
|
import de.grimsi.gameyfin.games.GameService
|
||||||
import de.grimsi.gameyfin.games.dto.GameDto
|
import de.grimsi.gameyfin.games.dto.GameDto
|
||||||
import de.grimsi.gameyfin.games.entities.Game
|
import de.grimsi.gameyfin.games.entities.Game
|
||||||
import de.grimsi.gameyfin.libraries.dto.LibraryDto
|
import de.grimsi.gameyfin.libraries.dto.LibraryDto
|
||||||
import de.grimsi.gameyfin.libraries.dto.LibraryStatsDto
|
import de.grimsi.gameyfin.libraries.dto.LibraryStatsDto
|
||||||
import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto
|
import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto
|
||||||
|
import de.grimsi.gameyfin.media.ImageService
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.util.concurrent.Callable
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class LibraryService(
|
class LibraryService(
|
||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
|
private val filesystemService: FilesystemService,
|
||||||
private val gameService: GameService,
|
private val gameService: GameService,
|
||||||
private val filesystemService: FilesystemService
|
private val imageService: ImageService,
|
||||||
|
private val companyService: CompanyService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
companion object {
|
||||||
|
private val log = KotlinLogging.logger {}
|
||||||
|
private val executor = Executors.newVirtualThreadPerTaskExecutor()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates or updates a library in the repository.
|
* Creates or updates a library in the repository.
|
||||||
@@ -117,7 +128,6 @@ class LibraryService(
|
|||||||
return libraryRepository.save(library)
|
return libraryRepository.save(library)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper function to trigger a scan for a list of libraries.
|
* Wrapper function to trigger a scan for a list of libraries.
|
||||||
*/
|
*/
|
||||||
@@ -131,15 +141,70 @@ class LibraryService(
|
|||||||
*
|
*
|
||||||
* @param libraryDtos: List of LibraryDto objects to scan.
|
* @param libraryDtos: List of LibraryDto objects to scan.
|
||||||
*/
|
*/
|
||||||
suspend fun scan(libraryDtos: Collection<LibraryDto>?) {
|
@Transactional(timeout = Integer.MAX_VALUE)
|
||||||
|
fun scan(libraryDtos: Collection<LibraryDto>?) {
|
||||||
val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll()
|
val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll()
|
||||||
|
|
||||||
libraries.forEach { library ->
|
libraries.forEach { library ->
|
||||||
val gamePaths = filesystemService.scanLibraryForGamefiles(library)
|
val gamePaths = filesystemService.scanLibraryForGamefiles(library)
|
||||||
val newGames = gamePaths.mapNotNull {
|
val totalPaths = gamePaths.size
|
||||||
gameService.createFromFile(it)
|
val completedMetadata = AtomicInteger(0)
|
||||||
|
val completedImageDownload = AtomicInteger(0)
|
||||||
|
|
||||||
|
log.info { "Scanning library '${library.name}' with $totalPaths paths..." }
|
||||||
|
|
||||||
|
// 1. Fetch metadata for each game
|
||||||
|
val metadataTasks = gamePaths.map { path ->
|
||||||
|
Callable<Game?> {
|
||||||
|
try {
|
||||||
|
val game = gameService.matchFromFile(path, library)
|
||||||
|
val progress = completedMetadata.incrementAndGet()
|
||||||
|
log.info { "${progress}/${totalPaths} metadata matched" }
|
||||||
|
game
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error(e) { "Error processing game: ${e.message}" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addGamesToLibrary(newGames, library)
|
val matchedGames = executor.invokeAll(metadataTasks).mapNotNull { it.get() }
|
||||||
|
|
||||||
|
// 2. Download all images
|
||||||
|
val totalImages = matchedGames.count { it.coverImage != null } + matchedGames.sumOf { it.images.size }
|
||||||
|
|
||||||
|
val imageDownloadTasks = matchedGames.map { game ->
|
||||||
|
Callable<Game?> {
|
||||||
|
try {
|
||||||
|
game.coverImage?.let {
|
||||||
|
imageService.downloadIfNew(it)
|
||||||
|
completedImageDownload.andIncrement
|
||||||
|
}
|
||||||
|
|
||||||
|
game.images.map {
|
||||||
|
imageService.downloadIfNew(it)
|
||||||
|
completedImageDownload.andIncrement
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info { "${completedImageDownload}/${totalImages} images downloaded" }
|
||||||
|
|
||||||
|
game
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error(e) { "Error downloading images for game: ${e.message}" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val gamesWithImages = executor.invokeAll(imageDownloadTasks).mapNotNull { it.get() }
|
||||||
|
|
||||||
|
// 3. Persist entities
|
||||||
|
val persistedGames = gameService.create(gamesWithImages)
|
||||||
|
log.info { "${persistedGames.size}/${totalPaths} saved to database" }
|
||||||
|
|
||||||
|
// 4. Add games to library
|
||||||
|
addGamesToLibrary(persistedGames, library)
|
||||||
|
log.info { "Scan finished, matched ${persistedGames.size} new games" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import de.grimsi.gameyfin.games.entities.Image
|
|||||||
import de.grimsi.gameyfin.games.entities.ImageType
|
import de.grimsi.gameyfin.games.entities.ImageType
|
||||||
import de.grimsi.gameyfin.games.repositories.ImageContentStore
|
import de.grimsi.gameyfin.games.repositories.ImageContentStore
|
||||||
import de.grimsi.gameyfin.games.repositories.ImageRepository
|
import de.grimsi.gameyfin.games.repositories.ImageRepository
|
||||||
|
import org.apache.tika.Tika
|
||||||
|
import org.apache.tika.io.TikaInputStream
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
@@ -13,9 +15,19 @@ class ImageService(
|
|||||||
private val imageRepository: ImageRepository,
|
private val imageRepository: ImageRepository,
|
||||||
private val imageContentStore: ImageContentStore
|
private val imageContentStore: ImageContentStore
|
||||||
) {
|
) {
|
||||||
|
companion object {
|
||||||
|
private val tika = Tika();
|
||||||
|
}
|
||||||
|
|
||||||
fun getImage(id: Long): Image? {
|
fun downloadIfNew(image: Image) {
|
||||||
return imageRepository.findByIdOrNull(id)
|
if (image.originalUrl == null) throw IllegalArgumentException("Image must have an original URL")
|
||||||
|
|
||||||
|
imageRepository.findByOriginalUrl(image.originalUrl)?.let { return }
|
||||||
|
|
||||||
|
TikaInputStream.get { image.originalUrl.openStream() }.use { input ->
|
||||||
|
image.mimeType = tika.detect(input)
|
||||||
|
imageContentStore.setContent(image, input)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createFile(type: ImageType, content: InputStream, mimeType: String): Image {
|
fun createFile(type: ImageType, content: InputStream, mimeType: String): Image {
|
||||||
@@ -24,6 +36,10 @@ class ImageService(
|
|||||||
return imageContentStore.setContent(image, content)
|
return imageContentStore.setContent(image, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getImage(id: Long): Image? {
|
||||||
|
return imageRepository.findByIdOrNull(id)
|
||||||
|
}
|
||||||
|
|
||||||
fun getFileContent(id: Long): InputStream? {
|
fun getFileContent(id: Long): InputStream? {
|
||||||
val image = getImage(id) ?: return null
|
val image = getImage(id) ?: return null
|
||||||
return getFileContent(image)
|
return getFileContent(image)
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ package de.grimsi.gameyfin.users.entities
|
|||||||
import de.grimsi.gameyfin.core.Role
|
import de.grimsi.gameyfin.core.Role
|
||||||
import de.grimsi.gameyfin.core.security.EncryptionConverter
|
import de.grimsi.gameyfin.core.security.EncryptionConverter
|
||||||
import de.grimsi.gameyfin.games.entities.Image
|
import de.grimsi.gameyfin.games.entities.Image
|
||||||
import jakarta.annotation.Nullable
|
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
import jakarta.validation.constraints.NotNull
|
|
||||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||||
|
|
||||||
|
|
||||||
@@ -16,15 +14,14 @@ class User(
|
|||||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
var id: Long? = null,
|
var id: Long? = null,
|
||||||
|
|
||||||
@NotNull
|
|
||||||
@Column(unique = true)
|
@Column(unique = true)
|
||||||
|
@Convert(converter = EncryptionConverter::class)
|
||||||
var username: String,
|
var username: String,
|
||||||
|
|
||||||
var password: String? = null,
|
var password: String? = null,
|
||||||
|
|
||||||
var oidcProviderId: String? = null,
|
var oidcProviderId: String? = null,
|
||||||
|
|
||||||
@Nullable
|
|
||||||
@Column(unique = true)
|
@Column(unique = true)
|
||||||
@Convert(converter = EncryptionConverter::class)
|
@Convert(converter = EncryptionConverter::class)
|
||||||
var email: String,
|
var email: String,
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class Mapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun gameFeatures(game: proto.Game): Set<GameFeature> {
|
fun gameFeatures(game: proto.Game): Set<GameFeature> {
|
||||||
var gameFeatures = mutableSetOf<GameFeature>()
|
val gameFeatures = mutableSetOf<GameFeature>()
|
||||||
|
|
||||||
// Get LAN support from multiplayer modes
|
// Get LAN support from multiplayer modes
|
||||||
if (game.multiplayerModesList.any { it.lancoop }) gameFeatures.add(GameFeature.LOCAL_MULTIPLAYER)
|
if (game.multiplayerModesList.any { it.lancoop }) gameFeatures.add(GameFeature.LOCAL_MULTIPLAYER)
|
||||||
@@ -119,6 +119,7 @@ class Mapper {
|
|||||||
"single-player" -> gameFeatures.add(GameFeature.SINGLEPLAYER)
|
"single-player" -> gameFeatures.add(GameFeature.SINGLEPLAYER)
|
||||||
"multiplayer" -> gameFeatures.add(GameFeature.MULTIPLAYER)
|
"multiplayer" -> gameFeatures.add(GameFeature.MULTIPLAYER)
|
||||||
"massively-multiplayer-online-mmo" -> gameFeatures.add(GameFeature.MULTIPLAYER)
|
"massively-multiplayer-online-mmo" -> gameFeatures.add(GameFeature.MULTIPLAYER)
|
||||||
|
"battle-royale" -> gameFeatures.add(GameFeature.MULTIPLAYER)
|
||||||
"co-operative" -> gameFeatures.add(GameFeature.CO_OP)
|
"co-operative" -> gameFeatures.add(GameFeature.CO_OP)
|
||||||
"split-screen" -> gameFeatures.add(GameFeature.SPLITSCREEN)
|
"split-screen" -> gameFeatures.add(GameFeature.SPLITSCREEN)
|
||||||
else -> {
|
else -> {
|
||||||
|
|||||||
Reference in New Issue
Block a user