mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-17 00:30:04 +00:00
Release v2.2.0 (#741)
* Migrate to TailwindCSS v4 (#740) * Remove "material-tailwind" dependencies due to incompatibility of Stepper component with Tailwind v4 * Clean up Tailwind configs before upgrade * Run HeroUI upgrade * Run TailwindCSS upgrade * Replace PostCSS with Vite * Migrate custom styles to v4 * Remove tailwind.config.ts * Add heroui.ts Add tailwind vite plugin * Fix small UI color inconsistency * Fix theming system Rename purple theme to pink * Re-implement stepper in HeroUI * Fix RoleChip colors * Migrate icon names (#743) * Add migration script for phosphor-icons * Migrate icon usages * Update version to 2.2.0-preview * Revert accidental rename of menu title * Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#750) Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Improve library scanning (#749) * Update script to generate example libraries using SteamSpy API * Refactor library scanning process * Display Flyway startup log by default * Fix race condition in CompanyService * Fix race condition in ImageService Remove obsolete table * Fix SMTP config requiring an email as username (#755) * Disable length limit for config values (#757) * Deprecate DockerHub image (#759) * Remove deprecation warning from web UI * Reworked the CICD pipelines * Optimize container image (#761) * Fix Gradle warning * Rework Docker image to improve layer caching * Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#765) Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Multi platform support (#764) * Remove migrate-phosphor-icons.js since migration has been successful * Refactor GameMetadata into separate files * Add Platform enum * Implement platform support in Plugin API * Implement platform support in Steam Plugin * Implement platform support in IGDB Plugin * Add database migration for platform support * Implement platform support in GameService * Implement platform support on most endpoints and features, some are still missing Implemented platform support in all bundled plugins (although not finished polishing yet) * Implement platforms in UI * Make GameRequest platform aware * Return headerImages from IGDB * Implement proper PlatformMapper for IGDB plugin * Fix various smaller issues and inconsistencies * Replace placeholder in LibraryOverviewCard (#767) * Bump actions/download-artifact from 5 to 6 (#769) * Bump actions/upload-artifact from 4 to 5 (#770) * Multi platform support (#773) * Fix bug in Plugin API related to state loading/saving * Hide Flyway query logs by default * Extend migration script for multi platform tables * Plugins now store their data and state in ./plugindata * Add "plugindata" directory to entrypoint scripts * Improve download handling (#756) * Process download in background thread to avoid session timeout affecting it * Increase default session timeout to 24h * Use virtual thread pool for download task in background * Make KSP extensions.idx generation more robust * Implement download bandwidth limiter Implement SliderInput Refactor NumberInput * Implement download bandwidth throttling Implement real-time download monitoring * Improve UI for DownloadManagement Track more stats in SessionStats * Update Hilla Use React 19 * Implement real-time graph to track bandwidth usage Implement downloaded data sum over last day Small bug fixes Small refactorings * Update docker-compose.example.yml * Improve DownloadSessionCard (#784) * Fix unit on y-axis of download graph * Show game size and library in tooltip Make game chips interactive in DownloadSessionCard (leads to game page when clicked) Optimize graph settings * Migrate torrent plugin to libtorrent (#775) * Disable TorrentDownloadPlugin in Alpine based Docker image * Improve test coverage (#785) * Fix potential divide by zero bug * Add mockk dependency * Add tests for org.gameyfin.app.core.download * Add tests for Filesytem package Fix DownloadServiceTest * Fix FilesystemServiceTest * Add tests for "job" package * Upgrade Gradle wrapper Enable Gradle config cache * Added more tests * Added tests for the "security" package * Add tests for "game" package * Fix AsyncFileTailer not shutting down properly on Windows * Fix GameServiceTest * Added tests for "libraries" package * Added tests for "media" package * Fix warning in ImageService * Add tests fpr "messages" package Make sure transport is closed even in case an exception is thrown * Add tests for "platforms" package * Add tests for "requests" package * Moved "token" package to "core" package (from "shared") * Add tests for "token" package * Fix issue in RoleEnum.safeValueOf() throwing Exception * Fix potential issue in UserEndpoint.getUserInfo() when auth is null * Added tests for "user" package * Migrate package for "token" in FE * Publish test report in CI * Fix workflow permissions * Remove test because of timing issue in CI * Replaced "unmatched paths" with "ignored paths" (#791) * Use new "AutoComplete" component (#793) * Use ArrayInputAutocomplete in EditGameMetadataModal * Add test for getEnumPropertyValues --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,23 +1,45 @@
|
||||
val jlibtorrentVersion = "2.0.12.7"
|
||||
|
||||
plugins {
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven { setUrl("https://jitpack.io") }
|
||||
maven { setUrl("https://repository.jboss.org") }
|
||||
maven {
|
||||
setUrl("https://dl.frostwire.com/maven")
|
||||
content {
|
||||
includeGroup("com.frostwire")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
ksp("care.better.pf4j:pf4j-kotlin-symbol-processing:${rootProject.extra["pf4jKspVersion"]}")
|
||||
|
||||
// Torrent tracker & seeder
|
||||
implementation("com.github.mpetazzoni:ttorrent:ttorrent-2.0") {
|
||||
exclude(group = "org.slf4j")
|
||||
}
|
||||
implementation("com.frostwire:jlibtorrent:$jlibtorrentVersion")
|
||||
implementation("com.frostwire:jlibtorrent-windows:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-macosx-x86_64:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-macosx-arm64:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-linux-x86_64:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-linux-arm64:${jlibtorrentVersion}")
|
||||
}
|
||||
|
||||
// Torrent file builder
|
||||
implementation("com.github.atomashpolskiy:bt-core:1.10") {
|
||||
exclude(group = "org.slf4j")
|
||||
// Extract native libraries from jlibtorrent JARs for local debugging
|
||||
tasks.register<Copy>("extractNativeLibraries") {
|
||||
dependsOn(tasks.named("compileKotlin"))
|
||||
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
|
||||
from(configurations.runtimeClasspath.get().map { project.zipTree(it) }) {
|
||||
include("lib/**")
|
||||
}
|
||||
}
|
||||
into(layout.buildDirectory.get().asFile.resolve("classes/kotlin/main"))
|
||||
}
|
||||
|
||||
tasks.named("classes") {
|
||||
dependsOn("extractNativeLibraries")
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
dependsOn("extractNativeLibraries")
|
||||
}
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package org.gameyfin.plugins.download.torrent
|
||||
|
||||
import com.frostwire.jlibtorrent.SessionManager
|
||||
import com.frostwire.jlibtorrent.SessionParams
|
||||
import com.frostwire.jlibtorrent.SettingsPack
|
||||
import com.frostwire.jlibtorrent.TorrentHandle.QUERY_DISTRIBUTED_COPIES
|
||||
import com.frostwire.jlibtorrent.TorrentHandle.QUERY_NAME
|
||||
import com.frostwire.jlibtorrent.TorrentInfo
|
||||
import com.frostwire.jlibtorrent.swig.libtorrent.*
|
||||
import com.frostwire.jlibtorrent.swig.settings_pack.string_types
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.net.InetAddress
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* A BitTorrent client implementation using jlibtorrent.
|
||||
* Handles torrent session management, seeding, and monitoring.
|
||||
*/
|
||||
class TorrentClient(
|
||||
private val listenPort: Int,
|
||||
private val externalHost: String?,
|
||||
private val performanceMode: TorrentClientPerformanceMode,
|
||||
private val dhtEnabled: Boolean,
|
||||
private val lsdEnabled: Boolean,
|
||||
private val stopSeedingWhenComplete: Boolean
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(TorrentClient::class.java)
|
||||
|
||||
private var session: SessionManager? = null
|
||||
private var monitorExecutor: ScheduledExecutorService? = null
|
||||
|
||||
companion object {
|
||||
private const val INTERNAL_PEER_ID_PREFIX = "-GF0001-"
|
||||
}
|
||||
|
||||
fun start() {
|
||||
// Initialize session
|
||||
session = initSession()
|
||||
|
||||
if (stopSeedingWhenComplete) {
|
||||
startMonitoringTask()
|
||||
}
|
||||
|
||||
log.info("TorrentClient started")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
monitorExecutor?.shutdown()
|
||||
|
||||
try {
|
||||
monitorExecutor?.awaitTermination(5, TimeUnit.SECONDS)
|
||||
} catch (_: InterruptedException) {
|
||||
monitorExecutor?.shutdownNow()
|
||||
}
|
||||
monitorExecutor = null
|
||||
|
||||
session?.stop()
|
||||
|
||||
log.info("TorrentClient stopped")
|
||||
}
|
||||
|
||||
fun addTorrent(torrentFile: Path, gameFile: Path) {
|
||||
val ti = TorrentInfo(torrentFile.toFile())
|
||||
|
||||
// For seeding, we need to use the parent directory as the save path
|
||||
val savePath = gameFile.parent.toFile()
|
||||
|
||||
// Check if torrent is already in session
|
||||
val existingHandle = session?.find(ti)
|
||||
if (existingHandle != null && existingHandle.isValid) {
|
||||
log.debug("Torrent ${ti.name()} is already in session, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify file access before adding to session
|
||||
if (!Files.isReadable(gameFile)) {
|
||||
log.error("Cannot read game file for seeding: $gameFile - check file permissions")
|
||||
throw IllegalStateException("Game file is not readable: $gameFile")
|
||||
}
|
||||
|
||||
try {
|
||||
// Use SessionManager's download method - it will seed the files in the save directory
|
||||
session?.download(ti, savePath)
|
||||
log.info("Added torrent to session for seeding: ${ti.name()} from $savePath")
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to add torrent to session for seeding: ${ti.name()}", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun initSession(): SessionManager {
|
||||
// This method is always called from the session executor thread
|
||||
// Return existing session if already initialized
|
||||
session?.let { return it }
|
||||
|
||||
// Initialize jlibtorrent session
|
||||
val sessionManager = SessionManager()
|
||||
|
||||
// Configure session settings based on performance mode
|
||||
val settingsPack = when (performanceMode) {
|
||||
TorrentClientPerformanceMode.Balanced -> SettingsPack(default_settings())
|
||||
TorrentClientPerformanceMode.`High Performance` -> SettingsPack(high_performance_seed())
|
||||
TorrentClientPerformanceMode.`Minimal Memory Usage` -> SettingsPack(min_memory_usage())
|
||||
}
|
||||
log.info("Configured TorrentClient with performance mode: $performanceMode")
|
||||
|
||||
|
||||
// Set custom peer ID prefix for our internal client
|
||||
// This allows us to identify this specific client if needed
|
||||
settingsPack.peerFingerprint = INTERNAL_PEER_ID_PREFIX.toByteArray()
|
||||
|
||||
// Configure interfaces
|
||||
settingsPack.listenInterfaces("0.0.0.0:$listenPort,[::]:$listenPort")
|
||||
|
||||
// Configure announce IP if externalHost is set
|
||||
if (externalHost != null && externalHost.isNotBlank()) {
|
||||
try {
|
||||
val resolvedIp = InetAddress.getByName(externalHost).hostAddress
|
||||
settingsPack.setString(string_types.announce_ip.swigValue(), resolvedIp)
|
||||
log.info("Configured client announce IP to: $resolvedIp (from external host: $externalHost)")
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to resolve external host '$externalHost' for client IP", e)
|
||||
}
|
||||
} else {
|
||||
log.info("No external host override set; using default announce IP behavior")
|
||||
}
|
||||
|
||||
// Configure DHT
|
||||
settingsPack.isEnableDht = dhtEnabled
|
||||
|
||||
// Configure Local Peer Discovery
|
||||
settingsPack.isEnableLsd = lsdEnabled
|
||||
|
||||
val sessionParams = SessionParams(settingsPack)
|
||||
|
||||
// Configure disk I/O based on operating system
|
||||
// This must be done because libtorrent 2.0 uses memory mapped files which conflict with java runtime handlers
|
||||
// resulting in SIGSEGV crashes
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
when {
|
||||
os.contains("win") -> {
|
||||
sessionParams.setDefaultDiskIO()
|
||||
log.info("Configured disk I/O for Windows (default disk I/O)")
|
||||
}
|
||||
|
||||
os.contains("nix") || os.contains("nux") || os.contains("mac") || os.contains("darwin") -> {
|
||||
sessionParams.setPosixDiskIO()
|
||||
log.info("Configured disk I/O for Unix-like system (POSIX disk I/O)")
|
||||
}
|
||||
|
||||
else -> {
|
||||
log.info("Unknown OS '$os', using default disk I/O settings")
|
||||
}
|
||||
}
|
||||
|
||||
sessionManager.start(sessionParams)
|
||||
|
||||
// Log the listening status
|
||||
log.info("BitTorrent client started. Listen interfaces: ${settingsPack.listenInterfaces()}")
|
||||
|
||||
return sessionManager
|
||||
}
|
||||
|
||||
private fun startMonitoringTask() {
|
||||
monitorExecutor = Executors.newSingleThreadScheduledExecutor()
|
||||
monitorExecutor?.scheduleWithFixedDelay({
|
||||
try {
|
||||
checkAndStopCompletedTorrents()
|
||||
} catch (e: Exception) {
|
||||
log.error("Error checking torrent completion status", e)
|
||||
}
|
||||
}, 60, 60, TimeUnit.SECONDS) // Check every 60 seconds
|
||||
}
|
||||
|
||||
private fun checkAndStopCompletedTorrents() {
|
||||
val handles = session?.torrentHandles ?: return
|
||||
|
||||
handles.forEach { handle ->
|
||||
if (!handle.isValid) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val status = handle.status(QUERY_DISTRIBUTED_COPIES.or_(QUERY_NAME))
|
||||
|
||||
// Only check torrents that we are seeding
|
||||
if (status.isFinished) {
|
||||
val knownSeeders = status.listSeeds()
|
||||
val completePeersFromTracker = status.numComplete()
|
||||
val distributedFullCopies = status.distributedFullCopies()
|
||||
|
||||
// If there are other seeders or complete peers, stop seeding
|
||||
if (distributedFullCopies > 0) {
|
||||
log.debug("Stopping seeding for torrent '${status.name()}' as it is healthy: $distributedFullCopies distributed full copies.")
|
||||
session?.remove(handle)
|
||||
} else if (completePeersFromTracker > 1) {
|
||||
log.debug("Stopping seeding for torrent '${status.name()}' as it is healthy: $completePeersFromTracker complete peers from tracker.")
|
||||
session?.remove(handle)
|
||||
} else if (knownSeeders > 0) {
|
||||
log.debug("Stopping seeding for torrent '${status.name()}' as it is healthy: $knownSeeders known seeders.")
|
||||
session?.remove(handle)
|
||||
} else {
|
||||
log.debug("Continuing to seed torrent '${status.name()}' - no other complete peers found.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package org.gameyfin.plugins.download.torrent
|
||||
|
||||
@Suppress("EnumEntryName")
|
||||
enum class TorrentClientPerformanceMode {
|
||||
`Minimal Memory Usage`,
|
||||
Balanced,
|
||||
`High Performance`
|
||||
}
|
||||
+208
-115
@@ -1,12 +1,8 @@
|
||||
package org.gameyfin.plugins.download.torrent
|
||||
|
||||
import bt.torrent.maker.TorrentBuilder
|
||||
import com.turn.ttorrent.client.CommunicationManager
|
||||
import com.turn.ttorrent.client.SelectorFactoryImpl
|
||||
import com.turn.ttorrent.client.storage.FullyPieceStorageFactory
|
||||
import com.turn.ttorrent.network.FirstAvailableChannel
|
||||
import com.turn.ttorrent.tracker.TrackedTorrent
|
||||
import com.turn.ttorrent.tracker.Tracker
|
||||
import com.frostwire.jlibtorrent.TorrentBuilder
|
||||
import com.frostwire.jlibtorrent.swig.create_torrent.v1_only
|
||||
import com.frostwire.jlibtorrent.swig.create_torrent.v2_only
|
||||
import org.gameyfin.pluginapi.core.config.ConfigMetadata
|
||||
import org.gameyfin.pluginapi.core.config.PluginConfigMetadata
|
||||
import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult
|
||||
@@ -21,20 +17,17 @@ import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.io.path.*
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
|
||||
|
||||
companion object {
|
||||
private val TORRENT_FILE_DIRECTORY = Path.of("torrent_dotfiles")
|
||||
private lateinit var tracker: Tracker
|
||||
private lateinit var communicationManager: CommunicationManager
|
||||
|
||||
private lateinit var plugin: TorrentDownloadPlugin
|
||||
|
||||
private lateinit var state: TorrentDownloadPluginState
|
||||
|
||||
private var client: TorrentClient? = null
|
||||
private var tracker: TorrentTracker? = null
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -43,102 +36,194 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin
|
||||
|
||||
override val configMetadata: PluginConfigMetadata = listOf(
|
||||
ConfigMetadata(
|
||||
key = "trackerPort",
|
||||
label = "Tracker Port",
|
||||
description = "Which port the torrent tracker should use",
|
||||
type = Int::class.java,
|
||||
default = 6969
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "clientPort",
|
||||
label = "Seed Client Port",
|
||||
description = "Which port the seed client should use",
|
||||
type = Int::class.java,
|
||||
default = 6881
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "externalHost",
|
||||
label = "Hostname/IP override",
|
||||
description = "Overrides the external host (e.g., if behind NAT)",
|
||||
type = String::class.java,
|
||||
isRequired = false
|
||||
key = "stopSeedingWhenComplete",
|
||||
label = "Stop Seeding When Complete",
|
||||
description = "Automatically stop seeding torrents once there are other peers with all pieces (torrent is healthy)",
|
||||
type = Boolean::class.java,
|
||||
default = false
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "privateMode",
|
||||
label = "Create torrents with private mode enabled",
|
||||
description = "Enables private mode for the torrent tracker according to BEP-27",
|
||||
description = "Enables private mode for torrents according to BEP-27",
|
||||
type = Boolean::class.java,
|
||||
default = true
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "dhtEnabled",
|
||||
label = "Enable DHT",
|
||||
description = "Enable Distributed Hash Table for peer discovery",
|
||||
type = Boolean::class.java,
|
||||
default = false
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "lsdEnabled",
|
||||
label = "Enable LSD",
|
||||
description = "Enable Local Service Discovery for finding peers on the local network",
|
||||
type = Boolean::class.java,
|
||||
default = false
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "torrentVersions",
|
||||
label = "Torrent Protocol Versions",
|
||||
description = "Which torrent protocol versions to support (some clients don't support v2)",
|
||||
type = TorrentVersion::class.java,
|
||||
default = TorrentVersion.`V1 and V2`
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "performanceMode",
|
||||
label = "Torrent Client Performance Mode",
|
||||
description = "Optimizes the torrent client for either low resource usage or high performance",
|
||||
type = TorrentClientPerformanceMode::class.java,
|
||||
default = TorrentClientPerformanceMode.Balanced
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "externalHost",
|
||||
label = "Hostname/IP override",
|
||||
description = "Overrides the external host for the built-in tracker (e.g., if behind NAT/Docker)",
|
||||
type = String::class.java,
|
||||
isRequired = false
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "listenPort",
|
||||
label = "Listen Port",
|
||||
description = "Which port the built-in torrent client should listen on",
|
||||
type = Int::class.java,
|
||||
default = 6881
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "trackerPort",
|
||||
label = "Tracker Port",
|
||||
description = "Which port the built-in tracker should listen on",
|
||||
type = Int::class.java,
|
||||
default = 6969
|
||||
),
|
||||
ConfigMetadata(
|
||||
key = "announceInterval",
|
||||
label = "Tracker Announce Interval (in seconds)",
|
||||
description = "Interval for clients to re-announce to the tracker",
|
||||
type = Int::class.java,
|
||||
default = 1800
|
||||
)
|
||||
)
|
||||
|
||||
override fun start() {
|
||||
Files.createDirectories(dataDirectory)
|
||||
|
||||
Files.createDirectories(TORRENT_FILE_DIRECTORY)
|
||||
tracker = initTracker()
|
||||
|
||||
tracker = Tracker(config("trackerPort"), getTrackerUri().toString())
|
||||
tracker.setAcceptForeignTorrents(false)
|
||||
tracker.start(true)
|
||||
|
||||
val workingExecutor = Executors.newVirtualThreadPerTaskExecutor()
|
||||
val validationExecutor = Executors.newVirtualThreadPerTaskExecutor()
|
||||
val clientPort = config<Int>("clientPort")
|
||||
communicationManager = CommunicationManager(workingExecutor, validationExecutor)
|
||||
communicationManager.start(
|
||||
arrayOf(getHostname()),
|
||||
15,
|
||||
getTrackerUri(),
|
||||
SelectorFactoryImpl(),
|
||||
FirstAvailableChannel(clientPort, clientPort)
|
||||
)
|
||||
client = initClient()
|
||||
|
||||
state = loadState<TorrentDownloadPluginState>() ?: TorrentDownloadPluginState()
|
||||
|
||||
state.torrentFilesMetadata.forEach {
|
||||
// Check if the torrent and game files exist and
|
||||
// that the game files have not been modified since the torrent file was created
|
||||
if (Files.exists(it.torrentFile) && Files.exists(it.gameFile) &&
|
||||
it.gameFile.getLastModifiedTime().toInstant().isBefore(it.torrentFile.getLastModifiedTime().toInstant())
|
||||
) {
|
||||
tracker.announce(TrackedTorrent.load(it.torrentFile.toFile()))
|
||||
communicationManager.addTorrent(
|
||||
it.torrentFile.toString(),
|
||||
getRootPath(it.gameFile).toString(),
|
||||
FullyPieceStorageFactory.INSTANCE
|
||||
)
|
||||
// Restore existing torrents and remove invalid ones
|
||||
state.torrentFilesMetadata.removeIf { metadata ->
|
||||
val shouldRemove = !Files.exists(metadata.torrentFile) ||
|
||||
!Files.exists(metadata.gameFile) ||
|
||||
metadata.gameFile.getLastModifiedTime().toInstant()
|
||||
.isAfter(metadata.torrentFile.getLastModifiedTime().toInstant())
|
||||
|
||||
if (shouldRemove) {
|
||||
true
|
||||
} else {
|
||||
state.torrentFilesMetadata.remove(it)
|
||||
try {
|
||||
client?.addTorrent(metadata.torrentFile, metadata.gameFile)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to add torrent ${metadata.torrentFile} to session", e)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveState(state)
|
||||
}
|
||||
|
||||
private fun initClient(): TorrentClient {
|
||||
val client = TorrentClient(
|
||||
listenPort = config("listenPort"),
|
||||
externalHost = optionalConfig("externalHost"),
|
||||
performanceMode = config("performanceMode"),
|
||||
dhtEnabled = config("dhtEnabled"),
|
||||
lsdEnabled = config("lsdEnabled"),
|
||||
stopSeedingWhenComplete = config("stopSeedingWhenComplete")
|
||||
)
|
||||
|
||||
client.start()
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
private fun initTracker(): TorrentTracker {
|
||||
val tracker = TorrentTracker(
|
||||
port = config("trackerPort"),
|
||||
announceInterval = config("announceInterval")
|
||||
)
|
||||
|
||||
tracker.start()
|
||||
|
||||
return tracker
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
tracker.stop()
|
||||
communicationManager.stop()
|
||||
client?.stop()
|
||||
client = null
|
||||
|
||||
tracker?.stop()
|
||||
tracker = null
|
||||
}
|
||||
|
||||
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
|
||||
|
||||
val errors = mutableMapOf<String, String>()
|
||||
|
||||
// Plugin is not compatible with Alpine Docker images due to missing glibc
|
||||
if (System.getenv("RUNTIME_ENV") == "docker") {
|
||||
if (getContainerOS() == "alpine") {
|
||||
errors["stopSeedingWhenComplete"] = " "
|
||||
errors["privateMode"] = " "
|
||||
errors["dhtEnabled"] = " "
|
||||
errors["lsdEnabled"] = " "
|
||||
errors["torrentVersions"] = " "
|
||||
errors["performanceMode"] = " "
|
||||
errors["externalHost"] = " "
|
||||
errors["listenPort"] = " "
|
||||
errors["trackerPort"] = " "
|
||||
errors["announceInterval"] =
|
||||
"The torrent plugin is not compatible with the Alpine-based Docker image. Please use the Ubuntu-based Docker image if you want to use the Torrent plugin."
|
||||
return PluginConfigValidationResult.INVALID(errors)
|
||||
}
|
||||
}
|
||||
|
||||
val configValidationResult = super.validateConfig(config)
|
||||
if (!configValidationResult.isValid()) {
|
||||
return configValidationResult
|
||||
}
|
||||
|
||||
val errors = mutableMapOf<String, String>()
|
||||
val listenPort = config["listenPort"]?.toIntOrNull()
|
||||
if (listenPort != null && listenPort !in 1024..65535) {
|
||||
errors["listenPort"] = "Must be a valid port number between 1024 and 65535."
|
||||
}
|
||||
|
||||
val trackerPort = config["trackerPort"]?.toIntOrNull()
|
||||
if (trackerPort != null && trackerPort !in 1024..49151) {
|
||||
errors["trackerPort"] = "Must be a valid port number between 1024 and 49151."
|
||||
if (trackerPort != null && trackerPort !in 1024..65535) {
|
||||
errors["trackerPort"] = "Must be a valid port number between 1024 and 65535."
|
||||
}
|
||||
|
||||
val externalHost = config["externalHost"]
|
||||
if (externalHost != null) {
|
||||
if (!externalHost.isNullOrBlank()) {
|
||||
try {
|
||||
InetAddress.getByName(externalHost)
|
||||
} catch (_: Exception) {
|
||||
errors["externalHost"] = "Must be a valid hostname or IP address."
|
||||
}
|
||||
} else if (System.getenv("RUNTIME_ENV") == "docker") {
|
||||
errors["externalHost"] = "Must be set when running in Docker."
|
||||
}
|
||||
|
||||
val announceInterval = config["announceInterval"]?.toIntOrNull()
|
||||
if (announceInterval != null && announceInterval <= 0) {
|
||||
errors["announceInterval"] = "Must be a positive integer."
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
@@ -149,27 +234,14 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin
|
||||
}
|
||||
|
||||
private fun getTrackerUri(): URI {
|
||||
val protocol = "http" // No SSL support in ttorrent: https://github.com/mpetazzoni/ttorrent/issues/4
|
||||
val host = getHostname().getHostName()
|
||||
val protocol = "http"
|
||||
val host = optionalConfig("externalHost") ?: InetAddress.getLocalHost().hostAddress
|
||||
val port = config<Int>("trackerPort")
|
||||
val path = "announce"
|
||||
|
||||
return URI.create("$protocol://$host:$port/$path")
|
||||
}
|
||||
|
||||
private fun getHostname(): InetAddress {
|
||||
return InetAddress.getByName(
|
||||
optionalConfig("externalHost") ?: InetAddress.getLocalHost().hostAddress
|
||||
)
|
||||
}
|
||||
|
||||
private fun getRootPath(gameFilesPath: Path): Path {
|
||||
return if (gameFilesPath.isDirectory()) {
|
||||
gameFilesPath
|
||||
} else {
|
||||
gameFilesPath.parent
|
||||
}
|
||||
}
|
||||
|
||||
@Extension(ordinal = 2)
|
||||
class TorrentDownloadProvider : DownloadProvider {
|
||||
@@ -179,7 +251,7 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin
|
||||
log.info("Creating torrent for '${path.name}'...")
|
||||
|
||||
val (torrentFile, timeTaken) = measureTimedValue {
|
||||
createTorrent(path)
|
||||
initNewTorrent(path)
|
||||
}
|
||||
|
||||
log.info("Created torrent '${torrentFile.name}' in ${timeTaken.asHumanReadable()}")
|
||||
@@ -191,45 +263,66 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTorrent(gameFilesPath: Path): Path {
|
||||
val torrentFile =
|
||||
TORRENT_FILE_DIRECTORY.resolve("${gameFilesPath.nameWithoutExtension}-${gameFilesPath.hashCode()}.torrent")
|
||||
private fun initNewTorrent(gameFilesPath: Path): Path {
|
||||
val torrentFile = plugin.dataDirectory
|
||||
.resolve("${gameFilesPath.nameWithoutExtension}-${gameFilesPath.hashCode()}.torrent")
|
||||
|
||||
if (Files.exists(torrentFile)) {
|
||||
return torrentFile
|
||||
val isNewTorrent = !Files.exists(torrentFile)
|
||||
|
||||
if (isNewTorrent) {
|
||||
Files.createFile(torrentFile)
|
||||
Files.write(torrentFile, torrentFileContent(gameFilesPath))
|
||||
|
||||
state.torrentFilesMetadata.add(
|
||||
TorrentFileMetadata(
|
||||
torrentFile = torrentFile,
|
||||
gameFile = gameFilesPath
|
||||
)
|
||||
)
|
||||
|
||||
plugin.saveState(state)
|
||||
}
|
||||
|
||||
Files.createFile(torrentFile)
|
||||
Files.write(torrentFile, torrentFileContent(gameFilesPath))
|
||||
|
||||
tracker.announce(TrackedTorrent.load(torrentFile.toFile()))
|
||||
communicationManager.addTorrent(
|
||||
torrentFile.toString(),
|
||||
plugin.getRootPath(gameFilesPath).toString(),
|
||||
FullyPieceStorageFactory.INSTANCE
|
||||
)
|
||||
|
||||
state.torrentFilesMetadata.add(
|
||||
TorrentFileMetadata(
|
||||
torrentFile = torrentFile,
|
||||
gameFile = gameFilesPath
|
||||
)
|
||||
)
|
||||
|
||||
plugin.saveState(state)
|
||||
// Add the torrent to the session for seeding asynchronously to avoid blocking the download
|
||||
// This prevents crashes if there are permission issues or other errors
|
||||
try {
|
||||
client?.addTorrent(torrentFile, gameFilesPath)
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to add torrent to seeding session - torrent file created but won't be seeded", e)
|
||||
// Don't rethrow - the torrent file was created successfully, seeding is optional
|
||||
}
|
||||
|
||||
return torrentFile
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun torrentFileContent(gameFilesPath: Path): ByteArray {
|
||||
return TorrentBuilder()
|
||||
.numHashingThreads(Runtime.getRuntime().availableProcessors() * 2)
|
||||
.createdBy(plugin.javaClass.name)
|
||||
.addFile(gameFilesPath)
|
||||
.rootPath(plugin.getRootPath(gameFilesPath))
|
||||
.announce(plugin.getTrackerUri().toString())
|
||||
.privateFlag(plugin.config("privateMode"))
|
||||
.build()
|
||||
val torrentBuilder = TorrentBuilder()
|
||||
|
||||
val trackerUrl = plugin.getTrackerUri().toString()
|
||||
val isPrivate = plugin.config<Boolean>("privateMode")
|
||||
val torrentVersions = plugin.config<TorrentVersion>("torrentVersions")
|
||||
|
||||
log.info("Creating ${if (isPrivate) "private" else "public"} ${if (torrentVersions !== TorrentVersion.`V1 and V2`) torrentVersions else ""} torrent with announce URL '$trackerUrl'")
|
||||
|
||||
val flags = when (torrentVersions) {
|
||||
TorrentVersion.`V1 only` -> v1_only
|
||||
TorrentVersion.`V2 only` -> v2_only
|
||||
TorrentVersion.`V1 and V2` -> null
|
||||
}
|
||||
|
||||
val builder = torrentBuilder.path(gameFilesPath.toFile())
|
||||
.creator("Gameyfin Torrent plugin v${plugin.wrapper.descriptor.version}")
|
||||
.addTracker(trackerUrl)
|
||||
.setPrivate(isPrivate)
|
||||
|
||||
if (flags != null) {
|
||||
builder.flags(flags)
|
||||
}
|
||||
|
||||
val builderResult = builder.generate()
|
||||
|
||||
return builderResult.entry().bencode()
|
||||
}
|
||||
}
|
||||
}
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
package org.gameyfin.plugins.download.torrent
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URLDecoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* A simple BitTorrent tracker implementation using HTTP protocol.
|
||||
* Implements the basic announce/scrape protocol as defined in BEP 3.
|
||||
* Supports hybrid torrents (BEP-52) by grouping v1 and v2 swarms using the key parameter (BEP-7).
|
||||
*
|
||||
* The key parameter remains constant for a peer across the same torrent's v1/v2 variants,
|
||||
* allowing the tracker to link related swarms and provide better peer discovery.
|
||||
*/
|
||||
class TorrentTracker(
|
||||
private val port: Int,
|
||||
private val announceInterval: Int
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(TorrentTracker::class.java)
|
||||
private var server: HttpServer? = null
|
||||
|
||||
// Map of info_hash -> peers
|
||||
private val torrents = ConcurrentHashMap<String, MutableSet<Peer>>()
|
||||
|
||||
// Map of key -> set of info_hashes (for linking hybrid torrent swarms)
|
||||
// The key parameter is per-torrent and stays the same across v1/v2 variants,
|
||||
// allowing us to group related swarms of the same hybrid torrent
|
||||
private val hybridTorrentGroups = ConcurrentHashMap<String, MutableSet<String>>()
|
||||
|
||||
data class Peer(
|
||||
val peerId: String,
|
||||
val ip: String,
|
||||
val port: Int,
|
||||
var uploaded: Long = 0,
|
||||
var downloaded: Long = 0,
|
||||
var left: Long = 0,
|
||||
var lastSeen: Long = System.currentTimeMillis(),
|
||||
val key: String? = null // BEP-7 key parameter - per-torrent identifier for grouping hybrid variants
|
||||
)
|
||||
|
||||
fun start() {
|
||||
server = HttpServer.create(InetSocketAddress(port), 0).apply {
|
||||
createContext("/announce") { exchange ->
|
||||
try {
|
||||
handleAnnounce(exchange)
|
||||
} catch (e: Exception) {
|
||||
log.error("Unhandled error in announce handler", e)
|
||||
try {
|
||||
respondBencodedError(exchange, "Internal server error")
|
||||
} catch (_: Exception) {
|
||||
// Ignore errors when trying to send error response
|
||||
}
|
||||
} finally {
|
||||
exchange.close()
|
||||
}
|
||||
}
|
||||
|
||||
createContext("/scrape") { exchange ->
|
||||
try {
|
||||
handleScrape(exchange)
|
||||
} catch (e: Exception) {
|
||||
log.error("Unhandled error in scrape handler", e)
|
||||
try {
|
||||
respondBencodedError(exchange, "Internal server error")
|
||||
} catch (_: Exception) {
|
||||
// Ignore errors when trying to send error response
|
||||
}
|
||||
} finally {
|
||||
exchange.close()
|
||||
}
|
||||
}
|
||||
|
||||
executor = Executors.newSingleThreadExecutor()
|
||||
start()
|
||||
}
|
||||
|
||||
log.info("Tracker started on port $port")
|
||||
|
||||
// Start cleanup task
|
||||
startCleanupTask()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val currentServer = server
|
||||
server = null
|
||||
|
||||
currentServer?.stop(2)
|
||||
|
||||
// Shutdown the executor service
|
||||
currentServer?.executor?.let { executor ->
|
||||
(executor as? java.util.concurrent.ExecutorService)?.let {
|
||||
it.shutdown()
|
||||
try {
|
||||
if (!it.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
it.shutdownNow()
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
it.shutdownNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Tracker stopped")
|
||||
}
|
||||
|
||||
private fun bytesToHex(bytes: String): String {
|
||||
return bytes.toByteArray(Charsets.ISO_8859_1).joinToString("") {
|
||||
"%02x".format(it.toInt() and 0xFF)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAnnounce(exchange: HttpExchange) {
|
||||
try {
|
||||
// Get raw query string from URI - we need to parse it ourselves to handle binary data
|
||||
// Using .query would give us a UTF-8 decoded string which corrupts binary info_hash
|
||||
val rawQuery = exchange.requestURI.rawQuery ?: ""
|
||||
|
||||
val params = parseQueryString(rawQuery)
|
||||
|
||||
// Required parameters
|
||||
val infoHash = params["info_hash"] ?: run {
|
||||
respondBencodedError(exchange, "Missing info_hash")
|
||||
return
|
||||
}
|
||||
|
||||
val peerId = params["peer_id"] ?: run {
|
||||
respondBencodedError(exchange, "Missing peer_id")
|
||||
return
|
||||
}
|
||||
val port = params["port"]?.toIntOrNull() ?: run {
|
||||
respondBencodedError(exchange, "Missing or invalid port")
|
||||
return
|
||||
}
|
||||
|
||||
// Optional parameters
|
||||
val uploaded = params["uploaded"]?.toLongOrNull() ?: 0
|
||||
val downloaded = params["downloaded"]?.toLongOrNull() ?: 0
|
||||
val left = params["left"]?.toLongOrNull() ?: 0
|
||||
val event = params["event"] // started, completed, stopped
|
||||
val key = params["key"] // BEP-7 key parameter - per-torrent identifier (same for v1/v2 variants)
|
||||
|
||||
// Get client IP from params or use remote address
|
||||
val ip = params["ip"] ?: run {
|
||||
val remoteAddress = exchange.remoteAddress.address.hostAddress
|
||||
log.debug("Param 'ip' not provided, falling back to remote host address ($remoteAddress) for peer $peerId")
|
||||
remoteAddress
|
||||
}
|
||||
|
||||
// Track hybrid torrent grouping if key is provided
|
||||
if (!key.isNullOrBlank()) {
|
||||
val relatedHashes = hybridTorrentGroups.computeIfAbsent(key) {
|
||||
ConcurrentHashMap.newKeySet()
|
||||
}
|
||||
relatedHashes.add(infoHash)
|
||||
|
||||
log.debug("Linked info_hash ${bytesToHex(infoHash)} with key $key (group has ${relatedHashes.size} hashes)")
|
||||
}
|
||||
|
||||
// Get or create torrent peer list
|
||||
val peers = torrents.computeIfAbsent(infoHash) {
|
||||
log.debug("New torrent tracked: ${bytesToHex(infoHash)}")
|
||||
ConcurrentHashMap.newKeySet()
|
||||
}
|
||||
|
||||
// Handle event
|
||||
when (event) {
|
||||
"stopped" -> {
|
||||
peers.removeIf { it.peerId == peerId }
|
||||
log.debug("Removed peer $peerId ($ip) from torrent ${bytesToHex(infoHash)}")
|
||||
|
||||
// Also remove from related hybrid torrent swarms if key is provided
|
||||
if (!key.isNullOrBlank()) {
|
||||
hybridTorrentGroups[key]?.forEach { relatedHash ->
|
||||
if (relatedHash != infoHash) {
|
||||
torrents[relatedHash]?.removeIf { it.peerId == peerId && it.key == key }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val existingPeer = peers.find { it.peerId == peerId }
|
||||
|
||||
if (existingPeer != null) {
|
||||
peers.remove(existingPeer)
|
||||
peers.add(Peer(peerId, ip, port, uploaded, downloaded, left, key = key))
|
||||
log.debug("Updated peer $peerId ($ip) for torrent ${bytesToHex(infoHash)}")
|
||||
} else {
|
||||
peers.add(Peer(peerId, ip, port, uploaded, downloaded, left, key = key))
|
||||
log.debug("Added peer $peerId ($ip) to torrent ${bytesToHex(infoHash)}")
|
||||
}
|
||||
|
||||
// Sync peer to related hybrid torrent swarms if key is provided
|
||||
if (!key.isNullOrBlank()) {
|
||||
hybridTorrentGroups[key]?.forEach { relatedHash ->
|
||||
if (relatedHash != infoHash) {
|
||||
val relatedPeers = torrents.computeIfAbsent(relatedHash) {
|
||||
ConcurrentHashMap.newKeySet()
|
||||
}
|
||||
relatedPeers.removeIf { it.peerId == peerId && it.key == key }
|
||||
relatedPeers.add(Peer(peerId, ip, port, uploaded, downloaded, left, key = key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build peer list from this swarm and related hybrid swarms
|
||||
val allPeers = mutableSetOf<Peer>()
|
||||
allPeers.addAll(peers)
|
||||
|
||||
// Include peers from related hybrid torrent swarms
|
||||
if (!key.isNullOrBlank()) {
|
||||
hybridTorrentGroups[key]?.forEach { relatedHash ->
|
||||
torrents[relatedHash]?.let { allPeers.addAll(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by peerId and exclude the requesting peer
|
||||
val peerList = allPeers
|
||||
.distinctBy { it.peerId }
|
||||
.filter { it.peerId != peerId }
|
||||
.take(50)
|
||||
|
||||
// Calculate stats across all related swarms
|
||||
val uniquePeers = allPeers.distinctBy { it.peerId }
|
||||
|
||||
// Send response
|
||||
respondBencodedAnnounce(
|
||||
exchange,
|
||||
interval = announceInterval,
|
||||
complete = uniquePeers.count { it.left == 0L },
|
||||
incomplete = uniquePeers.count { it.left > 0L },
|
||||
peers = peerList
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
log.error("Error handling announce", e)
|
||||
respondBencodedError(exchange, "Internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleScrape(exchange: HttpExchange) {
|
||||
try {
|
||||
val params = parseQueryString(exchange.requestURI.rawQuery ?: "")
|
||||
val infoHashes = params.entries
|
||||
.filter { it.key == "info_hash" }
|
||||
.map { it.value }
|
||||
|
||||
val response = buildString {
|
||||
append("d5:filesd")
|
||||
|
||||
if (infoHashes.isEmpty()) {
|
||||
// Scrape all torrents
|
||||
torrents.forEach { (infoHash, peers) ->
|
||||
appendTorrentStats(infoHash, peers)
|
||||
}
|
||||
} else {
|
||||
// Scrape specific torrents
|
||||
infoHashes.forEach { infoHash ->
|
||||
torrents[infoHash]?.let { peers ->
|
||||
appendTorrentStats(infoHash, peers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
append("ee")
|
||||
}
|
||||
|
||||
respondBytes(exchange, response.toByteArray(Charsets.ISO_8859_1))
|
||||
|
||||
} catch (e: Exception) {
|
||||
log.error("Error handling scrape", e)
|
||||
respondBencodedError(exchange, "Internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendTorrentStats(infoHash: String, peers: Set<Peer>) {
|
||||
val complete = peers.count { it.left == 0L }
|
||||
val incomplete = peers.count { it.left > 0L }
|
||||
val downloaded = peers.count() // Total number of times completed
|
||||
|
||||
append("20:") // info_hash is always 20 bytes
|
||||
append(infoHash)
|
||||
append("d")
|
||||
append("8:completei${complete}e")
|
||||
append("10:downloadedi${downloaded}e")
|
||||
append("10:incompletei${incomplete}e")
|
||||
append("e")
|
||||
}
|
||||
|
||||
private fun respondBencodedError(exchange: HttpExchange, message: String) {
|
||||
val response = "d14:failure reason${message.length}:${message}e"
|
||||
respondBytes(exchange, response.toByteArray(Charsets.ISO_8859_1))
|
||||
}
|
||||
|
||||
private fun respondBencodedAnnounce(
|
||||
exchange: HttpExchange,
|
||||
interval: Int,
|
||||
complete: Int,
|
||||
incomplete: Int,
|
||||
peers: List<Peer>
|
||||
) {
|
||||
val response = buildString {
|
||||
append("d")
|
||||
append("8:intervali${interval}e")
|
||||
append("8:completei${complete}e")
|
||||
append("10:incompletei${incomplete}e")
|
||||
|
||||
// Compact peer list (binary format)
|
||||
append("5:peers")
|
||||
val peerBytes = ByteBuffer.allocate(peers.size * 6)
|
||||
peers.forEach { peer ->
|
||||
val ipParts = peer.ip.split(".")
|
||||
if (ipParts.size == 4) {
|
||||
ipParts.forEach { peerBytes.put(it.toInt().toByte()) }
|
||||
peerBytes.putShort(peer.port.toShort())
|
||||
}
|
||||
}
|
||||
val compactPeers = peerBytes.array().copyOf(peerBytes.position())
|
||||
append("${compactPeers.size}:")
|
||||
append(String(compactPeers, Charsets.ISO_8859_1))
|
||||
|
||||
append("e")
|
||||
}
|
||||
|
||||
respondBytes(exchange, response.toByteArray(Charsets.ISO_8859_1))
|
||||
}
|
||||
|
||||
private fun respondBytes(exchange: HttpExchange, bytes: ByteArray) {
|
||||
exchange.responseHeaders.set("Content-Type", "text/plain")
|
||||
exchange.sendResponseHeaders(200, bytes.size.toLong())
|
||||
exchange.responseBody.write(bytes)
|
||||
exchange.responseBody.flush()
|
||||
}
|
||||
|
||||
private fun parseQueryString(query: String): Map<String, String> {
|
||||
if (query.isEmpty()) return emptyMap()
|
||||
|
||||
return query.split("&")
|
||||
.mapNotNull { param ->
|
||||
val parts = param.split("=", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val key = URLDecoder.decode(parts[0], "UTF-8")
|
||||
// Use ISO-8859-1 for binary parameters (info_hash, peer_id)
|
||||
// to preserve binary data without UTF-8 corruption
|
||||
val value = if (key == "info_hash" || key == "peer_id") {
|
||||
urlDecodeToBytes(parts[1])
|
||||
} else {
|
||||
URLDecoder.decode(parts[1], "UTF-8")
|
||||
}
|
||||
key to value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a URL-encoded string to raw bytes, preserving binary data.
|
||||
* Unlike URLDecoder.decode(), this uses ISO-8859-1 to preserve binary data.
|
||||
*/
|
||||
private fun urlDecodeToBytes(encoded: String): String {
|
||||
val bytes = mutableListOf<Byte>()
|
||||
var i = 0
|
||||
while (i < encoded.length) {
|
||||
when {
|
||||
encoded[i] == '%' && i + 2 < encoded.length -> {
|
||||
// Decode %XX to a byte
|
||||
val hex = encoded.substring(i + 1, i + 3)
|
||||
bytes.add(hex.toInt(16).toByte())
|
||||
i += 3
|
||||
}
|
||||
|
||||
encoded[i] == '+' -> {
|
||||
// Plus sign represents space in URL encoding
|
||||
bytes.add(' '.code.toByte())
|
||||
i++
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Regular character
|
||||
bytes.add(encoded[i].code.toByte())
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
// Convert bytes to String using ISO-8859-1 (preserves binary data)
|
||||
return String(bytes.toByteArray(), Charsets.ISO_8859_1)
|
||||
}
|
||||
|
||||
private fun startCleanupTask() {
|
||||
// Simple cleanup - in production you'd want a scheduled executor
|
||||
Thread {
|
||||
while (server != null) {
|
||||
try {
|
||||
Thread.sleep(TimeUnit.MINUTES.toMillis(5))
|
||||
cleanupStalePeers()
|
||||
} catch (_: InterruptedException) {
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
log.error("Error in cleanup task", e)
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
isDaemon = true
|
||||
name = "tracker-cleanup"
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupStalePeers() {
|
||||
val staleThreshold = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2)
|
||||
|
||||
torrents.forEach { (infoHash, peers) ->
|
||||
val initialSize = peers.size
|
||||
peers.removeIf { it.lastSeen < staleThreshold }
|
||||
val removed = initialSize - peers.size
|
||||
|
||||
if (removed > 0) {
|
||||
log.debug("Removed $removed stale peers from torrent $infoHash")
|
||||
}
|
||||
|
||||
if (peers.isEmpty()) {
|
||||
torrents.remove(infoHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package org.gameyfin.plugins.download.torrent
|
||||
|
||||
enum class TorrentVersion {
|
||||
`V1 only`,
|
||||
`V2 only`,
|
||||
`V1 and V2`
|
||||
}
|
||||
@@ -12,4 +12,19 @@ fun Duration.asHumanReadable(): String {
|
||||
append("${seconds}s")
|
||||
}.trim()
|
||||
}
|
||||
}
|
||||
|
||||
fun getContainerOS(): String {
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("cat", "/etc/os-release"))
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
val lines = output.lines()
|
||||
for (line in lines) {
|
||||
if (line.startsWith("ID=")) {
|
||||
return line.substringAfter("=")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
Plugin-Version: 1.0.1
|
||||
Plugin-Version: 2.0.0
|
||||
Plugin-Class: org.gameyfin.plugins.download.torrent.TorrentDownloadPlugin
|
||||
Plugin-Id: org.gameyfin.plugins.download.torrent
|
||||
Plugin-Name: Torrent Download
|
||||
|
||||
Reference in New Issue
Block a user