From fc3a6fd52f3a903d8dd2f784bf0ad859441d57ac Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Thu, 15 May 2025 19:18:55 +0200 Subject: [PATCH] Implement direct download via plugin --- .../frontend/endpoints/DownloadEndpoint.ts | 30 ++++ .../src/main/frontend/endpoints/endpoints.ts | 3 +- gameyfin/src/main/frontend/views/GameView.tsx | 3 +- .../core/download/DownloadEndpoint.kt | 44 ++++++ .../gameyfin/core/download/DownloadService.kt | 26 ++++ .../GameyfinManifestPluginDescriptorFinder.kt | 4 +- .../management/GameyfinPluginDescriptor.kt | 15 +- .../core/plugins/management/PluginDto.kt | 1 + .../management/PluginManagementService.kt | 1 + .../gameyfin/pluginapi/download/Download.kt | 16 +++ .../pluginapi/download/DownloadProvider.kt | 9 ++ plugins/directdownload/build.gradle.kts | 10 ++ .../directdownload/DirectDownloadPlugin.kt | 131 ++++++++++++++++++ .../src/main/resources/MANIFEST.MF | 10 ++ .../src/main/resources/logo.svg | 10 ++ settings.gradle.kts | 3 +- 16 files changed, 308 insertions(+), 8 deletions(-) create mode 100644 gameyfin/src/main/frontend/endpoints/DownloadEndpoint.ts create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadService.kt create mode 100644 plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/download/Download.kt create mode 100644 plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/download/DownloadProvider.kt create mode 100644 plugins/directdownload/build.gradle.kts create mode 100644 plugins/directdownload/src/main/kotlin/de/grimsi/gameyfin/plugins/directdownload/DirectDownloadPlugin.kt create mode 100644 plugins/directdownload/src/main/resources/MANIFEST.MF create mode 100644 plugins/directdownload/src/main/resources/logo.svg diff --git a/gameyfin/src/main/frontend/endpoints/DownloadEndpoint.ts b/gameyfin/src/main/frontend/endpoints/DownloadEndpoint.ts new file mode 100644 index 0000000..55a37a6 --- /dev/null +++ b/gameyfin/src/main/frontend/endpoints/DownloadEndpoint.ts @@ -0,0 +1,30 @@ +export async function downloadGame(gameId: number, provider: string) { + try { + const response = await fetch(`/download/${gameId}?provider=${provider}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const contentDisposition = response.headers.get('Content-Disposition'); + const filename = contentDisposition + ? contentDisposition.split('filename=')[1].replace(/"/g, '') + : 'downloaded_file'; + + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); + } catch (error) { + console.error('Error downloading the file', error); + } +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/endpoints/endpoints.ts b/gameyfin/src/main/frontend/endpoints/endpoints.ts index ca60cf3..fffe569 100644 --- a/gameyfin/src/main/frontend/endpoints/endpoints.ts +++ b/gameyfin/src/main/frontend/endpoints/endpoints.ts @@ -1,3 +1,4 @@ import * as AvatarEndpoint from './AvatarEndpoint' +import * as DownloadEndpoint from './DownloadEndpoint' -export {AvatarEndpoint} \ No newline at end of file +export {AvatarEndpoint, DownloadEndpoint} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/views/GameView.tsx b/gameyfin/src/main/frontend/views/GameView.tsx index 9710d15..1a9e210 100644 --- a/gameyfin/src/main/frontend/views/GameView.tsx +++ b/gameyfin/src/main/frontend/views/GameView.tsx @@ -7,6 +7,7 @@ import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ import ImageCarousel from "Frontend/components/general/covers/ImageCarousel"; import {Chip} from "@heroui/react"; import {toTitleCase} from "Frontend/util/utils"; +import {DownloadEndpoint} from "Frontend/endpoints/endpoints"; export default function GameView() { const {gameId} = useParams(); @@ -18,7 +19,7 @@ export default function GameView() { label: "Direct Download", description: "Download the game in this browser", action: () => { - alert("Direct download not yet implemented") + DownloadEndpoint.downloadGame(parseInt(gameId!), "de.grimsi.gameyfin.plugins.directdownload.DirectDownloadPlugin$DirectDownloadProvider") } }, torrent: { diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt new file mode 100644 index 0000000..878ffe6 --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt @@ -0,0 +1,44 @@ +package de.grimsi.gameyfin.core.download + +import de.grimsi.gameyfin.core.annotations.DynamicPublicAccess +import de.grimsi.gameyfin.games.GameService +import de.grimsi.gameyfin.pluginapi.download.FileDownload +import de.grimsi.gameyfin.pluginapi.download.LinkDownload +import org.springframework.core.io.InputStreamResource +import org.springframework.core.io.Resource +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/download") +@DynamicPublicAccess +class DownloadEndpoint( + private val downloadService: DownloadService, + private val gameService: GameService +) { + fun getProviders(): List { + return downloadService.getProviders() + } + + @GetMapping("/{gameId}") + fun downloadGame(@PathVariable gameId: Long, @RequestParam provider: String): ResponseEntity { + val game = gameService.getGame(gameId) + val downloadElement = downloadService.getDownloadElement(game.path, provider) + + return when (downloadElement) { + is FileDownload -> { + val resource = InputStreamResource(downloadElement.data) + ResponseEntity.ok() + .header( + "Content-Disposition", + "attachment; filename=\"${game.title}.${downloadElement.fileExtension}\"" + ) + .body(resource) + } + + is LinkDownload -> { + TODO("Handle download link") + } + } + } +} \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadService.kt new file mode 100644 index 0000000..829264d --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadService.kt @@ -0,0 +1,26 @@ +package de.grimsi.gameyfin.core.download + +import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginManager +import de.grimsi.gameyfin.pluginapi.download.Download +import de.grimsi.gameyfin.pluginapi.download.DownloadProvider +import org.springframework.stereotype.Service +import kotlin.io.path.Path + +@Service +class DownloadService( + private val pluginManager: GameyfinPluginManager, +) { + private val downloadPlugins: List + get() = pluginManager.getExtensions(DownloadProvider::class.java) + + fun getProviders(): List { + return downloadPlugins.map { it.javaClass.name } + } + + fun getDownloadElement(path: String, provider: String): Download { + val provider = downloadPlugins.firstOrNull { it.javaClass.name == provider } + ?: throw IllegalArgumentException("Download provider $provider not found") + + return provider.getDownloadSources(Path(path)) + } +} \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinManifestPluginDescriptorFinder.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinManifestPluginDescriptorFinder.kt index cc069d0..8d95d58 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinManifestPluginDescriptorFinder.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinManifestPluginDescriptorFinder.kt @@ -8,6 +8,7 @@ class GameyfinManifestPluginDescriptorFinder() : ManifestPluginDescriptorFinder( companion object { const val PLUGIN_NAME: String = "Plugin-Name" const val PLUGIN_AUTHOR: String = "Plugin-Author" + const val PLUGIN_SHORT_DESCRIPTION: String = "Plugin-Short-Description" const val PLUGIN_URL: String = "Plugin-Url" } @@ -22,9 +23,10 @@ class GameyfinManifestPluginDescriptorFinder() : ManifestPluginDescriptorFinder( descriptor = pluginDescriptor, name = attributes.getValue(PLUGIN_NAME) ?: throw IllegalStateException("Plugin-Name not found in manifest"), + shortDescription = attributes.getValue(PLUGIN_SHORT_DESCRIPTION), author = attributes.getValue(PLUGIN_AUTHOR) ?: throw IllegalStateException("Plugin-Author not found in manifest"), - url = attributes.getValue(PLUGIN_URL) ?: "", + url = attributes.getValue(PLUGIN_URL), ) } } \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginDescriptor.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginDescriptor.kt index c2bdd1d..9a4d106 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginDescriptor.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginDescriptor.kt @@ -4,24 +4,31 @@ import org.pf4j.DefaultPluginDescriptor import org.pf4j.PluginDescriptor data class GameyfinPluginDescriptor( - var pluginUrl: String, + var pluginUrl: String?, var pluginName: String, + var pluginShortDescription: String?, var author: String ) : DefaultPluginDescriptor() { + companion object { + const val NEWLINE_INDICATOR = "
" + } + constructor( descriptor: PluginDescriptor, - url: String, + url: String?, name: String, + shortDescription: String?, author: String ) : this( pluginUrl = url, pluginName = name, + pluginShortDescription = shortDescription, author = author ) { this.pluginId = descriptor.pluginId - // The Manifest parser ignores newlines in the description - this.pluginDescription = descriptor.pluginDescription.replace("
", "\n") + // The Manifest spec does not account for line breaks in values + this.pluginDescription = descriptor.pluginDescription.replace(NEWLINE_INDICATOR, "\n") this.pluginClass = descriptor.pluginClass this.setPluginVersion(descriptor.version) this.requires = descriptor.requires diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginDto.kt index 3e697b3..96fd8a4 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginDto.kt @@ -6,6 +6,7 @@ data class PluginDto( val id: String, val name: String, val description: String, + val shortDescription: String? = null, val version: String, val author: String, val license: String? = null, diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementService.kt index a2a34a2..cfb3e71 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementService.kt @@ -93,6 +93,7 @@ class PluginManagementService( id = descriptor.pluginId, name = descriptor.pluginName, description = descriptor.pluginDescription, + shortDescription = descriptor.pluginShortDescription, version = descriptor.version, author = descriptor.author, license = descriptor.license, diff --git a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/download/Download.kt b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/download/Download.kt new file mode 100644 index 0000000..da41a37 --- /dev/null +++ b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/download/Download.kt @@ -0,0 +1,16 @@ +package de.grimsi.gameyfin.pluginapi.download + +import java.io.InputStream + +sealed interface Download + +data class FileDownload( + val data: InputStream, + val fileExtension: String? = null, + val size: Long? = null +) : Download + +data class LinkDownload( + val url: String +) : Download + diff --git a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/download/DownloadProvider.kt b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/download/DownloadProvider.kt new file mode 100644 index 0000000..41c9238 --- /dev/null +++ b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/download/DownloadProvider.kt @@ -0,0 +1,9 @@ +package de.grimsi.gameyfin.pluginapi.download + +import org.pf4j.ExtensionPoint +import java.nio.file.Path + +interface DownloadProvider : ExtensionPoint { + + fun getDownloadSources(path: Path): Download +} \ No newline at end of file diff --git a/plugins/directdownload/build.gradle.kts b/plugins/directdownload/build.gradle.kts new file mode 100644 index 0000000..e18b5f5 --- /dev/null +++ b/plugins/directdownload/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("com.google.devtools.ksp") + kotlin("plugin.serialization") +} + +dependencies { + ksp("care.better.pf4j:pf4j-kotlin-symbol-processing:${rootProject.extra["pf4jKspVersion"]}") + + implementation("commons-io:commons-io:2.19.0") +} \ No newline at end of file diff --git a/plugins/directdownload/src/main/kotlin/de/grimsi/gameyfin/plugins/directdownload/DirectDownloadPlugin.kt b/plugins/directdownload/src/main/kotlin/de/grimsi/gameyfin/plugins/directdownload/DirectDownloadPlugin.kt new file mode 100644 index 0000000..d8da0e4 --- /dev/null +++ b/plugins/directdownload/src/main/kotlin/de/grimsi/gameyfin/plugins/directdownload/DirectDownloadPlugin.kt @@ -0,0 +1,131 @@ +package de.grimsi.gameyfin.plugins.directdownload + +import de.grimsi.gameyfin.pluginapi.core.Configurable +import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin +import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement +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 org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.InputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.nio.file.* +import java.nio.file.attribute.BasicFileAttributes +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue +import java.util.zip.Deflater +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.io.path.exists +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlin.io.path.isDirectory + +class DirectDownloadPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable { + val log: Logger = LoggerFactory.getLogger(javaClass) + + enum class CompressionMode { + NONE, + FAST, + BEST; + + companion object { + fun toDeflaterLevel(mode: CompressionMode): Int { + return when (mode) { + NONE -> Deflater.NO_COMPRESSION + FAST -> Deflater.BEST_SPEED + BEST -> Deflater.BEST_COMPRESSION + } + } + } + } + + override val configMetadata: List = listOf( + PluginConfigElement( + key = "compressionMode", + name = "Compression mode for generated ZIP files (\"none\", \"fast\", \"best\")", + description = "Higher compression uses more CPU but saves bandwidth", + ) + ) + + override var config: Map = emptyMap() + + override fun validateConfig(config: Map): Boolean { + return config["compressionMode"]?.let { + try { + CompressionMode.valueOf(it.uppercase()) + true + } catch (_: IllegalArgumentException) { + log.error("Invalid compression mode: $it") + false + } + } ?: true + } + + @Extension + class DirectDownloadProvider : DownloadProvider { + companion object { + private val END_OF_QUEUE = Pair(ZipEntry("__END__"), Paths.get("")) + } + + override fun getDownloadSources(path: Path): Download { + if (!path.exists()) throw IllegalArgumentException("Path $path does not exist") + + return FileDownload( + data = readContentAsSingleFile(path), + fileExtension = if (path.isDirectory()) "zip" else path.extension, + size = path.fileSize() + ) + } + + fun readContentAsSingleFile(path: Path): InputStream { + if (path.isDirectory()) return zipFilesInPath(path) + return Files.newInputStream(path, StandardOpenOption.READ) + } + + private fun zipFilesInPath(path: Path): InputStream { + val pipedIn = PipedInputStream(64 * 1024) + val pipedOut = PipedOutputStream(pipedIn) + val queue: BlockingQueue?> = LinkedBlockingQueue() + + // Producer: walks the file tree and enqueues files + Thread.startVirtualThread { + try { + Files.walkFileTree(path, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + val entry = ZipEntry(path.relativize(file).toString()) + queue.put(entry to file) + return FileVisitResult.CONTINUE + } + }) + } finally { + queue.put(END_OF_QUEUE) // signal end + } + } + + // Consumer: zips files in parallel, but writes entries in order + Thread { + ZipOutputStream(pipedOut).use { zos -> + zos.setLevel(Deflater.NO_COMPRESSION) + while (true) { + val item = queue.take() + if (item === END_OF_QUEUE || item == null) break + val (entry, file) = item + zos.putNextEntry(entry) + Files.newInputStream(file, StandardOpenOption.READ).use { input -> + input.copyTo(zos, 128 * 1024) + } + zos.closeEntry() + } + } + pipedOut.close() + }.start() + + return pipedIn + } + } +} \ No newline at end of file diff --git a/plugins/directdownload/src/main/resources/MANIFEST.MF b/plugins/directdownload/src/main/resources/MANIFEST.MF new file mode 100644 index 0000000..0a124a5 --- /dev/null +++ b/plugins/directdownload/src/main/resources/MANIFEST.MF @@ -0,0 +1,10 @@ +Plugin-Version: 1.0.0-alpha1 +Plugin-Class: de.grimsi.gameyfin.plugins.directdownload.DirectDownloadPlugin +Plugin-Id: de.grimsi.gameyfin.directdownload +Plugin-Name: Direct Download +Plugin-Description: Downloads games directly in the browser.
+ If the game is contained in a folder, it will pack the folder into a zip file on the fly. +Plugin-Short-Description: Download games directly in the browser +Plugin-Author: grimsi +Plugin-License: MIT +Plugin-Url: https://github.com/gameyfin diff --git a/plugins/directdownload/src/main/resources/logo.svg b/plugins/directdownload/src/main/resources/logo.svg new file mode 100644 index 0000000..1ad162c --- /dev/null +++ b/plugins/directdownload/src/main/resources/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 9d65d3d..95fd655 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,4 +25,5 @@ include(":plugins") include(":plugins:igdb") include(":plugins:steam") -include(":plugins:steamgriddb") \ No newline at end of file +include(":plugins:steamgriddb") +include(":plugins:directdownload") \ No newline at end of file