mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 08:15:48 +00:00
Implement direct download via plugin
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
import * as AvatarEndpoint from './AvatarEndpoint'
|
import * as AvatarEndpoint from './AvatarEndpoint'
|
||||||
|
import * as DownloadEndpoint from './DownloadEndpoint'
|
||||||
|
|
||||||
export {AvatarEndpoint}
|
export {AvatarEndpoint, DownloadEndpoint}
|
||||||
@@ -7,6 +7,7 @@ import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/
|
|||||||
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
|
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
|
||||||
import {Chip} from "@heroui/react";
|
import {Chip} from "@heroui/react";
|
||||||
import {toTitleCase} from "Frontend/util/utils";
|
import {toTitleCase} from "Frontend/util/utils";
|
||||||
|
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
|
||||||
|
|
||||||
export default function GameView() {
|
export default function GameView() {
|
||||||
const {gameId} = useParams();
|
const {gameId} = useParams();
|
||||||
@@ -18,7 +19,7 @@ export default function GameView() {
|
|||||||
label: "Direct Download",
|
label: "Direct Download",
|
||||||
description: "Download the game in this browser",
|
description: "Download the game in this browser",
|
||||||
action: () => {
|
action: () => {
|
||||||
alert("Direct download not yet implemented")
|
DownloadEndpoint.downloadGame(parseInt(gameId!), "de.grimsi.gameyfin.plugins.directdownload.DirectDownloadPlugin$DirectDownloadProvider")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
torrent: {
|
torrent: {
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
return downloadService.getProviders()
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{gameId}")
|
||||||
|
fun downloadGame(@PathVariable gameId: Long, @RequestParam provider: String): ResponseEntity<Resource> {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DownloadProvider>
|
||||||
|
get() = pluginManager.getExtensions(DownloadProvider::class.java)
|
||||||
|
|
||||||
|
fun getProviders(): List<String> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -8,6 +8,7 @@ class GameyfinManifestPluginDescriptorFinder() : ManifestPluginDescriptorFinder(
|
|||||||
companion object {
|
companion object {
|
||||||
const val PLUGIN_NAME: String = "Plugin-Name"
|
const val PLUGIN_NAME: String = "Plugin-Name"
|
||||||
const val PLUGIN_AUTHOR: String = "Plugin-Author"
|
const val PLUGIN_AUTHOR: String = "Plugin-Author"
|
||||||
|
const val PLUGIN_SHORT_DESCRIPTION: String = "Plugin-Short-Description"
|
||||||
const val PLUGIN_URL: String = "Plugin-Url"
|
const val PLUGIN_URL: String = "Plugin-Url"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,9 +23,10 @@ class GameyfinManifestPluginDescriptorFinder() : ManifestPluginDescriptorFinder(
|
|||||||
descriptor = pluginDescriptor,
|
descriptor = pluginDescriptor,
|
||||||
name = attributes.getValue(PLUGIN_NAME)
|
name = attributes.getValue(PLUGIN_NAME)
|
||||||
?: throw IllegalStateException("Plugin-Name not found in manifest"),
|
?: throw IllegalStateException("Plugin-Name not found in manifest"),
|
||||||
|
shortDescription = attributes.getValue(PLUGIN_SHORT_DESCRIPTION),
|
||||||
author = attributes.getValue(PLUGIN_AUTHOR)
|
author = attributes.getValue(PLUGIN_AUTHOR)
|
||||||
?: throw IllegalStateException("Plugin-Author not found in manifest"),
|
?: throw IllegalStateException("Plugin-Author not found in manifest"),
|
||||||
url = attributes.getValue(PLUGIN_URL) ?: "",
|
url = attributes.getValue(PLUGIN_URL),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
-4
@@ -4,24 +4,31 @@ import org.pf4j.DefaultPluginDescriptor
|
|||||||
import org.pf4j.PluginDescriptor
|
import org.pf4j.PluginDescriptor
|
||||||
|
|
||||||
data class GameyfinPluginDescriptor(
|
data class GameyfinPluginDescriptor(
|
||||||
var pluginUrl: String,
|
var pluginUrl: String?,
|
||||||
var pluginName: String,
|
var pluginName: String,
|
||||||
|
var pluginShortDescription: String?,
|
||||||
var author: String
|
var author: String
|
||||||
) : DefaultPluginDescriptor() {
|
) : DefaultPluginDescriptor() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NEWLINE_INDICATOR = "<br>"
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
descriptor: PluginDescriptor,
|
descriptor: PluginDescriptor,
|
||||||
url: String,
|
url: String?,
|
||||||
name: String,
|
name: String,
|
||||||
|
shortDescription: String?,
|
||||||
author: String
|
author: String
|
||||||
) : this(
|
) : this(
|
||||||
pluginUrl = url,
|
pluginUrl = url,
|
||||||
pluginName = name,
|
pluginName = name,
|
||||||
|
pluginShortDescription = shortDescription,
|
||||||
author = author
|
author = author
|
||||||
) {
|
) {
|
||||||
this.pluginId = descriptor.pluginId
|
this.pluginId = descriptor.pluginId
|
||||||
// The Manifest parser ignores newlines in the description
|
// The Manifest spec does not account for line breaks in values
|
||||||
this.pluginDescription = descriptor.pluginDescription.replace("<br>", "\n")
|
this.pluginDescription = descriptor.pluginDescription.replace(NEWLINE_INDICATOR, "\n")
|
||||||
this.pluginClass = descriptor.pluginClass
|
this.pluginClass = descriptor.pluginClass
|
||||||
this.setPluginVersion(descriptor.version)
|
this.setPluginVersion(descriptor.version)
|
||||||
this.requires = descriptor.requires
|
this.requires = descriptor.requires
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ data class PluginDto(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
|
val shortDescription: String? = null,
|
||||||
val version: String,
|
val version: String,
|
||||||
val author: String,
|
val author: String,
|
||||||
val license: String? = null,
|
val license: String? = null,
|
||||||
|
|||||||
+1
@@ -93,6 +93,7 @@ class PluginManagementService(
|
|||||||
id = descriptor.pluginId,
|
id = descriptor.pluginId,
|
||||||
name = descriptor.pluginName,
|
name = descriptor.pluginName,
|
||||||
description = descriptor.pluginDescription,
|
description = descriptor.pluginDescription,
|
||||||
|
shortDescription = descriptor.pluginShortDescription,
|
||||||
version = descriptor.version,
|
version = descriptor.version,
|
||||||
author = descriptor.author,
|
author = descriptor.author,
|
||||||
license = descriptor.license,
|
license = descriptor.license,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
+131
@@ -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<PluginConfigElement> = 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<String, String?> = emptyMap()
|
||||||
|
|
||||||
|
override fun validateConfig(config: Map<String, String?>): 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, Path>(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<Pair<ZipEntry, Path>?> = LinkedBlockingQueue()
|
||||||
|
|
||||||
|
// Producer: walks the file tree and enqueues files
|
||||||
|
Thread.startVirtualThread {
|
||||||
|
try {
|
||||||
|
Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.<br>
|
||||||
|
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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" 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="M74.34,85.66A8,8,0,0,1,85.66,74.34L120,108.69V24a8,8,0,0,1,16,0v84.69l34.34-34.35a8,8,0,0,1,11.32,11.32l-48,48a8,8,0,0,1-11.32,0ZM240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16H84.4a4,4,0,0,1,2.83,1.17L111,145A24,24,0,0,0,145,145l23.8-23.8A4,4,0,0,1,171.6,120H224A16,16,0,0,1,240,136Zm-40,32a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 764 B |
+2
-1
@@ -25,4 +25,5 @@ include(":plugins")
|
|||||||
|
|
||||||
include(":plugins:igdb")
|
include(":plugins:igdb")
|
||||||
include(":plugins:steam")
|
include(":plugins:steam")
|
||||||
include(":plugins:steamgriddb")
|
include(":plugins:steamgriddb")
|
||||||
|
include(":plugins:directdownload")
|
||||||
Reference in New Issue
Block a user