Add TorrentDownloadPlugin

This commit is contained in:
grimsi
2025-06-11 20:13:51 +02:00
parent 4072ff3dde
commit 60c83487dd
5 changed files with 249 additions and 0 deletions
+1
View File
@@ -51,3 +51,4 @@ templates
/gameyfin/src/main/frontend/**/*.js
/gameyfin/src/main/frontend/**/*.js.map
/gameyfin/src/main/bundles/
/torrent_dotfiles/
+23
View File
@@ -0,0 +1,23 @@
plugins {
id("com.google.devtools.ksp")
}
repositories {
maven { setUrl("https://jitpack.io") }
maven { setUrl("https://repository.jboss.org") }
}
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")
}
// Torrent file builder
implementation("com.github.atomashpolskiy:bt-core:1.10") {
exclude(group = "org.slf4j")
}
}
@@ -0,0 +1,205 @@
package de.grimsi.gameyfinplugins.torrentdownload
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 de.grimsi.gameyfin.pluginapi.core.config.ConfigMetadata
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigMetadata
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.wrapper.ConfigurableGameyfinPlugin
import de.grimsi.gameyfin.pluginapi.download.Download
import de.grimsi.gameyfin.pluginapi.download.DownloadProvider
import de.grimsi.gameyfin.pluginapi.download.FileDownload
import org.pf4j.Extension
import org.pf4j.PluginWrapper
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.*
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
}
init {
plugin = this
}
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
),
ConfigMetadata(
key = "trackerSsl",
label = "Use SSL for tracker",
description = "Enables use of SSL for the torrent tracker",
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",
type = Boolean::class.java,
default = true
)
)
@OptIn(ExperimentalPathApi::class)
override fun start() {
// Currently Gameyfin does not support storing plugin state
// and since we can't associate the torrent files with a game path after a restart
// we just delete the directory on startup.
if (Files.exists(TORRENT_FILE_DIRECTORY)) {
TORRENT_FILE_DIRECTORY.deleteRecursively()
}
Files.createDirectories(TORRENT_FILE_DIRECTORY)
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)
)
}
override fun stop() {
tracker.stop()
communicationManager.stop()
}
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
val configValidationResult = super.validateConfig(config)
if (!configValidationResult.isValid()) {
return configValidationResult
}
val errors = mutableMapOf<String, String>()
val trackerPort = config["trackerPort"]?.toIntOrNull()
if (trackerPort != null && trackerPort !in 1024..49151) {
errors["trackerPort"] = "Must be a valid port number between 1024 and 49151."
}
val externalHost = config["externalHost"]
if (externalHost != null) {
try {
InetAddress.getByName(externalHost)
} catch (_: Exception) {
errors["externalHost"] = "Must be a valid hostname or IP address."
}
}
return if (errors.isEmpty()) {
PluginConfigValidationResult.VALID
} else {
PluginConfigValidationResult.INVALID(errors)
}
}
private fun getTrackerUri(): URI {
val protocol = if (config("trackerSsl")) "https" else "http"
val host = getHostname().getCanonicalHostName()
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
)
}
@Extension
class TorrentDownloadProvider : DownloadProvider {
override fun download(path: Path): Download {
val torrentFile = createTorrentFile(path)
tracker.announce(TrackedTorrent.load(torrentFile.toFile()))
communicationManager.addTorrent(
torrentFile.toString(),
getRootPath(path).toString(),
FullyPieceStorageFactory.INSTANCE
)
return FileDownload(
data = torrentFile.inputStream(),
fileExtension = "torrent",
size = torrentFile.fileSize()
)
}
private fun createTorrentFile(gameFilesPath: Path): Path {
val torrentFile =
TORRENT_FILE_DIRECTORY.resolve("${gameFilesPath.nameWithoutExtension}-${gameFilesPath.hashCode()}.torrent")
if (Files.exists(torrentFile)) {
return torrentFile
}
Files.createFile(torrentFile)
Files.write(torrentFile, torrentFileContent(gameFilesPath))
return torrentFile
}
private fun torrentFileContent(gameFilesPath: Path): ByteArray {
return TorrentBuilder()
.numHashingThreads(Runtime.getRuntime().availableProcessors() * 2)
.createdBy(plugin.javaClass.name)
.addFile(gameFilesPath)
.rootPath(getRootPath(gameFilesPath))
.announce(plugin.getTrackerUri().toString())
.privateFlag(plugin.config("privateMode"))
.build()
}
private fun getRootPath(gameFilesPath: Path): Path {
return if (gameFilesPath.isDirectory()) {
gameFilesPath
} else {
gameFilesPath.parent
}
}
}
}
@@ -0,0 +1,10 @@
Plugin-Version: 1.0.0-alpha1
Plugin-Class: de.grimsi.gameyfinplugins.torrentdownload.TorrentDownloadPlugin
Plugin-Id: de.grimsi.gameyfinplugins.torrentdownload
Plugin-Name: Torrent Download
Plugin-Description: Distributes games via a built-in torrent tracker.<br>
Users need to install a torrent client to be able to download the games.
Plugin-Short-Description: Download via Torrent
Plugin-Author: grimsi, Pfuenzle
Plugin-License: MIT
Plugin-Url: https://github.com/gameyfin
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#000000" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2332c8;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#6441a5;stop-opacity:1"/>
</linearGradient>
</defs>
<path fill="url(#grad1)"
d="M212,96a27.84,27.84,0,0,0-10.51,2L171,59.94A28,28,0,1,0,120,44a28.65,28.65,0,0,0,.15,2.94L73.68,66.3a28,28,0,1,0-28.6,44.83l1.85,46.38a28,28,0,1,0,32.74,41.42L128,212.47a28,28,0,1,0,49.13-18.79l27.21-42.75A28,28,0,1,0,212,96ZM71.19,104.36,113.72,129,72.26,161.22a28,28,0,0,0-9.34-4.35l-1.85-46.38A28,28,0,0,0,71.19,104.36ZM149.57,72a27.8,27.8,0,0,0,8.94-2L189,108.06a27.86,27.86,0,0,0-4.18,9.22l-46.57,2.22ZM82.09,173.85,124,141.26l15.94,47.83a28.2,28.2,0,0,0-7.6,8L84,183.53A28,28,0,0,0,82.09,173.85ZM156,184l-.89,0-16.18-48.53,46.65-2.22a27.94,27.94,0,0,0,5.28,9l-27.21,42.75A28,28,0,0,0,156,184ZM126.32,61.7A28.44,28.44,0,0,0,134,68.24l-11.3,47.45L79.23,90.52A28,28,0,0,0,80,84a28.65,28.65,0,0,0-.15-2.94Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB