mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Release v2.2.0 (#741)
* Migrate to TailwindCSS v4 (#740) * Remove "material-tailwind" dependencies due to incompatibility of Stepper component with Tailwind v4 * Clean up Tailwind configs before upgrade * Run HeroUI upgrade * Run TailwindCSS upgrade * Replace PostCSS with Vite * Migrate custom styles to v4 * Remove tailwind.config.ts * Add heroui.ts Add tailwind vite plugin * Fix small UI color inconsistency * Fix theming system Rename purple theme to pink * Re-implement stepper in HeroUI * Fix RoleChip colors * Migrate icon names (#743) * Add migration script for phosphor-icons * Migrate icon usages * Update version to 2.2.0-preview * Revert accidental rename of menu title * Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#750) Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Improve library scanning (#749) * Update script to generate example libraries using SteamSpy API * Refactor library scanning process * Display Flyway startup log by default * Fix race condition in CompanyService * Fix race condition in ImageService Remove obsolete table * Fix SMTP config requiring an email as username (#755) * Disable length limit for config values (#757) * Deprecate DockerHub image (#759) * Remove deprecation warning from web UI * Reworked the CICD pipelines * Optimize container image (#761) * Fix Gradle warning * Rework Docker image to improve layer caching * Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#765) Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Multi platform support (#764) * Remove migrate-phosphor-icons.js since migration has been successful * Refactor GameMetadata into separate files * Add Platform enum * Implement platform support in Plugin API * Implement platform support in Steam Plugin * Implement platform support in IGDB Plugin * Add database migration for platform support * Implement platform support in GameService * Implement platform support on most endpoints and features, some are still missing Implemented platform support in all bundled plugins (although not finished polishing yet) * Implement platforms in UI * Make GameRequest platform aware * Return headerImages from IGDB * Implement proper PlatformMapper for IGDB plugin * Fix various smaller issues and inconsistencies * Replace placeholder in LibraryOverviewCard (#767) * Bump actions/download-artifact from 5 to 6 (#769) * Bump actions/upload-artifact from 4 to 5 (#770) * Multi platform support (#773) * Fix bug in Plugin API related to state loading/saving * Hide Flyway query logs by default * Extend migration script for multi platform tables * Plugins now store their data and state in ./plugindata * Add "plugindata" directory to entrypoint scripts * Improve download handling (#756) * Process download in background thread to avoid session timeout affecting it * Increase default session timeout to 24h * Use virtual thread pool for download task in background * Make KSP extensions.idx generation more robust * Implement download bandwidth limiter Implement SliderInput Refactor NumberInput * Implement download bandwidth throttling Implement real-time download monitoring * Improve UI for DownloadManagement Track more stats in SessionStats * Update Hilla Use React 19 * Implement real-time graph to track bandwidth usage Implement downloaded data sum over last day Small bug fixes Small refactorings * Update docker-compose.example.yml * Improve DownloadSessionCard (#784) * Fix unit on y-axis of download graph * Show game size and library in tooltip Make game chips interactive in DownloadSessionCard (leads to game page when clicked) Optimize graph settings * Migrate torrent plugin to libtorrent (#775) * Disable TorrentDownloadPlugin in Alpine based Docker image * Improve test coverage (#785) * Fix potential divide by zero bug * Add mockk dependency * Add tests for org.gameyfin.app.core.download * Add tests for Filesytem package Fix DownloadServiceTest * Fix FilesystemServiceTest * Add tests for "job" package * Upgrade Gradle wrapper Enable Gradle config cache * Added more tests * Added tests for the "security" package * Add tests for "game" package * Fix AsyncFileTailer not shutting down properly on Windows * Fix GameServiceTest * Added tests for "libraries" package * Added tests for "media" package * Fix warning in ImageService * Add tests fpr "messages" package Make sure transport is closed even in case an exception is thrown * Add tests for "platforms" package * Add tests for "requests" package * Moved "token" package to "core" package (from "shared") * Add tests for "token" package * Fix issue in RoleEnum.safeValueOf() throwing Exception * Fix potential issue in UserEndpoint.getUserInfo() when auth is null * Added tests for "user" package * Migrate package for "token" in FE * Publish test report in CI * Fix workflow permissions * Remove test because of timing issue in CI * Replaced "unmatched paths" with "ignored paths" (#791) * Use new "AutoComplete" component (#793) * Use ArrayInputAutocomplete in EditGameMetadataModal * Add test for getEnumPropertyValues --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -13,9 +13,11 @@ val keystorePasswordProperty = "gameyfin.keystorePassword"
|
||||
|
||||
val keystorePath: String = rootProject.file("certs/gameyfin.jks").absolutePath
|
||||
val keystoreAlias = "gameyfin-plugins"
|
||||
val keystorePassword: String = (findProperty(keystorePasswordProperty) as String?)
|
||||
?: System.getenv(keystorePasswordEnvironmentVariable)
|
||||
?: ""
|
||||
val keystorePasswordProvider = provider {
|
||||
(findProperty(keystorePasswordProperty) as String?)
|
||||
?: System.getenv(keystorePasswordEnvironmentVariable)
|
||||
?: ""
|
||||
}
|
||||
|
||||
subprojects {
|
||||
apply(plugin = "org.jetbrains.kotlin.jvm")
|
||||
@@ -23,6 +25,13 @@ subprojects {
|
||||
dependencies {
|
||||
compileOnly(kotlin("stdlib"))
|
||||
compileOnly(project(":plugin-api"))
|
||||
|
||||
// Test dependencies
|
||||
testImplementation(project(":plugin-api"))
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation(platform("org.junit:junit-bom:${rootProject.extra["junitVersion"]}"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
// Read the version from the MANIFEST.MF file in resources
|
||||
@@ -37,6 +46,9 @@ subprojects {
|
||||
isZip64 = true
|
||||
archiveBaseName.set(project.name)
|
||||
|
||||
// Ensure KSP runs before JAR is created
|
||||
dependsOn("kspKotlin")
|
||||
|
||||
manifest {
|
||||
from("./src/main/resources/MANIFEST.MF")
|
||||
}
|
||||
@@ -49,6 +61,9 @@ subprojects {
|
||||
from(sourceSets["main"].output.classesDirs)
|
||||
from(sourceSets["main"].resources)
|
||||
|
||||
// Include KSP-generated resources (extensions.idx)
|
||||
from(layout.buildDirectory.get().asFile.resolve("generated/ksp/main/resources"))
|
||||
|
||||
// Include logo file under META-INF/resources
|
||||
from("src/main/resources") {
|
||||
include("logo.*")
|
||||
@@ -75,6 +90,8 @@ subprojects {
|
||||
tasks.register<Exec>("signJar") {
|
||||
dependsOn(tasks.jar)
|
||||
|
||||
val keystorePassword = keystorePasswordProvider.get()
|
||||
|
||||
if ((findProperty("vaadin.productionMode") as String?) == "true" && keystorePassword.isEmpty()) {
|
||||
throw GradleException("Keystore password must be provided when vaadin.productionMode is true. Use -P$keystorePasswordProperty=your_password or set the $keystorePasswordEnvironmentVariable environment variable.")
|
||||
}
|
||||
@@ -82,7 +99,7 @@ subprojects {
|
||||
val jarFile = tasks.jar.get().archiveFile.get().asFile
|
||||
|
||||
// Only enable if password is present
|
||||
enabled = keystorePassword.isNotEmpty()
|
||||
onlyIf { keystorePassword.isNotEmpty() }
|
||||
|
||||
commandLine(
|
||||
"jarsigner",
|
||||
@@ -91,14 +108,14 @@ subprojects {
|
||||
jarFile.absolutePath,
|
||||
keystoreAlias
|
||||
)
|
||||
doFirst {
|
||||
if (keystorePassword.isEmpty()) {
|
||||
logger.lifecycle("Keystore password not provided, skipping JAR signing.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.build {
|
||||
dependsOn("signJar")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
dependsOn("copyDependencyClasses")
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -4,15 +4,15 @@ import org.gameyfin.plugins.download.direct.CompressionMode.*
|
||||
import java.util.zip.Deflater
|
||||
|
||||
enum class CompressionMode {
|
||||
NONE,
|
||||
FAST,
|
||||
BEST;
|
||||
None,
|
||||
Fast,
|
||||
Best;
|
||||
}
|
||||
|
||||
fun CompressionMode.deflaterLevel(): Int {
|
||||
return when (this) {
|
||||
NONE -> Deflater.NO_COMPRESSION
|
||||
FAST -> Deflater.BEST_SPEED
|
||||
BEST -> Deflater.BEST_COMPRESSION
|
||||
None -> Deflater.NO_COMPRESSION
|
||||
Fast -> Deflater.BEST_SPEED
|
||||
Best -> Deflater.BEST_COMPRESSION
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -38,10 +38,11 @@ class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(
|
||||
type = CompressionMode::class.java,
|
||||
label = "Compression mode",
|
||||
description = "Higher compression modes are more resource intensive, but save bandwidth",
|
||||
default = CompressionMode.NONE
|
||||
default = CompressionMode.None
|
||||
)
|
||||
)
|
||||
|
||||
@Suppress("Unused")
|
||||
@Extension(ordinal = 1)
|
||||
class DirectDownloadProvider : DownloadProvider {
|
||||
override fun download(path: Path): Download {
|
||||
@@ -116,4 +117,4 @@ class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(
|
||||
return pipeIn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Plugin-Version: 1.0.0
|
||||
Plugin-Version: 1.0.1
|
||||
Plugin-Class: org.gameyfin.plugins.download.direct.DirectDownloadPlugin
|
||||
Plugin-Id: org.gameyfin.plugins.download.direct
|
||||
Plugin-Name: Direct Download
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
val resilience4jVersion = "2.2.0"
|
||||
val resilience4jVersion = "2.3.0"
|
||||
|
||||
plugins {
|
||||
id("com.google.devtools.ksp")
|
||||
@@ -11,16 +11,10 @@ dependencies {
|
||||
implementation("io.github.husnjak:igdb-api-jvm:1.3.1")
|
||||
|
||||
// Resilience4j for rate limiting
|
||||
implementation("io.github.resilience4j:resilience4j-ratelimiter:${resilience4jVersion}") {
|
||||
exclude(group = "org.slf4j")
|
||||
}
|
||||
implementation("io.github.resilience4j:resilience4j-bulkhead:${resilience4jVersion}") {
|
||||
exclude(group = "org.slf4j")
|
||||
}
|
||||
implementation("io.github.resilience4j:resilience4j-all:${resilience4jVersion}") {
|
||||
exclude(group = "org.slf4j")
|
||||
}
|
||||
implementation("io.github.resilience4j:resilience4j-ratelimiter:${resilience4jVersion}")
|
||||
implementation("io.github.resilience4j:resilience4j-bulkhead:${resilience4jVersion}")
|
||||
implementation("io.github.resilience4j:resilience4j-all:${resilience4jVersion}")
|
||||
|
||||
// Fuzzy string matching
|
||||
implementation("me.xdrop:fuzzywuzzy:1.4.0")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult
|
||||
import org.gameyfin.pluginapi.core.wrapper.ConfigurableGameyfinPlugin
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameMetadata
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
||||
import org.gameyfin.pluginapi.gamemetadata.Platform
|
||||
import org.gameyfin.plugins.metadata.igdb.mapper.*
|
||||
import org.pf4j.Extension
|
||||
import org.pf4j.PluginWrapper
|
||||
import proto.Game
|
||||
@@ -88,6 +90,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
|
||||
log.debug("Authentication successful")
|
||||
}
|
||||
|
||||
@Suppress("Unused")
|
||||
@Extension(ordinal = 2)
|
||||
class IgdbMetadataProvider : GameMetadataProvider {
|
||||
|
||||
@@ -121,6 +124,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
|
||||
"game_modes.slug",
|
||||
"game_modes.name",
|
||||
"cover.image_id",
|
||||
"artworks.image_id",
|
||||
"screenshots.image_id",
|
||||
"videos.name",
|
||||
"videos.video_id",
|
||||
@@ -143,15 +147,26 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
|
||||
).joinToString(",")
|
||||
}
|
||||
|
||||
override fun fetchByTitle(gameTitle: String, maxResults: Int): List<GameMetadata> {
|
||||
// Note: Limit is intentionally set high because IGDBs ranking algorithm is not very good
|
||||
override val supportedPlatforms: Set<Platform>
|
||||
get() = Platform.entries.toSet()
|
||||
|
||||
override fun fetchByTitle(
|
||||
gameTitle: String,
|
||||
platformFilter: Set<Platform>,
|
||||
maxResults: Int
|
||||
): List<GameMetadata> {
|
||||
val searchByNameQuery = APICalypse()
|
||||
.fields(QUERY_FIELDS)
|
||||
// TODO: Change for multi-platform support
|
||||
.where("platforms.slug = \"win\"")
|
||||
// Note: Limit is intentionally set high because IGDBs ranking algorithm is not very good
|
||||
.limit(100)
|
||||
.search(gameTitle)
|
||||
|
||||
if (platformFilter.isNotEmpty()) {
|
||||
val platformFilterQuery = PlatformMapper.toIgdb(platformFilter)
|
||||
.joinToString(separator = "\", \"", prefix = "platforms.slug = (\"", postfix = "\")")
|
||||
searchByNameQuery.where(platformFilterQuery)
|
||||
}
|
||||
|
||||
// Use IGDBs search function to get a list of games that match the search query
|
||||
var games = queryIgdbGames(searchByNameQuery)
|
||||
|
||||
@@ -170,7 +185,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
|
||||
.sortedByDescending { bestMatchesMap[it.name] }
|
||||
.take(maxResults)
|
||||
|
||||
return games.map { toGameMetadata(it) }
|
||||
return games.map { toGameMetadata(it, platformFilter) }
|
||||
}
|
||||
|
||||
override fun fetchById(id: String): GameMetadata? {
|
||||
@@ -181,7 +196,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
|
||||
.where("slug = \"$id\"")
|
||||
|
||||
val game = queryIgdbGames(findBySlugQuery).firstOrNull()
|
||||
return game?.let { toGameMetadata(it) }
|
||||
return game?.let { toGameMetadata(it, null) }
|
||||
}
|
||||
|
||||
private fun queryIgdbGames(query: APICalypse): List<Game> {
|
||||
@@ -193,29 +208,35 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
|
||||
return decorated.get()
|
||||
}
|
||||
|
||||
private fun toGameMetadata(game: Game): GameMetadata {
|
||||
private fun toGameMetadata(game: Game, platformFilter: Set<Platform>?): GameMetadata {
|
||||
val supportedPlatforms = game.platformsList.map { PlatformMapper.toGameyfin(it.slug) }
|
||||
val filteredPlatforms = platformFilter?.let { filter -> supportedPlatforms.filter { it in filter } }
|
||||
?: supportedPlatforms
|
||||
|
||||
return GameMetadata(
|
||||
originalId = game.slug,
|
||||
title = game.name,
|
||||
platforms = filteredPlatforms.toSet(),
|
||||
description = game.summary,
|
||||
coverUrls = Mapper.cover(game.cover)?.let { listOf(it) },
|
||||
coverUrls = MediaMapper.cover(game.cover)?.let { listOf(it) }?.toSet(),
|
||||
headerUrls = game.artworksList.map { MediaMapper.header(it) }.toSet(),
|
||||
release = if (game.firstReleaseDate.seconds > 0) Instant.ofEpochSecond(game.firstReleaseDate.seconds) else null,
|
||||
userRating = game.rating.toInt(),
|
||||
criticRating = game.aggregatedRating.toInt(),
|
||||
developedBy = game.involvedCompaniesList.filter { it.developer }.map { it.company.name }.toSet(),
|
||||
publishedBy = game.involvedCompaniesList.filter { it.publisher }.map { it.company.name }.toSet(),
|
||||
genres = game.genresList.map { Mapper.genre(it) }.toSet(),
|
||||
themes = game.themesList.map { Mapper.theme(it) }.toSet(),
|
||||
genres = game.genresList.map { GenreMapper.genre(it) }.toSet(),
|
||||
themes = game.themesList.map { ThemeMapper.theme(it) }.toSet(),
|
||||
keywords = game.keywordsList.map { it.name }.toSet(),
|
||||
screenshotUrls = game.screenshotsList.map { Mapper.screenshot(it) }.toSet(),
|
||||
screenshotUrls = game.screenshotsList.map { MediaMapper.screenshot(it) }.toSet(),
|
||||
videoUrls = game.videosList
|
||||
// Lots of gameplay videos hosted on YouTube are blocked from viewing on external sites due to age ratings
|
||||
// Trailers usually are not affected so we filter for them
|
||||
// see https://support.google.com/youtube/answer/2802167
|
||||
.filter { it.name.equals("trailer", ignoreCase = true) }
|
||||
.map { Mapper.video(it) }.toSet(),
|
||||
features = Mapper.gameFeatures(game),
|
||||
perspectives = game.playerPerspectivesList.map { Mapper.playerPerspective(it) }.toSet()
|
||||
.map { MediaMapper.video(it) }.toSet(),
|
||||
features = GameFeatureMapper.gameFeatures(game),
|
||||
perspectives = game.playerPerspectivesList.map { PlayerPerspectiveMapper.playerPerspective(it) }.toSet()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package org.gameyfin.plugins.metadata.igdb
|
||||
|
||||
import com.api.igdb.utils.ImageSize
|
||||
import com.api.igdb.utils.ImageType
|
||||
import com.api.igdb.utils.imageBuilder
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameFeature
|
||||
import org.gameyfin.pluginapi.gamemetadata.Genre
|
||||
import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective
|
||||
import org.gameyfin.pluginapi.gamemetadata.Theme
|
||||
import org.slf4j.LoggerFactory
|
||||
import proto.Cover
|
||||
import proto.Game
|
||||
import proto.GameVideo
|
||||
import proto.Screenshot
|
||||
import java.net.URI
|
||||
|
||||
class Mapper {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(Mapper::class.java)
|
||||
|
||||
fun genre(genre: proto.Genre): Genre {
|
||||
return when (genre.slug) {
|
||||
"pinball" -> Genre.PINBALL
|
||||
"adventure" -> Genre.ADVENTURE
|
||||
"indie" -> Genre.INDIE
|
||||
"arcade" -> Genre.ARCADE
|
||||
"visual-novel" -> Genre.VISUAL_NOVEL
|
||||
"card-and-board-game" -> Genre.CARD_AND_BOARD_GAME
|
||||
"moba" -> Genre.MOBA
|
||||
"point-and-click" -> Genre.POINT_AND_CLICK
|
||||
"fighting" -> Genre.FIGHTING
|
||||
"shooter" -> Genre.SHOOTER
|
||||
"music" -> Genre.MUSIC
|
||||
"platform" -> Genre.PLATFORM
|
||||
"puzzle" -> Genre.PUZZLE
|
||||
"racing" -> Genre.RACING
|
||||
"real-time-strategy-rts" -> Genre.REAL_TIME_STRATEGY
|
||||
"role-playing-rpg" -> Genre.ROLE_PLAYING
|
||||
"simulator" -> Genre.SIMULATOR
|
||||
"sport" -> Genre.SPORT
|
||||
"strategy" -> Genre.STRATEGY
|
||||
"turn-based-strategy-tbs" -> Genre.TURN_BASED_STRATEGY
|
||||
"tactical" -> Genre.TACTICAL
|
||||
"hack-and-slash-beat-em-up" -> Genre.HACK_AND_SLASH_BEAT_EM_UP
|
||||
"quiz-trivia" -> Genre.QUIZ_TRIVIA
|
||||
else -> {
|
||||
log.warn("Unknown genre: {}", genre.slug)
|
||||
Genre.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun theme(theme: proto.Theme): Theme {
|
||||
return when (theme.slug) {
|
||||
"action" -> Theme.ACTION
|
||||
"fantasy" -> Theme.FANTASY
|
||||
"horror" -> Theme.HORROR
|
||||
"sci-fi" -> Theme.SCIENCE_FICTION
|
||||
"science-fiction" -> Theme.SCIENCE_FICTION
|
||||
"mystery" -> Theme.MYSTERY
|
||||
"thriller" -> Theme.THRILLER
|
||||
"survival" -> Theme.SURVIVAL
|
||||
"historical" -> Theme.HISTORICAL
|
||||
"stealth" -> Theme.STEALTH
|
||||
"comedy" -> Theme.COMEDY
|
||||
"business" -> Theme.BUSINESS
|
||||
"drama" -> Theme.DRAMA
|
||||
"non-fiction" -> Theme.NON_FICTION
|
||||
"sandbox" -> Theme.SANDBOX
|
||||
"educational" -> Theme.EDUCATIONAL
|
||||
"kids" -> Theme.KIDS
|
||||
"open-world" -> Theme.OPEN_WORLD
|
||||
"warfare" -> Theme.WARFARE
|
||||
"party" -> Theme.PARTY
|
||||
"4x-explore-expand-exploit-and-exterminate" -> Theme.FOUR_X
|
||||
"erotic" -> Theme.EROTIC
|
||||
"romance" -> Theme.ROMANCE
|
||||
else -> {
|
||||
log.warn("Unknown theme: {}", theme.slug)
|
||||
Theme.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun playerPerspective(perspective: proto.PlayerPerspective): PlayerPerspective {
|
||||
return when (perspective.slug) {
|
||||
"first-person" -> PlayerPerspective.FIRST_PERSON
|
||||
"third-person" -> PlayerPerspective.THIRD_PERSON
|
||||
"bird-view-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
|
||||
"bird-view-slash-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
|
||||
"side-view" -> PlayerPerspective.SIDE_VIEW
|
||||
"text" -> PlayerPerspective.TEXT
|
||||
"auditory" -> PlayerPerspective.AUDITORY
|
||||
"virtual-reality" -> PlayerPerspective.VIRTUAL_REALITY
|
||||
else -> {
|
||||
log.warn("Unknown player perspective: {}", perspective.slug)
|
||||
PlayerPerspective.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun screenshot(screenshot: Screenshot): URI {
|
||||
return URI(imageBuilder(screenshot.imageId, ImageSize.FHD, ImageType.PNG))
|
||||
}
|
||||
|
||||
fun cover(cover: Cover): URI? {
|
||||
if (cover.imageId.isEmpty()) return null
|
||||
return URI(imageBuilder(cover.imageId, ImageSize.COVER_BIG, ImageType.PNG))
|
||||
}
|
||||
|
||||
fun video(video: GameVideo): URI {
|
||||
return URI("https://www.youtube.com/watch?v=${video.videoId}")
|
||||
}
|
||||
|
||||
fun gameFeatures(game: Game): Set<GameFeature> {
|
||||
val gameFeatures = mutableSetOf<GameFeature>()
|
||||
|
||||
// Get LAN support from multiplayer modes
|
||||
if (game.multiplayerModesList.any { it.lancoop }) gameFeatures.add(GameFeature.LOCAL_MULTIPLAYER)
|
||||
|
||||
for (gameMode in game.gameModesList) {
|
||||
when (gameMode.slug) {
|
||||
"single-player" -> gameFeatures.add(GameFeature.SINGLEPLAYER)
|
||||
"multiplayer" -> 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)
|
||||
"split-screen" -> gameFeatures.add(GameFeature.SPLITSCREEN)
|
||||
else -> {
|
||||
log.warn("Unknown game mode: {}", gameMode.slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
return gameFeatures
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package org.gameyfin.plugins.metadata.igdb.mapper
|
||||
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameFeature
|
||||
import org.slf4j.LoggerFactory
|
||||
import proto.Game
|
||||
|
||||
class GameFeatureMapper {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
fun gameFeatures(game: Game): Set<GameFeature> {
|
||||
val gameFeatures = mutableSetOf<GameFeature>()
|
||||
|
||||
// Get LAN support from multiplayer modes
|
||||
if (game.multiplayerModesList.any { it.lancoop }) gameFeatures.add(GameFeature.LOCAL_MULTIPLAYER)
|
||||
|
||||
for (gameMode in game.gameModesList) {
|
||||
when (gameMode.slug) {
|
||||
"single-player" -> gameFeatures.add(GameFeature.SINGLEPLAYER)
|
||||
"multiplayer" -> 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)
|
||||
"split-screen" -> gameFeatures.add(GameFeature.SPLITSCREEN)
|
||||
else -> {
|
||||
log.warn("Unknown game mode: {}", gameMode.slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
return gameFeatures
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.gameyfin.plugins.metadata.igdb.mapper
|
||||
|
||||
import org.gameyfin.pluginapi.gamemetadata.Genre
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class GenreMapper {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
fun genre(genre: proto.Genre): Genre {
|
||||
return when (genre.slug) {
|
||||
"pinball" -> Genre.PINBALL
|
||||
"adventure" -> Genre.ADVENTURE
|
||||
"indie" -> Genre.INDIE
|
||||
"arcade" -> Genre.ARCADE
|
||||
"visual-novel" -> Genre.VISUAL_NOVEL
|
||||
"card-and-board-game" -> Genre.CARD_AND_BOARD_GAME
|
||||
"moba" -> Genre.MOBA
|
||||
"point-and-click" -> Genre.POINT_AND_CLICK
|
||||
"fighting" -> Genre.FIGHTING
|
||||
"shooter" -> Genre.SHOOTER
|
||||
"music" -> Genre.MUSIC
|
||||
"platform" -> Genre.PLATFORM
|
||||
"puzzle" -> Genre.PUZZLE
|
||||
"racing" -> Genre.RACING
|
||||
"real-time-strategy-rts" -> Genre.REAL_TIME_STRATEGY
|
||||
"role-playing-rpg" -> Genre.ROLE_PLAYING
|
||||
"simulator" -> Genre.SIMULATOR
|
||||
"sport" -> Genre.SPORT
|
||||
"strategy" -> Genre.STRATEGY
|
||||
"turn-based-strategy-tbs" -> Genre.TURN_BASED_STRATEGY
|
||||
"tactical" -> Genre.TACTICAL
|
||||
"hack-and-slash-beat-em-up" -> Genre.HACK_AND_SLASH_BEAT_EM_UP
|
||||
"quiz-trivia" -> Genre.QUIZ_TRIVIA
|
||||
else -> {
|
||||
log.warn("Unknown genre: {}", genre.slug)
|
||||
Genre.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.gameyfin.plugins.metadata.igdb.mapper
|
||||
|
||||
import com.api.igdb.utils.ImageSize
|
||||
import com.api.igdb.utils.ImageType
|
||||
import com.api.igdb.utils.imageBuilder
|
||||
import proto.Artwork
|
||||
import proto.Cover
|
||||
import proto.GameVideo
|
||||
import proto.Screenshot
|
||||
import java.net.URI
|
||||
|
||||
class MediaMapper {
|
||||
companion object {
|
||||
fun cover(cover: Cover): URI? {
|
||||
if (cover.imageId.isEmpty()) return null
|
||||
return URI(imageBuilder(cover.imageId, ImageSize.COVER_BIG, ImageType.PNG))
|
||||
}
|
||||
|
||||
fun header(header: Artwork): URI {
|
||||
return URI(imageBuilder(header.imageId, ImageSize.FHD, ImageType.PNG))
|
||||
}
|
||||
|
||||
fun screenshot(screenshot: Screenshot): URI {
|
||||
return URI(imageBuilder(screenshot.imageId, ImageSize.FHD, ImageType.PNG))
|
||||
}
|
||||
|
||||
fun video(video: GameVideo): URI {
|
||||
return URI("https://www.youtube.com/watch?v=${video.videoId}")
|
||||
}
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
package org.gameyfin.plugins.metadata.igdb.mapper
|
||||
|
||||
import org.gameyfin.pluginapi.gamemetadata.Platform
|
||||
|
||||
/**
|
||||
* Mapper for converting between IGDB platform slugs and Gameyfin Platform enums
|
||||
*/
|
||||
class PlatformMapper {
|
||||
companion object {
|
||||
/**
|
||||
* Map from IGDB platform slug to Gameyfin Platform enum
|
||||
*/
|
||||
private val igdbToGameyfinMap: Map<String, Platform> = mapOf(
|
||||
"linux" to Platform.LINUX,
|
||||
"n64" to Platform.NINTENDO_64,
|
||||
"wii" to Platform.WII,
|
||||
"win" to Platform.PC_MICROSOFT_WINDOWS,
|
||||
"ps" to Platform.PLAYSTATION,
|
||||
"ps2" to Platform.PLAYSTATION_2,
|
||||
"ps3" to Platform.PLAYSTATION_3,
|
||||
"xbox" to Platform.XBOX,
|
||||
"xbox360" to Platform.XBOX_360,
|
||||
"dos" to Platform.DOS,
|
||||
"mac" to Platform.MAC,
|
||||
"c64" to Platform.COMMODORE_C64_128_MAX,
|
||||
"amiga" to Platform.AMIGA,
|
||||
"nes" to Platform.NINTENDO_ENTERTAINMENT_SYSTEM,
|
||||
"snes" to Platform.SUPER_NINTENDO_ENTERTAINMENT_SYSTEM,
|
||||
"nds" to Platform.NINTENDO_DS,
|
||||
"ngc" to Platform.NINTENDO_GAMECUBE,
|
||||
"gbc" to Platform.GAME_BOY_COLOR,
|
||||
"dc" to Platform.DREAMCAST,
|
||||
"gba" to Platform.GAME_BOY_ADVANCE,
|
||||
"acpc" to Platform.AMSTRAD_CPC,
|
||||
"zxs" to Platform.ZX_SPECTRUM,
|
||||
"msx" to Platform.MSX,
|
||||
"genesis-slash-megadrive" to Platform.SEGA_MEGA_DRIVE_GENESIS,
|
||||
"sega32" to Platform.SEGA_32X,
|
||||
"saturn" to Platform.SEGA_SATURN,
|
||||
"gb" to Platform.GAME_BOY,
|
||||
"android" to Platform.ANDROID,
|
||||
"gamegear" to Platform.SEGA_GAME_GEAR,
|
||||
"3ds" to Platform.NINTENDO_3DS,
|
||||
"psp" to Platform.PLAYSTATION_PORTABLE,
|
||||
"ios" to Platform.IOS,
|
||||
"wiiu" to Platform.WII_U,
|
||||
"ngage" to Platform.N_GAGE,
|
||||
"zod" to Platform.TAPWAVE_ZODIAC,
|
||||
"psvita" to Platform.PLAYSTATION_VITA,
|
||||
"vc" to Platform.VIRTUAL_CONSOLE,
|
||||
"ps4--1" to Platform.PLAYSTATION_4,
|
||||
"xboxone" to Platform.XBOX_ONE,
|
||||
"3do" to Platform._3DO_INTERACTIVE_MULTIPLAYER,
|
||||
"fds" to Platform.FAMILY_COMPUTER_DISK_SYSTEM,
|
||||
"arcade" to Platform.ARCADE,
|
||||
"msx2" to Platform.MSX2,
|
||||
"mobile" to Platform.LEGACY_MOBILE_DEVICE,
|
||||
"wonderswan" to Platform.WONDERSWAN,
|
||||
"sfam" to Platform.SUPER_FAMICOM,
|
||||
"atari2600" to Platform.ATARI_2600,
|
||||
"atari7800" to Platform.ATARI_7800,
|
||||
"lynx" to Platform.ATARI_LYNX,
|
||||
"jaguar" to Platform.ATARI_JAGUAR,
|
||||
"atari-st" to Platform.ATARI_ST_STE,
|
||||
"sms" to Platform.SEGA_MASTER_SYSTEM_MARK_III,
|
||||
"atari8bit" to Platform.ATARI_8_BIT,
|
||||
"atari5200" to Platform.ATARI_5200,
|
||||
"intellivision" to Platform.INTELLIVISION,
|
||||
"colecovision" to Platform.COLECOVISION,
|
||||
"bbcmicro" to Platform.BBC_MICROCOMPUTER_SYSTEM,
|
||||
"vectrex" to Platform.VECTREX,
|
||||
"vic-20" to Platform.COMMODORE_VIC_20,
|
||||
"ouya" to Platform.OUYA,
|
||||
"blackberry" to Platform.BLACKBERRY_OS,
|
||||
"winphone" to Platform.WINDOWS_PHONE,
|
||||
"appleii" to Platform.APPLE_II,
|
||||
"x1" to Platform.SHARP_X1,
|
||||
"sega-cd" to Platform.SEGA_CD,
|
||||
"neogeomvs" to Platform.NEO_GEO_MVS,
|
||||
"neogeoaes" to Platform.NEO_GEO_AES,
|
||||
"browser" to Platform.WEB_BROWSER,
|
||||
"sg1000" to Platform.SG_1000,
|
||||
"donner30" to Platform.DONNER_MODEL_30,
|
||||
"turbografx16--1" to Platform.TURBOGRAFX_16_PC_ENGINE,
|
||||
"virtualboy" to Platform.VIRTUAL_BOY,
|
||||
"odyssey--1" to Platform.ODYSSEY,
|
||||
"microvision--1" to Platform.MICROVISION,
|
||||
"cpet" to Platform.COMMODORE_PET,
|
||||
"astrocade" to Platform.BALLY_ASTROCADE,
|
||||
"c16" to Platform.COMMODORE_16,
|
||||
"c-plus-4" to Platform.COMMODORE_PLUS_4,
|
||||
"pdp1" to Platform.PDP_1,
|
||||
"pdp10" to Platform.PDP_10,
|
||||
"pdp-8--1" to Platform.PDP_8,
|
||||
"gt40" to Platform.DEC_GT40,
|
||||
"famicom" to Platform.FAMILY_COMPUTER,
|
||||
"analogueelectronics" to Platform.ANALOGUE_ELECTRONICS,
|
||||
"nimrod" to Platform.FERRANTI_NIMROD_COMPUTER,
|
||||
"edsac--1" to Platform.EDSAC,
|
||||
"pdp-7--1" to Platform.PDP_7,
|
||||
"hp2100" to Platform.HP_2100,
|
||||
"hp3000" to Platform.HP_3000,
|
||||
"sdssigma7" to Platform.SDS_SIGMA_7,
|
||||
"call-a-computer" to Platform.CALL_A_COMPUTER_TIME_SHARED_MAINFRAME_COMPUTER_SYSTEM,
|
||||
"pdp11" to Platform.PDP_11,
|
||||
"cdccyber70" to Platform.CDC_CYBER_70,
|
||||
"plato--1" to Platform.PLATO,
|
||||
"imlac-pds1" to Platform.IMLAC_PDS_1,
|
||||
"microcomputer--1" to Platform.MICROCOMPUTER,
|
||||
"onlive" to Platform.ONLIVE_GAME_SYSTEM,
|
||||
"amiga-cd32" to Platform.AMIGA_CD32,
|
||||
"apple-iigs" to Platform.APPLE_IIGS,
|
||||
"acorn-archimedes" to Platform.ACORN_ARCHIMEDES,
|
||||
"philips-cdi" to Platform.PHILIPS_CD_I,
|
||||
"fm-towns" to Platform.FM_TOWNS,
|
||||
"neo-geo-pocket" to Platform.NEO_GEO_POCKET,
|
||||
"neo-geo-pocket-color" to Platform.NEO_GEO_POCKET_COLOR,
|
||||
"sharp-x68000" to Platform.SHARP_X68000,
|
||||
"nuon" to Platform.NUON,
|
||||
"wonderswan-color" to Platform.WONDERSWAN_COLOR,
|
||||
"swancrystal" to Platform.SWANCRYSTAL,
|
||||
"pc-8800-series" to Platform.PC_8800_SERIES,
|
||||
"trs-80" to Platform.TRS_80,
|
||||
"fairchild-channel-f" to Platform.FAIRCHILD_CHANNEL_F,
|
||||
"supergrafx" to Platform.PC_ENGINE_SUPERGRAFX,
|
||||
"ti-99" to Platform.TEXAS_INSTRUMENTS_TI_99,
|
||||
"switch" to Platform.NINTENDO_SWITCH,
|
||||
"super-nes-cd-rom-system" to Platform.SUPER_NES_CD_ROM_SYSTEM,
|
||||
"firetv" to Platform.AMAZON_FIRE_TV,
|
||||
"odyssey-2-slash-videopac-g7000" to Platform.ODYSSEY_2_VIDEOPAC_G7000,
|
||||
"acorn-electron" to Platform.ACORN_ELECTRON,
|
||||
"hyper-neo-geo-64" to Platform.HYPER_NEO_GEO_64,
|
||||
"neo-geo-cd" to Platform.NEO_GEO_CD,
|
||||
"new-3ds" to Platform.NEW_NINTENDO_3DS,
|
||||
"vc-4000" to Platform.VC_4000,
|
||||
"1292-advanced-programmable-video-system" to Platform._1292_ADVANCED_PROGRAMMABLE_VIDEO_SYSTEM,
|
||||
"ay-3-8500" to Platform.AY_3_8500,
|
||||
"ay-3-8610" to Platform.AY_3_8610,
|
||||
"pc-50x-family" to Platform.PC_50X_FAMILY,
|
||||
"ay-3-8760" to Platform.AY_3_8760,
|
||||
"ay-3-8710" to Platform.AY_3_8710,
|
||||
"ay-3-8603" to Platform.AY_3_8603,
|
||||
"ay-3-8605" to Platform.AY_3_8605,
|
||||
"ay-3-8606" to Platform.AY_3_8606,
|
||||
"ay-3-8607" to Platform.AY_3_8607,
|
||||
"pc-9800-series" to Platform.PC_9800_SERIES,
|
||||
"turbografx-16-slash-pc-engine-cd" to Platform.TURBOGRAFX_16_PC_ENGINE_CD,
|
||||
"trs-80-color-computer" to Platform.TRS_80_COLOR_COMPUTER,
|
||||
"fm-7" to Platform.FM_7,
|
||||
"dragon-32-slash-64" to Platform.DRAGON_32_64,
|
||||
"apcw" to Platform.AMSTRAD_PCW,
|
||||
"tatung-einstein" to Platform.TATUNG_EINSTEIN,
|
||||
"thomson-mo5" to Platform.THOMSON_MO5,
|
||||
"nec-pc-6000-series" to Platform.NEC_PC_6000_SERIES,
|
||||
"commodore-cdtv" to Platform.COMMODORE_CDTV,
|
||||
"nintendo-dsi" to Platform.NINTENDO_DSI,
|
||||
"windows-mixed-reality" to Platform.WINDOWS_MIXED_REALITY,
|
||||
"oculus-vr" to Platform.OCULUS_VR,
|
||||
"steam-vr" to Platform.STEAMVR,
|
||||
"daydream" to Platform.DAYDREAM,
|
||||
"psvr" to Platform.PLAYSTATION_VR,
|
||||
"pokemon-mini" to Platform.POKEMON_MINI,
|
||||
"ps5" to Platform.PLAYSTATION_5,
|
||||
"series-x-s" to Platform.XBOX_SERIES_X_S,
|
||||
"stadia" to Platform.GOOGLE_STADIA,
|
||||
"duplicate-stadia" to Platform.DUPLICATE_STADIA,
|
||||
"exidy-sorcerer" to Platform.EXIDY_SORCERER,
|
||||
"sol-20" to Platform.SOL_20,
|
||||
"dvd-player" to Platform.DVD_PLAYER,
|
||||
"blu-ray-player" to Platform.BLU_RAY_PLAYER,
|
||||
"zeebo" to Platform.ZEEBO,
|
||||
"pc-fx" to Platform.PC_FX,
|
||||
"satellaview" to Platform.SATELLAVIEW,
|
||||
"g-and-w" to Platform.GAME_AND_WATCH,
|
||||
"playdia" to Platform.PLAYDIA,
|
||||
"evercade" to Platform.EVERCADE,
|
||||
"sega-pico" to Platform.SEGA_PICO,
|
||||
"ooparts" to Platform.OOPARTS,
|
||||
"sinclair-zx81" to Platform.SINCLAIR_ZX81,
|
||||
"sharp-mz-2200" to Platform.SHARP_MZ_2200,
|
||||
"epoch-cassette-vision" to Platform.EPOCH_CASSETTE_VISION,
|
||||
"epoch-super-cassette-vision" to Platform.EPOCH_SUPER_CASSETTE_VISION,
|
||||
"plug-and-play" to Platform.PLUG_AND_PLAY,
|
||||
"gamate" to Platform.GAMATE,
|
||||
"game-dot-com" to Platform.GAME_COM,
|
||||
"casio-loopy" to Platform.CASIO_LOOPY,
|
||||
"playdate" to Platform.PLAYDATE,
|
||||
"intellivision-amico" to Platform.INTELLIVISION_AMICO,
|
||||
"oculus-quest" to Platform.OCULUS_QUEST,
|
||||
"oculus-rift" to Platform.OCULUS_RIFT,
|
||||
"meta-quest-2" to Platform.META_QUEST_2,
|
||||
"oculus-go" to Platform.OCULUS_GO,
|
||||
"gear-vr" to Platform.GEAR_VR,
|
||||
"airconsole" to Platform.AIRCONSOLE,
|
||||
"psvr2" to Platform.PLAYSTATION_VR2,
|
||||
"windows-mobile" to Platform.WINDOWS_MOBILE,
|
||||
"sinclair-ql" to Platform.SINCLAIR_QL,
|
||||
"hyperscan" to Platform.HYPERSCAN,
|
||||
"mega-duck-slash-cougar-boy" to Platform.MEGA_DUCK_COUGAR_BOY,
|
||||
"legacy-computer" to Platform.LEGACY_COMPUTER,
|
||||
"atari-jaguar-cd" to Platform.ATARI_JAGUAR_CD,
|
||||
"handheld" to Platform.HANDHELD_ELECTRONIC_LCD,
|
||||
"leapster" to Platform.LEAPSTER,
|
||||
"leapster-explorer-slash-leadpad-explorer" to Platform.LEAPSTER_EXPLORER_LEADPAD_EXPLORER,
|
||||
"leaptv" to Platform.LEAPTV,
|
||||
"watara-slash-quickshot-supervision" to Platform.WATARA_QUICKSHOT_SUPERVISION,
|
||||
"64dd" to Platform._64DD,
|
||||
"palm-os" to Platform.PALM_OS,
|
||||
"arduboy" to Platform.ARDUBOY,
|
||||
"vsmile" to Platform.V_SMILE,
|
||||
"visual-memory-unit-slash-visual-memory-system" to Platform.VISUAL_MEMORY_UNIT_VISUAL_MEMORY_SYSTEM,
|
||||
"pocketstation" to Platform.POCKETSTATION,
|
||||
"meta-quest-3" to Platform.META_QUEST_3,
|
||||
"visionos" to Platform.VISIONOS,
|
||||
"arcadia-2001" to Platform.ARCADIA_2001,
|
||||
"gizmondo" to Platform.GIZMONDO,
|
||||
"r-zone" to Platform.R_ZONE,
|
||||
"apple-pippin" to Platform.APPLE_PIPPIN,
|
||||
"panasonic-jungle" to Platform.PANASONIC_JUNGLE,
|
||||
"panasonic-m2" to Platform.PANASONIC_M2,
|
||||
"terebikko-slash-see-n-say-video-phone" to Platform.TEREBIKKO_SEE_N_SAY_VIDEO_PHONE,
|
||||
"super-acan" to Platform.SUPER_ACAN,
|
||||
"tomy-tutor-slash-pyuta-slash-grandstand-tutor" to Platform.TOMY_TUTOR_PYUTA_GRANDSTAND_TUTOR,
|
||||
"sega-cd-32x" to Platform.SEGA_CD_32X,
|
||||
"digiblast" to Platform.DIGIBLAST,
|
||||
"laseractive" to Platform.LASERACTIVE,
|
||||
"uzebox" to Platform.UZEBOX,
|
||||
"elektor-tv-games-computer" to Platform.ELEKTOR_TV_GAMES_COMPUTER,
|
||||
"gx4000" to Platform.AMSTRAD_GX4000,
|
||||
"advanced-pico-beena" to Platform.ADVANCED_PICO_BEENA,
|
||||
"switch-2" to Platform.NINTENDO_SWITCH_2,
|
||||
"polymega" to Platform.POLYMEGA,
|
||||
"e-reader-slash-card-e-reader" to Platform.E_READER_CARD_E_READER
|
||||
)
|
||||
|
||||
/**
|
||||
* Map from Gameyfin Platform enum to IGDB platform slug
|
||||
*/
|
||||
private val gameyfinToIgdbMap: Map<Platform, String> =
|
||||
igdbToGameyfinMap.entries.associateBy({ it.value }, { it.key })
|
||||
|
||||
/**
|
||||
* Convert an IGDB platform slug to a Gameyfin Platform enum
|
||||
* @param igdbPlatformSlug The IGDB platform slug
|
||||
* @return The corresponding Platform enum value, or null if not found
|
||||
*/
|
||||
fun toGameyfin(igdbPlatformSlug: String): Platform {
|
||||
val platform = igdbToGameyfinMap[igdbPlatformSlug]
|
||||
?: throw IllegalArgumentException("Could not map IGDB platform with slug '$igdbPlatformSlug' to Gameyfin Platform")
|
||||
return platform
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert multiple IGDB platform slugs to Gameyfin Platform enums
|
||||
* @param igdbPlatformSlugs Collection of IGDB platform slugs
|
||||
* @return Set of Platform enums (unmapped slugs are filtered out)
|
||||
*/
|
||||
fun toGameyfin(igdbPlatformSlugs: Collection<String>): Set<Platform> {
|
||||
return igdbPlatformSlugs.map { toGameyfin(it) }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Gameyfin Platform enum to an IGDB platform slug
|
||||
* @param platform The Platform enum value
|
||||
* @return The corresponding IGDB platform slug, or null if not found
|
||||
*/
|
||||
fun toIgdb(platform: Platform): String {
|
||||
val slug = gameyfinToIgdbMap[platform]
|
||||
?: throw IllegalArgumentException("Could not map Gameyfin Platform '${platform.displayName}' to IGDB platform slug")
|
||||
return slug
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert multiple Gameyfin Platform enums to IGDB platform slugs
|
||||
* @param platforms Collection of Platform enums
|
||||
* @return Set of IGDB platform slugs (unmapped platforms are filtered out)
|
||||
*/
|
||||
fun toIgdb(platforms: Collection<Platform>): Set<String> {
|
||||
return platforms.map { toIgdb(it) }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all IGDB platform slugs that have mappings
|
||||
* @return Set of all mapped IGDB platform slugs
|
||||
*/
|
||||
fun getAllMappedIgdbSlugs(): Set<String> {
|
||||
return igdbToGameyfinMap.keys
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package org.gameyfin.plugins.metadata.igdb.mapper
|
||||
|
||||
import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class PlayerPerspectiveMapper {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
fun playerPerspective(perspective: proto.PlayerPerspective): PlayerPerspective {
|
||||
return when (perspective.slug) {
|
||||
"first-person" -> PlayerPerspective.FIRST_PERSON
|
||||
"third-person" -> PlayerPerspective.THIRD_PERSON
|
||||
"bird-view-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
|
||||
"bird-view-slash-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
|
||||
"side-view" -> PlayerPerspective.SIDE_VIEW
|
||||
"text" -> PlayerPerspective.TEXT
|
||||
"auditory" -> PlayerPerspective.AUDITORY
|
||||
"virtual-reality" -> PlayerPerspective.VIRTUAL_REALITY
|
||||
else -> {
|
||||
log.warn("Unknown player perspective: {}", perspective.slug)
|
||||
PlayerPerspective.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.gameyfin.plugins.metadata.igdb.mapper
|
||||
|
||||
import org.gameyfin.pluginapi.gamemetadata.Theme
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class ThemeMapper {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
fun theme(theme: proto.Theme): Theme {
|
||||
return when (theme.slug) {
|
||||
"action" -> Theme.ACTION
|
||||
"fantasy" -> Theme.FANTASY
|
||||
"horror" -> Theme.HORROR
|
||||
"sci-fi" -> Theme.SCIENCE_FICTION
|
||||
"science-fiction" -> Theme.SCIENCE_FICTION
|
||||
"mystery" -> Theme.MYSTERY
|
||||
"thriller" -> Theme.THRILLER
|
||||
"survival" -> Theme.SURVIVAL
|
||||
"historical" -> Theme.HISTORICAL
|
||||
"stealth" -> Theme.STEALTH
|
||||
"comedy" -> Theme.COMEDY
|
||||
"business" -> Theme.BUSINESS
|
||||
"drama" -> Theme.DRAMA
|
||||
"non-fiction" -> Theme.NON_FICTION
|
||||
"sandbox" -> Theme.SANDBOX
|
||||
"educational" -> Theme.EDUCATIONAL
|
||||
"kids" -> Theme.KIDS
|
||||
"open-world" -> Theme.OPEN_WORLD
|
||||
"warfare" -> Theme.WARFARE
|
||||
"party" -> Theme.PARTY
|
||||
"4x-explore-expand-exploit-and-exterminate" -> Theme.FOUR_X
|
||||
"erotic" -> Theme.EROTIC
|
||||
"romance" -> Theme.ROMANCE
|
||||
else -> {
|
||||
log.warn("Unknown theme: {}", theme.slug)
|
||||
Theme.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
Plugin-Version: 1.0.0
|
||||
Plugin-Version: 1.1.0
|
||||
Plugin-Class: org.gameyfin.plugins.metadata.igdb.IgdbPlugin
|
||||
Plugin-Id: org.gameyfin.plugins.metadata.igdb
|
||||
Plugin-Name: IGDB Metadata
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package org.gameyfin.plugins.metadata.igdb.mapper
|
||||
|
||||
import org.gameyfin.pluginapi.gamemetadata.Platform
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertAll
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* Unit tests for the PlatformMapper to ensure complete bidirectional mapping coverage
|
||||
* between IGDB platform slugs and Gameyfin Platform enums.
|
||||
*
|
||||
* Goal: 100% 1:1 mapping in both directions
|
||||
*/
|
||||
class PlatformMapperTest {
|
||||
|
||||
@Test
|
||||
fun `all Platform enums should map to an IGDB slug`() {
|
||||
val unmappedPlatforms = mutableListOf<Platform>()
|
||||
|
||||
Platform.entries.forEach { platform ->
|
||||
try {
|
||||
PlatformMapper.toIgdb(platform)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
unmappedPlatforms.add(platform)
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
unmappedPlatforms,
|
||||
"The following Platform enum entries do not have IGDB slug mappings: $unmappedPlatforms"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all mapped IGDB platform slugs should map to a Platform`() {
|
||||
val allMappedIgdbSlugs = PlatformMapper.getAllMappedIgdbSlugs()
|
||||
val unmappedIgdbSlugs = mutableListOf<String>()
|
||||
|
||||
allMappedIgdbSlugs.forEach { igdbSlug ->
|
||||
try {
|
||||
PlatformMapper.toGameyfin(igdbSlug)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
unmappedIgdbSlugs.add(igdbSlug)
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
unmappedIgdbSlugs,
|
||||
"The following IGDB slugs from the mapper do not have Platform mappings: $unmappedIgdbSlugs"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bidirectional mapping should be consistent`() {
|
||||
val inconsistencies = mutableListOf<String>()
|
||||
|
||||
Platform.entries.forEach { platform ->
|
||||
val igdbSlug = PlatformMapper.toIgdb(platform)
|
||||
assertNotNull(igdbSlug, "Platform $platform should map to an IGDB slug")
|
||||
|
||||
val mappedBack = PlatformMapper.toGameyfin(igdbSlug)
|
||||
if (mappedBack != platform) {
|
||||
inconsistencies.add(
|
||||
"Platform.$platform -> IGDB slug '$igdbSlug' -> Platform.$mappedBack (expected Platform.$platform)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
inconsistencies,
|
||||
"Bidirectional mapping inconsistencies found:\n${inconsistencies.joinToString("\n")}"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toGameyfin collection should map all slugs`() {
|
||||
val testSlugs = setOf("ps4--1", "xboxone", "switch") // PS4, Xbox One, Nintendo Switch
|
||||
val platforms = PlatformMapper.toGameyfin(testSlugs)
|
||||
|
||||
assertEquals(
|
||||
setOf(Platform.PLAYSTATION_4, Platform.XBOX_ONE, Platform.NINTENDO_SWITCH),
|
||||
platforms,
|
||||
"Should map all provided IGDB slugs to platforms"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toIgdb collection should map all platforms`() {
|
||||
val platformSet = setOf(
|
||||
Platform.PLAYSTATION_4,
|
||||
Platform.XBOX_ONE,
|
||||
Platform.NINTENDO_SWITCH,
|
||||
Platform.PC_MICROSOFT_WINDOWS
|
||||
)
|
||||
val igdbSlugs = PlatformMapper.toIgdb(platformSet)
|
||||
|
||||
assertEquals(
|
||||
setOf("ps4--1", "xboxone", "switch", "win"),
|
||||
igdbSlugs,
|
||||
"Should map all platforms to their IGDB slugs"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `specific platform mappings should be correct`() {
|
||||
assertAll(
|
||||
// Modern consoles
|
||||
{ assertEquals(Platform.PLAYSTATION_5, PlatformMapper.toGameyfin("ps5")) },
|
||||
{ assertEquals("ps5", PlatformMapper.toIgdb(Platform.PLAYSTATION_5)) },
|
||||
|
||||
{ assertEquals(Platform.XBOX_SERIES_X_S, PlatformMapper.toGameyfin("series-x-s")) },
|
||||
{ assertEquals("series-x-s", PlatformMapper.toIgdb(Platform.XBOX_SERIES_X_S)) },
|
||||
|
||||
{ assertEquals(Platform.NINTENDO_SWITCH, PlatformMapper.toGameyfin("switch")) },
|
||||
{ assertEquals("switch", PlatformMapper.toIgdb(Platform.NINTENDO_SWITCH)) },
|
||||
|
||||
// PC platforms
|
||||
{ assertEquals(Platform.PC_MICROSOFT_WINDOWS, PlatformMapper.toGameyfin("win")) },
|
||||
{ assertEquals("win", PlatformMapper.toIgdb(Platform.PC_MICROSOFT_WINDOWS)) },
|
||||
|
||||
{ assertEquals(Platform.LINUX, PlatformMapper.toGameyfin("linux")) },
|
||||
{ assertEquals("linux", PlatformMapper.toIgdb(Platform.LINUX)) },
|
||||
|
||||
{ assertEquals(Platform.MAC, PlatformMapper.toGameyfin("mac")) },
|
||||
{ assertEquals("mac", PlatformMapper.toIgdb(Platform.MAC)) },
|
||||
|
||||
// Classic consoles
|
||||
{ assertEquals(Platform.NINTENDO_ENTERTAINMENT_SYSTEM, PlatformMapper.toGameyfin("nes")) },
|
||||
{ assertEquals("nes", PlatformMapper.toIgdb(Platform.NINTENDO_ENTERTAINMENT_SYSTEM)) },
|
||||
|
||||
{ assertEquals(Platform.SUPER_NINTENDO_ENTERTAINMENT_SYSTEM, PlatformMapper.toGameyfin("snes")) },
|
||||
{ assertEquals("snes", PlatformMapper.toIgdb(Platform.SUPER_NINTENDO_ENTERTAINMENT_SYSTEM)) },
|
||||
|
||||
// Mobile
|
||||
{ assertEquals(Platform.IOS, PlatformMapper.toGameyfin("ios")) },
|
||||
{ assertEquals("ios", PlatformMapper.toIgdb(Platform.IOS)) },
|
||||
|
||||
{ assertEquals(Platform.ANDROID, PlatformMapper.toGameyfin("android")) },
|
||||
{ assertEquals("android", PlatformMapper.toIgdb(Platform.ANDROID)) },
|
||||
|
||||
// VR
|
||||
{ assertEquals(Platform.PLAYSTATION_VR2, PlatformMapper.toGameyfin("psvr2")) },
|
||||
{ assertEquals("psvr2", PlatformMapper.toIgdb(Platform.PLAYSTATION_VR2)) },
|
||||
|
||||
{ assertEquals(Platform.META_QUEST_3, PlatformMapper.toGameyfin("meta-quest-3")) },
|
||||
{ assertEquals("meta-quest-3", PlatformMapper.toIgdb(Platform.META_QUEST_3)) }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no duplicate mappings should exist`() {
|
||||
// Get all mapped IGDB slugs from all Platform enums
|
||||
val allMappedIgdbSlugs = Platform.entries.map { PlatformMapper.toIgdb(it) }
|
||||
val uniqueIgdbSlugs = allMappedIgdbSlugs.toSet()
|
||||
|
||||
assertEquals(
|
||||
allMappedIgdbSlugs.size,
|
||||
uniqueIgdbSlugs.size,
|
||||
"Duplicate IGDB slug mappings found. Each Platform should map to a unique IGDB slug."
|
||||
)
|
||||
|
||||
// Get all mapped Platforms from all IGDB slugs
|
||||
val allIgdbSlugs = PlatformMapper.getAllMappedIgdbSlugs()
|
||||
val allMappedPlatforms = allIgdbSlugs.map { PlatformMapper.toGameyfin(it) }
|
||||
val uniquePlatforms = allMappedPlatforms.toSet()
|
||||
|
||||
assertEquals(
|
||||
allMappedPlatforms.size,
|
||||
uniquePlatforms.size,
|
||||
"Duplicate Platform mappings found. Each IGDB slug should map to a unique Platform."
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapped IGDB slugs count should equal Platform enum count for 1-to-1 mapping`() {
|
||||
val allMappedIgdbSlugs = PlatformMapper.getAllMappedIgdbSlugs()
|
||||
|
||||
assertEquals(
|
||||
Platform.entries.size,
|
||||
allMappedIgdbSlugs.size,
|
||||
"For 1:1 mapping, the number of mapped IGDB slugs (${allMappedIgdbSlugs.size}) should equal the number of Platform enums (${Platform.entries.size})"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
val ktor_version = "3.1.3"
|
||||
val resilience4jVersion = "2.2.0"
|
||||
|
||||
plugins {
|
||||
id("com.google.devtools.ksp")
|
||||
@@ -21,6 +22,17 @@ dependencies {
|
||||
exclude(group = "org.slf4j")
|
||||
}
|
||||
|
||||
// Resilience4j for rate limiting and bulkheading
|
||||
implementation("io.github.resilience4j:resilience4j-ratelimiter:${resilience4jVersion}") {
|
||||
exclude(group = "org.slf4j")
|
||||
}
|
||||
implementation("io.github.resilience4j:resilience4j-bulkhead:${resilience4jVersion}") {
|
||||
exclude(group = "org.slf4j")
|
||||
}
|
||||
implementation("io.github.resilience4j:resilience4j-all:${resilience4jVersion}") {
|
||||
exclude(group = "org.slf4j")
|
||||
}
|
||||
|
||||
implementation("me.xdrop:fuzzywuzzy:1.4.0")
|
||||
implementation("org.jsoup:jsoup:1.20.1")
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
package org.gameyfin.plugins.metadata.steam
|
||||
|
||||
// Resilience4j
|
||||
import io.github.resilience4j.bulkhead.Bulkhead
|
||||
import io.github.resilience4j.bulkhead.BulkheadConfig
|
||||
import io.github.resilience4j.decorators.Decorators
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterConfig
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
@@ -15,8 +21,10 @@ import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||
import org.gameyfin.pluginapi.core.wrapper.GameyfinPlugin
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameMetadata
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
||||
import org.gameyfin.pluginapi.gamemetadata.Platform
|
||||
import org.gameyfin.plugins.metadata.steam.dto.SteamDetailsResultWrapper
|
||||
import org.gameyfin.plugins.metadata.steam.dto.SteamGame
|
||||
import org.gameyfin.plugins.metadata.steam.dto.SteamPlatforms
|
||||
import org.gameyfin.plugins.metadata.steam.dto.SteamSearchResult
|
||||
import org.gameyfin.plugins.metadata.steam.mapper.Mapper
|
||||
import org.gameyfin.plugins.metadata.steam.util.SteamDateSerializer
|
||||
@@ -25,6 +33,7 @@ import org.pf4j.Extension
|
||||
import org.pf4j.PluginWrapper
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.net.URI
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
@@ -38,11 +47,12 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
val dateSerializer = SteamDateSerializer()
|
||||
}
|
||||
|
||||
@Suppress("Unused")
|
||||
@Extension(ordinal = 3)
|
||||
class SteamMetadataProvider : GameMetadataProvider {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
val client = HttpClient(CIO) {
|
||||
private val client = HttpClient(CIO) {
|
||||
// Use a fake browser user agent to avoid being blocked by Steam
|
||||
BrowserUserAgent()
|
||||
|
||||
@@ -51,12 +61,43 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val rateLimiter: RateLimiter = RateLimiter.of(
|
||||
"steam-api",
|
||||
RateLimiterConfig.custom()
|
||||
.limitForPeriod(4)
|
||||
.limitRefreshPeriod(Duration.ofSeconds(1))
|
||||
.timeoutDuration(Duration.ofMinutes(10))
|
||||
.build()
|
||||
)
|
||||
private val bulkhead: Bulkhead = Bulkhead.of(
|
||||
"steam-api",
|
||||
BulkheadConfig.custom()
|
||||
.maxConcurrentCalls(8)
|
||||
.maxWaitDuration(Duration.ofMinutes(10))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
// SteamVR support is not properly reflected in the store API, so we cannot reliably detect VR games
|
||||
override val supportedPlatforms: Set<Platform> =
|
||||
setOf(Platform.PC_MICROSOFT_WINDOWS, Platform.LINUX, Platform.MAC)
|
||||
|
||||
/**
|
||||
* The Steam Store API I am using provides far less info than IGDB for example
|
||||
* See it more as a proof of concept than a fully functional plugin
|
||||
**/
|
||||
override fun fetchByTitle(gameTitle: String, maxResults: Int): List<GameMetadata> {
|
||||
val searchResult: List<SteamGame> = runBlocking { searchStore(gameTitle) }
|
||||
override fun fetchByTitle(
|
||||
gameTitle: String,
|
||||
platformFilter: Set<Platform>,
|
||||
maxResults: Int
|
||||
): List<GameMetadata> {
|
||||
val searchResult: List<SteamGame> = try {
|
||||
steamApiCall { searchStore(gameTitle, platformFilter) }
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to search Steam store: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
if (searchResult.isEmpty()) return emptyList()
|
||||
|
||||
// Use fuzzy search to find the best matching game name
|
||||
@@ -71,38 +112,79 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
bestMatches = bestMatches.filter { it.name in bestMatchesMap.keys }
|
||||
.sortedByDescending { bestMatchesMap[it.name] }
|
||||
|
||||
return runBlocking { bestMatches.map { getGameDetails(it.id) } }
|
||||
.filterNotNull()
|
||||
.take(maxResults)
|
||||
return bestMatches.mapNotNull { steamGame ->
|
||||
try {
|
||||
steamApiCall { getGameDetails(steamGame.id, platformFilter) }
|
||||
} catch (e: Exception) {
|
||||
log.warn("Failed to fetch details for app ${steamGame.id}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}.take(maxResults)
|
||||
}
|
||||
|
||||
// Helper to enforce rate limit + bulkhead around suspend HTTP operations
|
||||
private fun <T> steamApiCall(block: suspend () -> T): T {
|
||||
val supplier = { runBlocking { block() } }
|
||||
val decorated = Decorators.ofSupplier(supplier)
|
||||
.withBulkhead(bulkhead)
|
||||
.withRateLimiter(rateLimiter)
|
||||
.decorate()
|
||||
return decorated.get()
|
||||
}
|
||||
|
||||
override fun fetchById(id: String): GameMetadata? {
|
||||
val id = id.toIntOrNull() ?: return null
|
||||
return runBlocking { getGameDetails(id) }
|
||||
}
|
||||
|
||||
private suspend fun searchStore(title: String): List<SteamGame> {
|
||||
val intId = id.toIntOrNull() ?: return null
|
||||
return try {
|
||||
val response = client.get("https://store.steampowered.com/api/storesearch") {
|
||||
parameter("term", title)
|
||||
parameter("cc", "en")
|
||||
parameter("l", "en")
|
||||
}
|
||||
val searchResult: SteamSearchResult = response.body()
|
||||
searchResult.items
|
||||
steamApiCall { getGameDetails(intId) }
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to search Steam store: ${e.message}")
|
||||
emptyList()
|
||||
log.warn("Failed to fetch details for app $intId: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getGameDetails(id: Int): GameMetadata? {
|
||||
private suspend fun searchStore(title: String, platformFilter: Set<Platform>): List<SteamGame> {
|
||||
val response = client.get("https://store.steampowered.com/api/storesearch") {
|
||||
parameter("term", title)
|
||||
parameter("cc", "en")
|
||||
parameter("l", "en")
|
||||
}
|
||||
|
||||
if (response.status == HttpStatusCode.Forbidden) {
|
||||
log.warn("Steam API rate limit hit; backing off and returning empty result")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
if (response.status != HttpStatusCode.OK) {
|
||||
log.warn("Steam search returned HTTP ${response.status}")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val searchResult: SteamSearchResult = response.body()
|
||||
|
||||
val filteredByPlatform = if (platformFilter.isNotEmpty()) {
|
||||
searchResult.items.filter { game ->
|
||||
val platformsSupportedByGame = toGameyfinPlatforms(game.platforms)
|
||||
platformFilter.any { it in platformsSupportedByGame }
|
||||
}
|
||||
} else {
|
||||
searchResult.items
|
||||
}
|
||||
|
||||
return filteredByPlatform
|
||||
}
|
||||
|
||||
private suspend fun getGameDetails(id: Int, platformFilter: Set<Platform> = emptySet()): GameMetadata? {
|
||||
val response = client.get("https://store.steampowered.com/api/appdetails") {
|
||||
parameter("appids", id)
|
||||
parameter("cc", "en")
|
||||
parameter("l", "en")
|
||||
}
|
||||
|
||||
if (response.status == HttpStatusCode.Forbidden) {
|
||||
log.warn("Steam API rate limit hit; backing off and returning empty result")
|
||||
return null
|
||||
}
|
||||
|
||||
if (response.status != HttpStatusCode.OK) return null
|
||||
|
||||
val responseBody: String = response.bodyAsText(Charsets.UTF_8)
|
||||
@@ -115,12 +197,23 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
|
||||
if (game.type != "game") return null
|
||||
|
||||
// The returned game should only contain the platforms we are interested in (if any filter is set)
|
||||
val gamePlatforms = if (platformFilter.isNotEmpty()) {
|
||||
toGameyfinPlatforms(game.platforms).intersect(platformFilter)
|
||||
} else {
|
||||
toGameyfinPlatforms(game.platforms)
|
||||
}
|
||||
|
||||
// If the game does not support any of the requested platforms, skip it
|
||||
if (gamePlatforms.isEmpty()) return null
|
||||
|
||||
// This is as much as I can get from the Steam Store API
|
||||
val metadata = GameMetadata(
|
||||
originalId = id.toString(),
|
||||
title = sanitizeTitle(game.name),
|
||||
platforms = gamePlatforms,
|
||||
description = game.shortDescription, // Using short description since the detailed description often contains just some ads for the Battle Pass etc.
|
||||
coverUrls = game.headerImage?.let { URI(it) }?.let { listOf(it) },
|
||||
coverUrls = game.headerImage?.let { URI(it) }?.let { listOf(it) }?.toSet(),
|
||||
release = parseOriginalReleaseDateFromStorePage(id) ?: game.releaseDate?.date,
|
||||
developedBy = game.developers?.toSet(),
|
||||
publishedBy = game.publishers?.toSet(),
|
||||
@@ -146,6 +239,11 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
cookie("lastagecheckage", "1-January-1900")
|
||||
}
|
||||
|
||||
if (response.status == HttpStatusCode.Forbidden) {
|
||||
log.warn("Steam web page responded 403 Forbidden for app $appId; can't parse original release date")
|
||||
return null
|
||||
}
|
||||
|
||||
if (response.status != HttpStatusCode.OK) return null
|
||||
|
||||
val html: String = response.bodyAsText(Charsets.UTF_8)
|
||||
@@ -155,7 +253,6 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
return dateSerializer.deserialize(releaseDateText.text())
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Often titles on Steam contain copyright symbols which makes matching between different providers harder
|
||||
* This method removes those symbols
|
||||
@@ -164,5 +261,17 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
val unwantedChars = setOf('™', '©', '®')
|
||||
return originalTitle.filter { it !in unwantedChars }.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine supported Gameyfin platforms for a Steam game based on its platform flags
|
||||
*/
|
||||
private fun toGameyfinPlatforms(steamPlatforms: SteamPlatforms): Set<Platform> {
|
||||
val gameyfinPlatforms = mutableSetOf<Platform>()
|
||||
if (steamPlatforms.windows) gameyfinPlatforms.add(Platform.PC_MICROSOFT_WINDOWS)
|
||||
if (steamPlatforms.linux) gameyfinPlatforms.add(Platform.LINUX)
|
||||
if (steamPlatforms.mac) gameyfinPlatforms.add(Platform.MAC)
|
||||
return gameyfinPlatforms
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ data class SteamDetailsResultWrapper(
|
||||
data class SteamGameDetails(
|
||||
val type: String,
|
||||
val name: String,
|
||||
val platforms: SteamPlatforms,
|
||||
@SerialName("short_description") val shortDescription: String? = null,
|
||||
@SerialName("detailed_description") val detailedDescription: String? = null,
|
||||
@SerialName("header_image") val headerImage: String? = null,
|
||||
|
||||
+2
-1
@@ -12,5 +12,6 @@ data class SteamSearchResult(
|
||||
data class SteamGame(
|
||||
val type: String,
|
||||
val name: String,
|
||||
val id: Int
|
||||
val id: Int,
|
||||
val platforms: SteamPlatforms
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package org.gameyfin.plugins.metadata.steam.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SteamPlatforms(
|
||||
val windows: Boolean,
|
||||
val mac: Boolean,
|
||||
val linux: Boolean
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
Plugin-Version: 1.0.0
|
||||
Plugin-Version: 1.2.0
|
||||
Plugin-Class: org.gameyfin.plugins.metadata.steam.SteamPlugin
|
||||
Plugin-Id: org.gameyfin.plugins.metadata.steam
|
||||
Plugin-Name: Steam Metadata
|
||||
|
||||
+14
-5
@@ -8,6 +8,7 @@ import org.gameyfin.pluginapi.core.config.*
|
||||
import org.gameyfin.pluginapi.core.wrapper.ConfigurableGameyfinPlugin
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameMetadata
|
||||
import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
||||
import org.gameyfin.pluginapi.gamemetadata.Platform
|
||||
import org.gameyfin.plugins.metadata.steamgriddb.api.SteamGridDbApiClient
|
||||
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGame
|
||||
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGrid
|
||||
@@ -78,10 +79,18 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
|
||||
log.debug("Authentication successful")
|
||||
}
|
||||
|
||||
@Suppress("Unused")
|
||||
@Extension(ordinal = 1)
|
||||
class SteamGridDBGameCoverProvider : GameMetadataProvider {
|
||||
|
||||
override fun fetchByTitle(gameTitle: String, maxResults: Int): List<GameMetadata> {
|
||||
// Supports all platforms since SteamGridDB has covers for a wide range of platforms
|
||||
override val supportedPlatforms: Set<Platform> = emptySet()
|
||||
|
||||
override fun fetchByTitle(
|
||||
gameTitle: String,
|
||||
platformFilter: Set<Platform>,
|
||||
maxResults: Int
|
||||
): List<GameMetadata> {
|
||||
return runBlocking {
|
||||
val results = searchSteamGridDb(gameTitle)
|
||||
coroutineScope {
|
||||
@@ -93,8 +102,8 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
|
||||
originalId = game.id.toString(),
|
||||
title = game.name,
|
||||
release = game.releaseDate,
|
||||
coverUrls = grids?.map { URI(it.url) },
|
||||
headerUrls = heroes?.map { URI(it.url) }
|
||||
coverUrls = grids?.map { URI(it.url) }?.toSet(),
|
||||
headerUrls = heroes?.map { URI(it.url) }?.toSet()
|
||||
)
|
||||
}
|
||||
}.awaitAll().take(maxResults)
|
||||
@@ -114,8 +123,8 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
|
||||
originalId = game.id.toString(),
|
||||
title = game.name,
|
||||
release = game.releaseDate,
|
||||
coverUrls = grids?.map { URI(it.url) },
|
||||
headerUrls = heroes?.map { URI(it.url) }
|
||||
coverUrls = grids?.map { URI(it.url) }?.toSet(),
|
||||
headerUrls = heroes?.map { URI(it.url) }?.toSet()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
val jlibtorrentVersion = "2.0.12.7"
|
||||
|
||||
plugins {
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven { setUrl("https://jitpack.io") }
|
||||
maven { setUrl("https://repository.jboss.org") }
|
||||
maven {
|
||||
setUrl("https://dl.frostwire.com/maven")
|
||||
content {
|
||||
includeGroup("com.frostwire")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
ksp("care.better.pf4j:pf4j-kotlin-symbol-processing:${rootProject.extra["pf4jKspVersion"]}")
|
||||
|
||||
// Torrent tracker & seeder
|
||||
implementation("com.github.mpetazzoni:ttorrent:ttorrent-2.0") {
|
||||
exclude(group = "org.slf4j")
|
||||
}
|
||||
implementation("com.frostwire:jlibtorrent:$jlibtorrentVersion")
|
||||
implementation("com.frostwire:jlibtorrent-windows:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-macosx-x86_64:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-macosx-arm64:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-linux-x86_64:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-linux-arm64:${jlibtorrentVersion}")
|
||||
}
|
||||
|
||||
// Torrent file builder
|
||||
implementation("com.github.atomashpolskiy:bt-core:1.10") {
|
||||
exclude(group = "org.slf4j")
|
||||
// Extract native libraries from jlibtorrent JARs for local debugging
|
||||
tasks.register<Copy>("extractNativeLibraries") {
|
||||
dependsOn(tasks.named("compileKotlin"))
|
||||
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
|
||||
from(configurations.runtimeClasspath.get().map { project.zipTree(it) }) {
|
||||
include("lib/**")
|
||||
}
|
||||
}
|
||||
into(layout.buildDirectory.get().asFile.resolve("classes/kotlin/main"))
|
||||
}
|
||||
|
||||
tasks.named("classes") {
|
||||
dependsOn("extractNativeLibraries")
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
dependsOn("extractNativeLibraries")
|
||||
}
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package org.gameyfin.plugins.download.torrent
|
||||
|
||||
import com.frostwire.jlibtorrent.SessionManager
|
||||
import com.frostwire.jlibtorrent.SessionParams
|
||||
import com.frostwire.jlibtorrent.SettingsPack
|
||||
import com.frostwire.jlibtorrent.TorrentHandle.QUERY_DISTRIBUTED_COPIES
|
||||
import com.frostwire.jlibtorrent.TorrentHandle.QUERY_NAME
|
||||
import com.frostwire.jlibtorrent.TorrentInfo
|
||||
import com.frostwire.jlibtorrent.swig.libtorrent.*
|
||||
import com.frostwire.jlibtorrent.swig.settings_pack.string_types
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.net.InetAddress
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* A BitTorrent client implementation using jlibtorrent.
|
||||
* Handles torrent session management, seeding, and monitoring.
|
||||
*/
|
||||
class TorrentClient(
|
||||
private val listenPort: Int,
|
||||
private val externalHost: String?,
|
||||
private val performanceMode: TorrentClientPerformanceMode,
|
||||
private val dhtEnabled: Boolean,
|
||||
private val lsdEnabled: Boolean,
|
||||
private val stopSeedingWhenComplete: Boolean
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(TorrentClient::class.java)
|
||||
|
||||
private var session: SessionManager? = null
|
||||
private var monitorExecutor: ScheduledExecutorService? = null
|
||||
|
||||
companion object {
|
||||
private const val INTERNAL_PEER_ID_PREFIX = "-GF0001-"
|
||||
}
|
||||
|
||||
fun start() {
|
||||
// Initialize session
|
||||
session = initSession()
|
||||
|
||||
if (stopSeedingWhenComplete) {
|
||||
startMonitoringTask()
|
||||
}
|
||||
|
||||
log.info("TorrentClient started")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
monitorExecutor?.shutdown()
|
||||
|
||||
try {
|
||||
monitorExecutor?.awaitTermination(5, TimeUnit.SECONDS)
|
||||
} catch (_: InterruptedException) {
|
||||
monitorExecutor?.shutdownNow()
|
||||
}
|
||||
monitorExecutor = null
|
||||
|
||||
session?.stop()
|
||||
|
||||
log.info("TorrentClient stopped")
|
||||
}
|
||||
|
||||
fun addTorrent(torrentFile: Path, gameFile: Path) {
|
||||
val ti = TorrentInfo(torrentFile.toFile())
|
||||
|
||||
// For seeding, we need to use the parent directory as the save path
|
||||
val savePath = gameFile.parent.toFile()
|
||||
|
||||
// Check if torrent is already in session
|
||||
val existingHandle = session?.find(ti)
|
||||
if (existingHandle != null && existingHandle.isValid) {
|
||||
log.debug("Torrent ${ti.name()} is already in session, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify file access before adding to session
|
||||
if (!Files.isReadable(gameFile)) {
|
||||
log.error("Cannot read game file for seeding: $gameFile - check file permissions")
|
||||
throw IllegalStateException("Game file is not readable: $gameFile")
|
||||
}
|
||||
|
||||
try {
|
||||
// Use SessionManager's download method - it will seed the files in the save directory
|
||||
session?.download(ti, savePath)
|
||||
log.info("Added torrent to session for seeding: ${ti.name()} from $savePath")
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to add torrent to session for seeding: ${ti.name()}", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun initSession(): SessionManager {
|
||||
// This method is always called from the session executor thread
|
||||
// Return existing session if already initialized
|
||||
session?.let { return it }
|
||||
|
||||
// Initialize jlibtorrent session
|
||||
val sessionManager = SessionManager()
|
||||
|
||||
// Configure session settings based on performance mode
|
||||
val settingsPack = when (performanceMode) {
|
||||
TorrentClientPerformanceMode.Balanced -> SettingsPack(default_settings())
|
||||
TorrentClientPerformanceMode.`High Performance` -> SettingsPack(high_performance_seed())
|
||||
TorrentClientPerformanceMode.`Minimal Memory Usage` -> SettingsPack(min_memory_usage())
|
||||
}
|
||||
log.info("Configured TorrentClient with performance mode: $performanceMode")
|
||||
|
||||
|
||||
// Set custom peer ID prefix for our internal client
|
||||
// This allows us to identify this specific client if needed
|
||||
settingsPack.peerFingerprint = INTERNAL_PEER_ID_PREFIX.toByteArray()
|
||||
|
||||
// Configure interfaces
|
||||
settingsPack.listenInterfaces("0.0.0.0:$listenPort,[::]:$listenPort")
|
||||
|
||||
// Configure announce IP if externalHost is set
|
||||
if (externalHost != null && externalHost.isNotBlank()) {
|
||||
try {
|
||||
val resolvedIp = InetAddress.getByName(externalHost).hostAddress
|
||||
settingsPack.setString(string_types.announce_ip.swigValue(), resolvedIp)
|
||||
log.info("Configured client announce IP to: $resolvedIp (from external host: $externalHost)")
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to resolve external host '$externalHost' for client IP", e)
|
||||
}
|
||||
} else {
|
||||
log.info("No external host override set; using default announce IP behavior")
|
||||
}
|
||||
|
||||
// Configure DHT
|
||||
settingsPack.isEnableDht = dhtEnabled
|
||||
|
||||
// Configure Local Peer Discovery
|
||||
settingsPack.isEnableLsd = lsdEnabled
|
||||
|
||||
val sessionParams = SessionParams(settingsPack)
|
||||
|
||||
// Configure disk I/O based on operating system
|
||||
// This must be done because libtorrent 2.0 uses memory mapped files which conflict with java runtime handlers
|
||||
// resulting in SIGSEGV crashes
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
when {
|
||||
os.contains("win") -> {
|
||||
sessionParams.setDefaultDiskIO()
|
||||
log.info("Configured disk I/O for Windows (default disk I/O)")
|
||||
}
|
||||
|
||||
os.contains("nix") || os.contains("nux") || os.contains("mac") || os.contains("darwin") -> {
|
||||
sessionParams.setPosixDiskIO()
|
||||
log.info("Configured disk I/O for Unix-like system (POSIX disk I/O)")
|
||||
}
|
||||
|
||||
else -> {
|
||||
log.info("Unknown OS '$os', using default disk I/O settings")
|
||||
}
|
||||
}
|
||||
|
||||
sessionManager.start(sessionParams)
|
||||
|
||||
// Log the listening status
|
||||
log.info("BitTorrent client started. Listen interfaces: ${settingsPack.listenInterfaces()}")
|
||||
|
||||
return sessionManager
|
||||
}
|
||||
|
||||
private fun startMonitoringTask() {
|
||||
monitorExecutor = Executors.newSingleThreadScheduledExecutor()
|
||||
monitorExecutor?.scheduleWithFixedDelay({
|
||||
try {
|
||||
checkAndStopCompletedTorrents()
|
||||
} catch (e: Exception) {
|
||||
log.error("Error checking torrent completion status", e)
|
||||
}
|
||||
}, 60, 60, TimeUnit.SECONDS) // Check every 60 seconds
|
||||
}
|
||||
|
||||
private fun checkAndStopCompletedTorrents() {
|
||||
val handles = session?.torrentHandles ?: return
|
||||
|
||||
handles.forEach { handle ->
|
||||
if (!handle.isValid) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val status = handle.status(QUERY_DISTRIBUTED_COPIES.or_(QUERY_NAME))
|
||||
|
||||
// Only check torrents that we are seeding
|
||||
if (status.isFinished) {
|
||||
val knownSeeders = status.listSeeds()
|
||||
val completePeersFromTracker = status.numComplete()
|
||||
val distributedFullCopies = status.distributedFullCopies()
|
||||
|
||||
// If there are other seeders or complete peers, stop seeding
|
||||
if (distributedFullCopies > 0) {
|
||||
log.debug("Stopping seeding for torrent '${status.name()}' as it is healthy: $distributedFullCopies distributed full copies.")
|
||||
session?.remove(handle)
|
||||
} else if (completePeersFromTracker > 1) {
|
||||
log.debug("Stopping seeding for torrent '${status.name()}' as it is healthy: $completePeersFromTracker complete peers from tracker.")
|
||||
session?.remove(handle)
|
||||
} else if (knownSeeders > 0) {
|
||||
log.debug("Stopping seeding for torrent '${status.name()}' as it is healthy: $knownSeeders known seeders.")
|
||||
session?.remove(handle)
|
||||
} else {
|
||||
log.debug("Continuing to seed torrent '${status.name()}' - no other complete peers found.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package org.gameyfin.plugins.download.torrent
|
||||
|
||||
@Suppress("EnumEntryName")
|
||||
enum class TorrentClientPerformanceMode {
|
||||
`Minimal Memory Usage`,
|
||||
Balanced,
|
||||
`High Performance`
|
||||
}
|
||||
+208
-115
@@ -1,12 +1,8 @@
|
||||
package org.gameyfin.plugins.download.torrent
|
||||
|
||||
import bt.torrent.maker.TorrentBuilder
|
||||
import com.turn.ttorrent.client.CommunicationManager
|
||||
import com.turn.ttorrent.client.SelectorFactoryImpl
|
||||
import com.turn.ttorrent.client.storage.FullyPieceStorageFactory
|
||||
import com.turn.ttorrent.network.FirstAvailableChannel
|
||||
import com.turn.ttorrent.tracker.TrackedTorrent
|
||||
import com.turn.ttorrent.tracker.Tracker
|
||||
import com.frostwire.jlibtorrent.TorrentBuilder
|
||||
import com.frostwire.jlibtorrent.swig.create_torrent.v1_only
|
||||
import com.frostwire.jlibtorrent.swig.create_torrent.v2_only
|
||||
import org.gameyfin.pluginapi.core.config.ConfigMetadata
|
||||
import org.gameyfin.pluginapi.core.config.PluginConfigMetadata
|
||||
import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult
|
||||
@@ -21,20 +17,17 @@ import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.io.path.*
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
|
||||
|
||||
companion object {
|
||||
private val TORRENT_FILE_DIRECTORY = Path.of("torrent_dotfiles")
|
||||
private lateinit var tracker: Tracker
|
||||
private lateinit var communicationManager: CommunicationManager
|
||||
|
||||
private lateinit var plugin: TorrentDownloadPlugin
|
||||
|
||||
private lateinit var state: TorrentDownloadPluginState
|
||||
|
||||
private var client: TorrentClient? = null
|
||||
private var tracker: TorrentTracker? = null
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -43,102 +36,194 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin
|
||||
|
||||
override val configMetadata: PluginConfigMetadata = listOf(
|
||||
ConfigMetadata(
|
||||
key = "trackerPort",
|
||||
label = "Tracker Port",
|
||||
description = "Which port the torrent tracker should use",
|
||||
type = Int::class.java,
|
||||
default = 6969
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "clientPort",
|
||||
label = "Seed Client Port",
|
||||
description = "Which port the seed client should use",
|
||||
type = Int::class.java,
|
||||
default = 6881
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "externalHost",
|
||||
label = "Hostname/IP override",
|
||||
description = "Overrides the external host (e.g., if behind NAT)",
|
||||
type = String::class.java,
|
||||
isRequired = false
|
||||
key = "stopSeedingWhenComplete",
|
||||
label = "Stop Seeding When Complete",
|
||||
description = "Automatically stop seeding torrents once there are other peers with all pieces (torrent is healthy)",
|
||||
type = Boolean::class.java,
|
||||
default = false
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "privateMode",
|
||||
label = "Create torrents with private mode enabled",
|
||||
description = "Enables private mode for the torrent tracker according to BEP-27",
|
||||
description = "Enables private mode for torrents according to BEP-27",
|
||||
type = Boolean::class.java,
|
||||
default = true
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "dhtEnabled",
|
||||
label = "Enable DHT",
|
||||
description = "Enable Distributed Hash Table for peer discovery",
|
||||
type = Boolean::class.java,
|
||||
default = false
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "lsdEnabled",
|
||||
label = "Enable LSD",
|
||||
description = "Enable Local Service Discovery for finding peers on the local network",
|
||||
type = Boolean::class.java,
|
||||
default = false
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "torrentVersions",
|
||||
label = "Torrent Protocol Versions",
|
||||
description = "Which torrent protocol versions to support (some clients don't support v2)",
|
||||
type = TorrentVersion::class.java,
|
||||
default = TorrentVersion.`V1 and V2`
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "performanceMode",
|
||||
label = "Torrent Client Performance Mode",
|
||||
description = "Optimizes the torrent client for either low resource usage or high performance",
|
||||
type = TorrentClientPerformanceMode::class.java,
|
||||
default = TorrentClientPerformanceMode.Balanced
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "externalHost",
|
||||
label = "Hostname/IP override",
|
||||
description = "Overrides the external host for the built-in tracker (e.g., if behind NAT/Docker)",
|
||||
type = String::class.java,
|
||||
isRequired = false
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "listenPort",
|
||||
label = "Listen Port",
|
||||
description = "Which port the built-in torrent client should listen on",
|
||||
type = Int::class.java,
|
||||
default = 6881
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "trackerPort",
|
||||
label = "Tracker Port",
|
||||
description = "Which port the built-in tracker should listen on",
|
||||
type = Int::class.java,
|
||||
default = 6969
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "announceInterval",
|
||||
label = "Tracker Announce Interval (in seconds)",
|
||||
description = "Interval for clients to re-announce to the tracker",
|
||||
type = Int::class.java,
|
||||
default = 1800
|
||||
)
|
||||
)
|
||||
|
||||
override fun start() {
|
||||
Files.createDirectories(dataDirectory)
|
||||
|
||||
Files.createDirectories(TORRENT_FILE_DIRECTORY)
|
||||
tracker = initTracker()
|
||||
|
||||
tracker = Tracker(config("trackerPort"), getTrackerUri().toString())
|
||||
tracker.setAcceptForeignTorrents(false)
|
||||
tracker.start(true)
|
||||
|
||||
val workingExecutor = Executors.newVirtualThreadPerTaskExecutor()
|
||||
val validationExecutor = Executors.newVirtualThreadPerTaskExecutor()
|
||||
val clientPort = config<Int>("clientPort")
|
||||
communicationManager = CommunicationManager(workingExecutor, validationExecutor)
|
||||
communicationManager.start(
|
||||
arrayOf(getHostname()),
|
||||
15,
|
||||
getTrackerUri(),
|
||||
SelectorFactoryImpl(),
|
||||
FirstAvailableChannel(clientPort, clientPort)
|
||||
)
|
||||
client = initClient()
|
||||
|
||||
state = loadState<TorrentDownloadPluginState>() ?: TorrentDownloadPluginState()
|
||||
|
||||
state.torrentFilesMetadata.forEach {
|
||||
// Check if the torrent and game files exist and
|
||||
// that the game files have not been modified since the torrent file was created
|
||||
if (Files.exists(it.torrentFile) && Files.exists(it.gameFile) &&
|
||||
it.gameFile.getLastModifiedTime().toInstant().isBefore(it.torrentFile.getLastModifiedTime().toInstant())
|
||||
) {
|
||||
tracker.announce(TrackedTorrent.load(it.torrentFile.toFile()))
|
||||
communicationManager.addTorrent(
|
||||
it.torrentFile.toString(),
|
||||
getRootPath(it.gameFile).toString(),
|
||||
FullyPieceStorageFactory.INSTANCE
|
||||
)
|
||||
// Restore existing torrents and remove invalid ones
|
||||
state.torrentFilesMetadata.removeIf { metadata ->
|
||||
val shouldRemove = !Files.exists(metadata.torrentFile) ||
|
||||
!Files.exists(metadata.gameFile) ||
|
||||
metadata.gameFile.getLastModifiedTime().toInstant()
|
||||
.isAfter(metadata.torrentFile.getLastModifiedTime().toInstant())
|
||||
|
||||
if (shouldRemove) {
|
||||
true
|
||||
} else {
|
||||
state.torrentFilesMetadata.remove(it)
|
||||
try {
|
||||
client?.addTorrent(metadata.torrentFile, metadata.gameFile)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to add torrent ${metadata.torrentFile} to session", e)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveState(state)
|
||||
}
|
||||
|
||||
private fun initClient(): TorrentClient {
|
||||
val client = TorrentClient(
|
||||
listenPort = config("listenPort"),
|
||||
externalHost = optionalConfig("externalHost"),
|
||||
performanceMode = config("performanceMode"),
|
||||
dhtEnabled = config("dhtEnabled"),
|
||||
lsdEnabled = config("lsdEnabled"),
|
||||
stopSeedingWhenComplete = config("stopSeedingWhenComplete")
|
||||
)
|
||||
|
||||
client.start()
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
private fun initTracker(): TorrentTracker {
|
||||
val tracker = TorrentTracker(
|
||||
port = config("trackerPort"),
|
||||
announceInterval = config("announceInterval")
|
||||
)
|
||||
|
||||
tracker.start()
|
||||
|
||||
return tracker
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
tracker.stop()
|
||||
communicationManager.stop()
|
||||
client?.stop()
|
||||
client = null
|
||||
|
||||
tracker?.stop()
|
||||
tracker = null
|
||||
}
|
||||
|
||||
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
|
||||
|
||||
val errors = mutableMapOf<String, String>()
|
||||
|
||||
// Plugin is not compatible with Alpine Docker images due to missing glibc
|
||||
if (System.getenv("RUNTIME_ENV") == "docker") {
|
||||
if (getContainerOS() == "alpine") {
|
||||
errors["stopSeedingWhenComplete"] = " "
|
||||
errors["privateMode"] = " "
|
||||
errors["dhtEnabled"] = " "
|
||||
errors["lsdEnabled"] = " "
|
||||
errors["torrentVersions"] = " "
|
||||
errors["performanceMode"] = " "
|
||||
errors["externalHost"] = " "
|
||||
errors["listenPort"] = " "
|
||||
errors["trackerPort"] = " "
|
||||
errors["announceInterval"] =
|
||||
"The torrent plugin is not compatible with the Alpine-based Docker image. Please use the Ubuntu-based Docker image if you want to use the Torrent plugin."
|
||||
return PluginConfigValidationResult.INVALID(errors)
|
||||
}
|
||||
}
|
||||
|
||||
val configValidationResult = super.validateConfig(config)
|
||||
if (!configValidationResult.isValid()) {
|
||||
return configValidationResult
|
||||
}
|
||||
|
||||
val errors = mutableMapOf<String, String>()
|
||||
val listenPort = config["listenPort"]?.toIntOrNull()
|
||||
if (listenPort != null && listenPort !in 1024..65535) {
|
||||
errors["listenPort"] = "Must be a valid port number between 1024 and 65535."
|
||||
}
|
||||
|
||||
val trackerPort = config["trackerPort"]?.toIntOrNull()
|
||||
if (trackerPort != null && trackerPort !in 1024..49151) {
|
||||
errors["trackerPort"] = "Must be a valid port number between 1024 and 49151."
|
||||
if (trackerPort != null && trackerPort !in 1024..65535) {
|
||||
errors["trackerPort"] = "Must be a valid port number between 1024 and 65535."
|
||||
}
|
||||
|
||||
val externalHost = config["externalHost"]
|
||||
if (externalHost != null) {
|
||||
if (!externalHost.isNullOrBlank()) {
|
||||
try {
|
||||
InetAddress.getByName(externalHost)
|
||||
} catch (_: Exception) {
|
||||
errors["externalHost"] = "Must be a valid hostname or IP address."
|
||||
}
|
||||
} else if (System.getenv("RUNTIME_ENV") == "docker") {
|
||||
errors["externalHost"] = "Must be set when running in Docker."
|
||||
}
|
||||
|
||||
val announceInterval = config["announceInterval"]?.toIntOrNull()
|
||||
if (announceInterval != null && announceInterval <= 0) {
|
||||
errors["announceInterval"] = "Must be a positive integer."
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
@@ -149,27 +234,14 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin
|
||||
}
|
||||
|
||||
private fun getTrackerUri(): URI {
|
||||
val protocol = "http" // No SSL support in ttorrent: https://github.com/mpetazzoni/ttorrent/issues/4
|
||||
val host = getHostname().getHostName()
|
||||
val protocol = "http"
|
||||
val host = optionalConfig("externalHost") ?: InetAddress.getLocalHost().hostAddress
|
||||
val port = config<Int>("trackerPort")
|
||||
val path = "announce"
|
||||
|
||||
return URI.create("$protocol://$host:$port/$path")
|
||||
}
|
||||
|
||||
private fun getHostname(): InetAddress {
|
||||
return InetAddress.getByName(
|
||||
optionalConfig("externalHost") ?: InetAddress.getLocalHost().hostAddress
|
||||
)
|
||||
}
|
||||
|
||||
private fun getRootPath(gameFilesPath: Path): Path {
|
||||
return if (gameFilesPath.isDirectory()) {
|
||||
gameFilesPath
|
||||
} else {
|
||||
gameFilesPath.parent
|
||||
}
|
||||
}
|
||||
|
||||
@Extension(ordinal = 2)
|
||||
class TorrentDownloadProvider : DownloadProvider {
|
||||
@@ -179,7 +251,7 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin
|
||||
log.info("Creating torrent for '${path.name}'...")
|
||||
|
||||
val (torrentFile, timeTaken) = measureTimedValue {
|
||||
createTorrent(path)
|
||||
initNewTorrent(path)
|
||||
}
|
||||
|
||||
log.info("Created torrent '${torrentFile.name}' in ${timeTaken.asHumanReadable()}")
|
||||
@@ -191,45 +263,66 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTorrent(gameFilesPath: Path): Path {
|
||||
val torrentFile =
|
||||
TORRENT_FILE_DIRECTORY.resolve("${gameFilesPath.nameWithoutExtension}-${gameFilesPath.hashCode()}.torrent")
|
||||
private fun initNewTorrent(gameFilesPath: Path): Path {
|
||||
val torrentFile = plugin.dataDirectory
|
||||
.resolve("${gameFilesPath.nameWithoutExtension}-${gameFilesPath.hashCode()}.torrent")
|
||||
|
||||
if (Files.exists(torrentFile)) {
|
||||
return torrentFile
|
||||
val isNewTorrent = !Files.exists(torrentFile)
|
||||
|
||||
if (isNewTorrent) {
|
||||
Files.createFile(torrentFile)
|
||||
Files.write(torrentFile, torrentFileContent(gameFilesPath))
|
||||
|
||||
state.torrentFilesMetadata.add(
|
||||
TorrentFileMetadata(
|
||||
torrentFile = torrentFile,
|
||||
gameFile = gameFilesPath
|
||||
)
|
||||
)
|
||||
|
||||
plugin.saveState(state)
|
||||
}
|
||||
|
||||
Files.createFile(torrentFile)
|
||||
Files.write(torrentFile, torrentFileContent(gameFilesPath))
|
||||
|
||||
tracker.announce(TrackedTorrent.load(torrentFile.toFile()))
|
||||
communicationManager.addTorrent(
|
||||
torrentFile.toString(),
|
||||
plugin.getRootPath(gameFilesPath).toString(),
|
||||
FullyPieceStorageFactory.INSTANCE
|
||||
)
|
||||
|
||||
state.torrentFilesMetadata.add(
|
||||
TorrentFileMetadata(
|
||||
torrentFile = torrentFile,
|
||||
gameFile = gameFilesPath
|
||||
)
|
||||
)
|
||||
|
||||
plugin.saveState(state)
|
||||
// Add the torrent to the session for seeding asynchronously to avoid blocking the download
|
||||
// This prevents crashes if there are permission issues or other errors
|
||||
try {
|
||||
client?.addTorrent(torrentFile, gameFilesPath)
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to add torrent to seeding session - torrent file created but won't be seeded", e)
|
||||
// Don't rethrow - the torrent file was created successfully, seeding is optional
|
||||
}
|
||||
|
||||
return torrentFile
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun torrentFileContent(gameFilesPath: Path): ByteArray {
|
||||
return TorrentBuilder()
|
||||
.numHashingThreads(Runtime.getRuntime().availableProcessors() * 2)
|
||||
.createdBy(plugin.javaClass.name)
|
||||
.addFile(gameFilesPath)
|
||||
.rootPath(plugin.getRootPath(gameFilesPath))
|
||||
.announce(plugin.getTrackerUri().toString())
|
||||
.privateFlag(plugin.config("privateMode"))
|
||||
.build()
|
||||
val torrentBuilder = TorrentBuilder()
|
||||
|
||||
val trackerUrl = plugin.getTrackerUri().toString()
|
||||
val isPrivate = plugin.config<Boolean>("privateMode")
|
||||
val torrentVersions = plugin.config<TorrentVersion>("torrentVersions")
|
||||
|
||||
log.info("Creating ${if (isPrivate) "private" else "public"} ${if (torrentVersions !== TorrentVersion.`V1 and V2`) torrentVersions else ""} torrent with announce URL '$trackerUrl'")
|
||||
|
||||
val flags = when (torrentVersions) {
|
||||
TorrentVersion.`V1 only` -> v1_only
|
||||
TorrentVersion.`V2 only` -> v2_only
|
||||
TorrentVersion.`V1 and V2` -> null
|
||||
}
|
||||
|
||||
val builder = torrentBuilder.path(gameFilesPath.toFile())
|
||||
.creator("Gameyfin Torrent plugin v${plugin.wrapper.descriptor.version}")
|
||||
.addTracker(trackerUrl)
|
||||
.setPrivate(isPrivate)
|
||||
|
||||
if (flags != null) {
|
||||
builder.flags(flags)
|
||||
}
|
||||
|
||||
val builderResult = builder.generate()
|
||||
|
||||
return builderResult.entry().bencode()
|
||||
}
|
||||
}
|
||||
}
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
package org.gameyfin.plugins.download.torrent
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URLDecoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* A simple BitTorrent tracker implementation using HTTP protocol.
|
||||
* Implements the basic announce/scrape protocol as defined in BEP 3.
|
||||
* Supports hybrid torrents (BEP-52) by grouping v1 and v2 swarms using the key parameter (BEP-7).
|
||||
*
|
||||
* The key parameter remains constant for a peer across the same torrent's v1/v2 variants,
|
||||
* allowing the tracker to link related swarms and provide better peer discovery.
|
||||
*/
|
||||
class TorrentTracker(
|
||||
private val port: Int,
|
||||
private val announceInterval: Int
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(TorrentTracker::class.java)
|
||||
private var server: HttpServer? = null
|
||||
|
||||
// Map of info_hash -> peers
|
||||
private val torrents = ConcurrentHashMap<String, MutableSet<Peer>>()
|
||||
|
||||
// Map of key -> set of info_hashes (for linking hybrid torrent swarms)
|
||||
// The key parameter is per-torrent and stays the same across v1/v2 variants,
|
||||
// allowing us to group related swarms of the same hybrid torrent
|
||||
private val hybridTorrentGroups = ConcurrentHashMap<String, MutableSet<String>>()
|
||||
|
||||
data class Peer(
|
||||
val peerId: String,
|
||||
val ip: String,
|
||||
val port: Int,
|
||||
var uploaded: Long = 0,
|
||||
var downloaded: Long = 0,
|
||||
var left: Long = 0,
|
||||
var lastSeen: Long = System.currentTimeMillis(),
|
||||
val key: String? = null // BEP-7 key parameter - per-torrent identifier for grouping hybrid variants
|
||||
)
|
||||
|
||||
fun start() {
|
||||
server = HttpServer.create(InetSocketAddress(port), 0).apply {
|
||||
createContext("/announce") { exchange ->
|
||||
try {
|
||||
handleAnnounce(exchange)
|
||||
} catch (e: Exception) {
|
||||
log.error("Unhandled error in announce handler", e)
|
||||
try {
|
||||
respondBencodedError(exchange, "Internal server error")
|
||||
} catch (_: Exception) {
|
||||
// Ignore errors when trying to send error response
|
||||
}
|
||||
} finally {
|
||||
exchange.close()
|
||||
}
|
||||
}
|
||||
|
||||
createContext("/scrape") { exchange ->
|
||||
try {
|
||||
handleScrape(exchange)
|
||||
} catch (e: Exception) {
|
||||
log.error("Unhandled error in scrape handler", e)
|
||||
try {
|
||||
respondBencodedError(exchange, "Internal server error")
|
||||
} catch (_: Exception) {
|
||||
// Ignore errors when trying to send error response
|
||||
}
|
||||
} finally {
|
||||
exchange.close()
|
||||
}
|
||||
}
|
||||
|
||||
executor = Executors.newSingleThreadExecutor()
|
||||
start()
|
||||
}
|
||||
|
||||
log.info("Tracker started on port $port")
|
||||
|
||||
// Start cleanup task
|
||||
startCleanupTask()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val currentServer = server
|
||||
server = null
|
||||
|
||||
currentServer?.stop(2)
|
||||
|
||||
// Shutdown the executor service
|
||||
currentServer?.executor?.let { executor ->
|
||||
(executor as? java.util.concurrent.ExecutorService)?.let {
|
||||
it.shutdown()
|
||||
try {
|
||||
if (!it.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
it.shutdownNow()
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
it.shutdownNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Tracker stopped")
|
||||
}
|
||||
|
||||
private fun bytesToHex(bytes: String): String {
|
||||
return bytes.toByteArray(Charsets.ISO_8859_1).joinToString("") {
|
||||
"%02x".format(it.toInt() and 0xFF)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAnnounce(exchange: HttpExchange) {
|
||||
try {
|
||||
// Get raw query string from URI - we need to parse it ourselves to handle binary data
|
||||
// Using .query would give us a UTF-8 decoded string which corrupts binary info_hash
|
||||
val rawQuery = exchange.requestURI.rawQuery ?: ""
|
||||
|
||||
val params = parseQueryString(rawQuery)
|
||||
|
||||
// Required parameters
|
||||
val infoHash = params["info_hash"] ?: run {
|
||||
respondBencodedError(exchange, "Missing info_hash")
|
||||
return
|
||||
}
|
||||
|
||||
val peerId = params["peer_id"] ?: run {
|
||||
respondBencodedError(exchange, "Missing peer_id")
|
||||
return
|
||||
}
|
||||
val port = params["port"]?.toIntOrNull() ?: run {
|
||||
respondBencodedError(exchange, "Missing or invalid port")
|
||||
return
|
||||
}
|
||||
|
||||
// Optional parameters
|
||||
val uploaded = params["uploaded"]?.toLongOrNull() ?: 0
|
||||
val downloaded = params["downloaded"]?.toLongOrNull() ?: 0
|
||||
val left = params["left"]?.toLongOrNull() ?: 0
|
||||
val event = params["event"] // started, completed, stopped
|
||||
val key = params["key"] // BEP-7 key parameter - per-torrent identifier (same for v1/v2 variants)
|
||||
|
||||
// Get client IP from params or use remote address
|
||||
val ip = params["ip"] ?: run {
|
||||
val remoteAddress = exchange.remoteAddress.address.hostAddress
|
||||
log.debug("Param 'ip' not provided, falling back to remote host address ($remoteAddress) for peer $peerId")
|
||||
remoteAddress
|
||||
}
|
||||
|
||||
// Track hybrid torrent grouping if key is provided
|
||||
if (!key.isNullOrBlank()) {
|
||||
val relatedHashes = hybridTorrentGroups.computeIfAbsent(key) {
|
||||
ConcurrentHashMap.newKeySet()
|
||||
}
|
||||
relatedHashes.add(infoHash)
|
||||
|
||||
log.debug("Linked info_hash ${bytesToHex(infoHash)} with key $key (group has ${relatedHashes.size} hashes)")
|
||||
}
|
||||
|
||||
// Get or create torrent peer list
|
||||
val peers = torrents.computeIfAbsent(infoHash) {
|
||||
log.debug("New torrent tracked: ${bytesToHex(infoHash)}")
|
||||
ConcurrentHashMap.newKeySet()
|
||||
}
|
||||
|
||||
// Handle event
|
||||
when (event) {
|
||||
"stopped" -> {
|
||||
peers.removeIf { it.peerId == peerId }
|
||||
log.debug("Removed peer $peerId ($ip) from torrent ${bytesToHex(infoHash)}")
|
||||
|
||||
// Also remove from related hybrid torrent swarms if key is provided
|
||||
if (!key.isNullOrBlank()) {
|
||||
hybridTorrentGroups[key]?.forEach { relatedHash ->
|
||||
if (relatedHash != infoHash) {
|
||||
torrents[relatedHash]?.removeIf { it.peerId == peerId && it.key == key }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val existingPeer = peers.find { it.peerId == peerId }
|
||||
|
||||
if (existingPeer != null) {
|
||||
peers.remove(existingPeer)
|
||||
peers.add(Peer(peerId, ip, port, uploaded, downloaded, left, key = key))
|
||||
log.debug("Updated peer $peerId ($ip) for torrent ${bytesToHex(infoHash)}")
|
||||
} else {
|
||||
peers.add(Peer(peerId, ip, port, uploaded, downloaded, left, key = key))
|
||||
log.debug("Added peer $peerId ($ip) to torrent ${bytesToHex(infoHash)}")
|
||||
}
|
||||
|
||||
// Sync peer to related hybrid torrent swarms if key is provided
|
||||
if (!key.isNullOrBlank()) {
|
||||
hybridTorrentGroups[key]?.forEach { relatedHash ->
|
||||
if (relatedHash != infoHash) {
|
||||
val relatedPeers = torrents.computeIfAbsent(relatedHash) {
|
||||
ConcurrentHashMap.newKeySet()
|
||||
}
|
||||
relatedPeers.removeIf { it.peerId == peerId && it.key == key }
|
||||
relatedPeers.add(Peer(peerId, ip, port, uploaded, downloaded, left, key = key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build peer list from this swarm and related hybrid swarms
|
||||
val allPeers = mutableSetOf<Peer>()
|
||||
allPeers.addAll(peers)
|
||||
|
||||
// Include peers from related hybrid torrent swarms
|
||||
if (!key.isNullOrBlank()) {
|
||||
hybridTorrentGroups[key]?.forEach { relatedHash ->
|
||||
torrents[relatedHash]?.let { allPeers.addAll(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by peerId and exclude the requesting peer
|
||||
val peerList = allPeers
|
||||
.distinctBy { it.peerId }
|
||||
.filter { it.peerId != peerId }
|
||||
.take(50)
|
||||
|
||||
// Calculate stats across all related swarms
|
||||
val uniquePeers = allPeers.distinctBy { it.peerId }
|
||||
|
||||
// Send response
|
||||
respondBencodedAnnounce(
|
||||
exchange,
|
||||
interval = announceInterval,
|
||||
complete = uniquePeers.count { it.left == 0L },
|
||||
incomplete = uniquePeers.count { it.left > 0L },
|
||||
peers = peerList
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
log.error("Error handling announce", e)
|
||||
respondBencodedError(exchange, "Internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleScrape(exchange: HttpExchange) {
|
||||
try {
|
||||
val params = parseQueryString(exchange.requestURI.rawQuery ?: "")
|
||||
val infoHashes = params.entries
|
||||
.filter { it.key == "info_hash" }
|
||||
.map { it.value }
|
||||
|
||||
val response = buildString {
|
||||
append("d5:filesd")
|
||||
|
||||
if (infoHashes.isEmpty()) {
|
||||
// Scrape all torrents
|
||||
torrents.forEach { (infoHash, peers) ->
|
||||
appendTorrentStats(infoHash, peers)
|
||||
}
|
||||
} else {
|
||||
// Scrape specific torrents
|
||||
infoHashes.forEach { infoHash ->
|
||||
torrents[infoHash]?.let { peers ->
|
||||
appendTorrentStats(infoHash, peers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
append("ee")
|
||||
}
|
||||
|
||||
respondBytes(exchange, response.toByteArray(Charsets.ISO_8859_1))
|
||||
|
||||
} catch (e: Exception) {
|
||||
log.error("Error handling scrape", e)
|
||||
respondBencodedError(exchange, "Internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendTorrentStats(infoHash: String, peers: Set<Peer>) {
|
||||
val complete = peers.count { it.left == 0L }
|
||||
val incomplete = peers.count { it.left > 0L }
|
||||
val downloaded = peers.count() // Total number of times completed
|
||||
|
||||
append("20:") // info_hash is always 20 bytes
|
||||
append(infoHash)
|
||||
append("d")
|
||||
append("8:completei${complete}e")
|
||||
append("10:downloadedi${downloaded}e")
|
||||
append("10:incompletei${incomplete}e")
|
||||
append("e")
|
||||
}
|
||||
|
||||
private fun respondBencodedError(exchange: HttpExchange, message: String) {
|
||||
val response = "d14:failure reason${message.length}:${message}e"
|
||||
respondBytes(exchange, response.toByteArray(Charsets.ISO_8859_1))
|
||||
}
|
||||
|
||||
private fun respondBencodedAnnounce(
|
||||
exchange: HttpExchange,
|
||||
interval: Int,
|
||||
complete: Int,
|
||||
incomplete: Int,
|
||||
peers: List<Peer>
|
||||
) {
|
||||
val response = buildString {
|
||||
append("d")
|
||||
append("8:intervali${interval}e")
|
||||
append("8:completei${complete}e")
|
||||
append("10:incompletei${incomplete}e")
|
||||
|
||||
// Compact peer list (binary format)
|
||||
append("5:peers")
|
||||
val peerBytes = ByteBuffer.allocate(peers.size * 6)
|
||||
peers.forEach { peer ->
|
||||
val ipParts = peer.ip.split(".")
|
||||
if (ipParts.size == 4) {
|
||||
ipParts.forEach { peerBytes.put(it.toInt().toByte()) }
|
||||
peerBytes.putShort(peer.port.toShort())
|
||||
}
|
||||
}
|
||||
val compactPeers = peerBytes.array().copyOf(peerBytes.position())
|
||||
append("${compactPeers.size}:")
|
||||
append(String(compactPeers, Charsets.ISO_8859_1))
|
||||
|
||||
append("e")
|
||||
}
|
||||
|
||||
respondBytes(exchange, response.toByteArray(Charsets.ISO_8859_1))
|
||||
}
|
||||
|
||||
private fun respondBytes(exchange: HttpExchange, bytes: ByteArray) {
|
||||
exchange.responseHeaders.set("Content-Type", "text/plain")
|
||||
exchange.sendResponseHeaders(200, bytes.size.toLong())
|
||||
exchange.responseBody.write(bytes)
|
||||
exchange.responseBody.flush()
|
||||
}
|
||||
|
||||
private fun parseQueryString(query: String): Map<String, String> {
|
||||
if (query.isEmpty()) return emptyMap()
|
||||
|
||||
return query.split("&")
|
||||
.mapNotNull { param ->
|
||||
val parts = param.split("=", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val key = URLDecoder.decode(parts[0], "UTF-8")
|
||||
// Use ISO-8859-1 for binary parameters (info_hash, peer_id)
|
||||
// to preserve binary data without UTF-8 corruption
|
||||
val value = if (key == "info_hash" || key == "peer_id") {
|
||||
urlDecodeToBytes(parts[1])
|
||||
} else {
|
||||
URLDecoder.decode(parts[1], "UTF-8")
|
||||
}
|
||||
key to value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a URL-encoded string to raw bytes, preserving binary data.
|
||||
* Unlike URLDecoder.decode(), this uses ISO-8859-1 to preserve binary data.
|
||||
*/
|
||||
private fun urlDecodeToBytes(encoded: String): String {
|
||||
val bytes = mutableListOf<Byte>()
|
||||
var i = 0
|
||||
while (i < encoded.length) {
|
||||
when {
|
||||
encoded[i] == '%' && i + 2 < encoded.length -> {
|
||||
// Decode %XX to a byte
|
||||
val hex = encoded.substring(i + 1, i + 3)
|
||||
bytes.add(hex.toInt(16).toByte())
|
||||
i += 3
|
||||
}
|
||||
|
||||
encoded[i] == '+' -> {
|
||||
// Plus sign represents space in URL encoding
|
||||
bytes.add(' '.code.toByte())
|
||||
i++
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Regular character
|
||||
bytes.add(encoded[i].code.toByte())
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
// Convert bytes to String using ISO-8859-1 (preserves binary data)
|
||||
return String(bytes.toByteArray(), Charsets.ISO_8859_1)
|
||||
}
|
||||
|
||||
private fun startCleanupTask() {
|
||||
// Simple cleanup - in production you'd want a scheduled executor
|
||||
Thread {
|
||||
while (server != null) {
|
||||
try {
|
||||
Thread.sleep(TimeUnit.MINUTES.toMillis(5))
|
||||
cleanupStalePeers()
|
||||
} catch (_: InterruptedException) {
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
log.error("Error in cleanup task", e)
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
isDaemon = true
|
||||
name = "tracker-cleanup"
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupStalePeers() {
|
||||
val staleThreshold = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2)
|
||||
|
||||
torrents.forEach { (infoHash, peers) ->
|
||||
val initialSize = peers.size
|
||||
peers.removeIf { it.lastSeen < staleThreshold }
|
||||
val removed = initialSize - peers.size
|
||||
|
||||
if (removed > 0) {
|
||||
log.debug("Removed $removed stale peers from torrent $infoHash")
|
||||
}
|
||||
|
||||
if (peers.isEmpty()) {
|
||||
torrents.remove(infoHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package org.gameyfin.plugins.download.torrent
|
||||
|
||||
enum class TorrentVersion {
|
||||
`V1 only`,
|
||||
`V2 only`,
|
||||
`V1 and V2`
|
||||
}
|
||||
@@ -12,4 +12,19 @@ fun Duration.asHumanReadable(): String {
|
||||
append("${seconds}s")
|
||||
}.trim()
|
||||
}
|
||||
}
|
||||
|
||||
fun getContainerOS(): String {
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("cat", "/etc/os-release"))
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
val lines = output.lines()
|
||||
for (line in lines) {
|
||||
if (line.startsWith("ID=")) {
|
||||
return line.substringAfter("=")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
Plugin-Version: 1.0.1
|
||||
Plugin-Version: 2.0.0
|
||||
Plugin-Class: org.gameyfin.plugins.download.torrent.TorrentDownloadPlugin
|
||||
Plugin-Id: org.gameyfin.plugins.download.torrent
|
||||
Plugin-Name: Torrent Download
|
||||
|
||||
Reference in New Issue
Block a user