diff --git a/.gitignore b/.gitignore index 502ff23..426a94c 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ templates /gameyfin/src/main/frontend/**/*.js /gameyfin/src/main/frontend/**/*.js.map /gameyfin/src/main/bundles/ +/torrent_dotfiles/ diff --git a/plugins/torrentdownload/build.gradle.kts b/plugins/torrentdownload/build.gradle.kts new file mode 100644 index 0000000..01f530a --- /dev/null +++ b/plugins/torrentdownload/build.gradle.kts @@ -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") + } +} \ No newline at end of file diff --git a/plugins/torrentdownload/src/main/kotlin/de/grimsi/gameyfinplugins/torrentdownload/TorrentDownloadPlugin.kt b/plugins/torrentdownload/src/main/kotlin/de/grimsi/gameyfinplugins/torrentdownload/TorrentDownloadPlugin.kt new file mode 100644 index 0000000..6796b21 --- /dev/null +++ b/plugins/torrentdownload/src/main/kotlin/de/grimsi/gameyfinplugins/torrentdownload/TorrentDownloadPlugin.kt @@ -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("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): PluginConfigValidationResult { + val configValidationResult = super.validateConfig(config) + if (!configValidationResult.isValid()) { + return configValidationResult + } + + val errors = mutableMapOf() + + 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("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 + } + } + } +} diff --git a/plugins/torrentdownload/src/main/resources/MANIFEST.MF b/plugins/torrentdownload/src/main/resources/MANIFEST.MF new file mode 100644 index 0000000..e829e14 --- /dev/null +++ b/plugins/torrentdownload/src/main/resources/MANIFEST.MF @@ -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.
+ 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 diff --git a/plugins/torrentdownload/src/main/resources/logo.svg b/plugins/torrentdownload/src/main/resources/logo.svg new file mode 100644 index 0000000..e42305d --- /dev/null +++ b/plugins/torrentdownload/src/main/resources/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file