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:
Simon
2025-11-17 08:45:39 +01:00
committed by GitHub
parent dd3b18e5e3
commit 717a423449
357 changed files with 39213 additions and 7918 deletions
+26 -9
View File
@@ -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")
}
}
@@ -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
}
}
@@ -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
+5 -11
View File
@@ -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
}
}
}
@@ -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}")
}
}
}
@@ -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
}
}
}
@@ -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 -1
View File
@@ -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
@@ -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})"
)
}
}
+12
View File
@@ -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
}
}
}
}
@@ -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,
@@ -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
)
@@ -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 -1
View File
@@ -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
@@ -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()
)
}
}
+33 -11
View File
@@ -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")
}
@@ -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.")
}
}
}
}
}
@@ -0,0 +1,8 @@
package org.gameyfin.plugins.download.torrent
@Suppress("EnumEntryName")
enum class TorrentClientPerformanceMode {
`Minimal Memory Usage`,
Balanced,
`High Performance`
}
@@ -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()
}
}
}
@@ -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)
}
}
}
}
@@ -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