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
+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