mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 08:15:48 +00:00
Implement file size calculation
Implement compression level config for DirectDownloadPlugin Fix download of games hogging RAM
This commit is contained in:
@@ -1,30 +1,3 @@
|
|||||||
export async function downloadGame(gameId: number, provider: string) {
|
export function downloadGame(gameId: number, provider: string) {
|
||||||
try {
|
window.open(`/download/${gameId}?provider=${provider}`, '_top');
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -87,6 +87,38 @@ export function timeUntil(instantString: string, timeZone: string = moment.tz.gu
|
|||||||
return "just now";
|
return "just now";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes as human-readable text.
|
||||||
|
*
|
||||||
|
* @param bytes Number of bytes.
|
||||||
|
* @param si True to use metric (SI) units, aka powers of 1000. False to use
|
||||||
|
* binary (IEC), aka powers of 1024.
|
||||||
|
* @param dp Number of decimal places to display.
|
||||||
|
*
|
||||||
|
* @return Formatted string.
|
||||||
|
*/
|
||||||
|
export function humanFileSize(bytes: number, si: boolean = false, dp: number = 1) {
|
||||||
|
const thresh = si ? 1000 : 1024;
|
||||||
|
|
||||||
|
if (Math.abs(bytes) < thresh) {
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = si
|
||||||
|
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
|
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||||
|
let u = -1;
|
||||||
|
const r = 10 ** dp;
|
||||||
|
|
||||||
|
do {
|
||||||
|
bytes /= thresh;
|
||||||
|
++u;
|
||||||
|
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||||
|
|
||||||
|
|
||||||
|
return bytes.toFixed(dp) + ' ' + units[u];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a random number of games from the library based on the library ID.
|
* Select a random number of games from the library based on the library ID.
|
||||||
* @param library
|
* @param library
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {GameCover} from "Frontend/components/general/covers/GameCover";
|
|||||||
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
|
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
|
||||||
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 {humanFileSize, toTitleCase} from "Frontend/util/utils";
|
||||||
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
|
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
|
||||||
|
|
||||||
export default function GameView() {
|
export default function GameView() {
|
||||||
@@ -61,7 +61,7 @@ export default function GameView() {
|
|||||||
<p className="text-foreground/60">{game.release !== undefined ? new Date(game.release).getFullYear() : "unknown"}</p>
|
<p className="text-foreground/60">{game.release !== undefined ? new Date(game.release).getFullYear() : "unknown"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ComboButton description="64 GiB"
|
<ComboButton description={humanFileSize(game.fileSize)}
|
||||||
options={downloadOptions}
|
options={downloadOptions}
|
||||||
preferredOptionKey="preferred-download-method"
|
preferredOptionKey="preferred-download-method"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import de.grimsi.gameyfin.core.annotations.DynamicPublicAccess
|
|||||||
import de.grimsi.gameyfin.games.GameService
|
import de.grimsi.gameyfin.games.GameService
|
||||||
import de.grimsi.gameyfin.pluginapi.download.FileDownload
|
import de.grimsi.gameyfin.pluginapi.download.FileDownload
|
||||||
import de.grimsi.gameyfin.pluginapi.download.LinkDownload
|
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.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/download")
|
@RequestMapping("/download")
|
||||||
@@ -21,19 +20,20 @@ class DownloadEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{gameId}")
|
@GetMapping("/{gameId}")
|
||||||
fun downloadGame(@PathVariable gameId: Long, @RequestParam provider: String): ResponseEntity<Resource> {
|
fun downloadGame(
|
||||||
|
@PathVariable gameId: Long,
|
||||||
|
@RequestParam provider: String
|
||||||
|
): ResponseEntity<StreamingResponseBody> {
|
||||||
val game = gameService.getGame(gameId)
|
val game = gameService.getGame(gameId)
|
||||||
val downloadElement = downloadService.getDownloadElement(game.path, provider)
|
val download = downloadService.getDownload(game.path, provider)
|
||||||
|
|
||||||
return when (downloadElement) {
|
return when (download) {
|
||||||
is FileDownload -> {
|
is FileDownload -> {
|
||||||
val resource = InputStreamResource(downloadElement.data)
|
|
||||||
ResponseEntity.ok()
|
ResponseEntity.ok()
|
||||||
.header(
|
.header("Content-Disposition", "attachment; filename=\"${game.title}.zip\"")
|
||||||
"Content-Disposition",
|
.body(StreamingResponseBody { outputStream ->
|
||||||
"attachment; filename=\"${game.title}.${downloadElement.fileExtension}\""
|
download.data.copyTo(outputStream)
|
||||||
)
|
})
|
||||||
.body(resource)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is LinkDownload -> {
|
is LinkDownload -> {
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ class DownloadService(
|
|||||||
return downloadPlugins.map { it.javaClass.name }
|
return downloadPlugins.map { it.javaClass.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDownloadElement(path: String, provider: String): Download {
|
fun getDownload(path: String, provider: String): Download {
|
||||||
val provider = downloadPlugins.firstOrNull { it.javaClass.name == provider }
|
val provider = downloadPlugins.firstOrNull { it.javaClass.name == provider }
|
||||||
?: throw IllegalArgumentException("Download provider $provider not found")
|
?: throw IllegalArgumentException("Download provider $provider not found")
|
||||||
|
|
||||||
return provider.getDownloadSources(Path(path))
|
return provider.download(Path(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import de.grimsi.gameyfin.libraries.Library
|
|||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.apache.commons.io.FilenameUtils
|
import org.apache.commons.io.FilenameUtils
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import java.io.File
|
||||||
import java.nio.file.FileSystems
|
import java.nio.file.FileSystems
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.io.path.*
|
import kotlin.io.path.*
|
||||||
@@ -125,6 +126,22 @@ class FilesystemService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun calculateFileSize(path: String): Long {
|
||||||
|
return try {
|
||||||
|
val file = File(path)
|
||||||
|
if (file.isFile) {
|
||||||
|
file.length()
|
||||||
|
} else if (file.isDirectory) {
|
||||||
|
File(path).walkTopDown().filter { it.isFile }.map { it.length() }.sum()
|
||||||
|
} else {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn { "Error calculating file size for $path: ${e.message}" }
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun safeReadDirectoryContents(path: String): List<FileDto> {
|
private fun safeReadDirectoryContents(path: String): List<FileDto> {
|
||||||
return safeReadDirectoryContents(Path(path))
|
return safeReadDirectoryContents(Path(path))
|
||||||
.map { FileDto(it.name, if (it.isDirectory()) FileType.DIRECTORY else FileType.FILE, it.hashCode()) }
|
.map { FileDto(it.name, if (it.isDirectory()) FileType.DIRECTORY else FileType.FILE, it.hashCode()) }
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ fun Game.toDto(): GameDto {
|
|||||||
imageIds = this.images.mapNotNull { it.id },
|
imageIds = this.images.mapNotNull { it.id },
|
||||||
videoUrls = this.videoUrls.map { it.toString() },
|
videoUrls = this.videoUrls.map { it.toString() },
|
||||||
path = this.path,
|
path = this.path,
|
||||||
|
fileSize = this.fileSize ?: 0L,
|
||||||
metadata = toDto(this.metadata),
|
metadata = toDto(this.metadata),
|
||||||
originalIds = this.originalIds.mapKeys { it.key.pluginId }
|
originalIds = this.originalIds.mapKeys { it.key.pluginId }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class GameDto(
|
|||||||
val imageIds: List<Long>?,
|
val imageIds: List<Long>?,
|
||||||
val videoUrls: List<String>?,
|
val videoUrls: List<String>?,
|
||||||
val path: String,
|
val path: String,
|
||||||
|
val fileSize: Long,
|
||||||
val metadata: Map<String, GameMetadataDto>,
|
val metadata: Map<String, GameMetadataDto>,
|
||||||
val originalIds: Map<String, String>
|
val originalIds: Map<String, String>
|
||||||
)
|
)
|
||||||
@@ -78,6 +78,8 @@ class Game(
|
|||||||
@Column(unique = true)
|
@Column(unique = true)
|
||||||
val path: String,
|
val path: String,
|
||||||
|
|
||||||
|
var fileSize: Long? = null,
|
||||||
|
|
||||||
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
|
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
|
||||||
var metadata: Map<String, FieldMetadata> = emptyMap(),
|
var metadata: Map<String, FieldMetadata> = emptyMap(),
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ class LibraryService(
|
|||||||
val totalPaths = gamePaths.size
|
val totalPaths = gamePaths.size
|
||||||
val completedMetadata = AtomicInteger(0)
|
val completedMetadata = AtomicInteger(0)
|
||||||
val completedImageDownload = AtomicInteger(0)
|
val completedImageDownload = AtomicInteger(0)
|
||||||
|
val calculatedFileSize = AtomicInteger(0)
|
||||||
|
|
||||||
log.info { "Scanning library '${library.name}' with $totalPaths paths..." }
|
log.info { "Scanning library '${library.name}' with $totalPaths paths..." }
|
||||||
|
|
||||||
@@ -238,14 +239,30 @@ class LibraryService(
|
|||||||
|
|
||||||
val gamesWithImages = executor.invokeAll(imageDownloadTasks).mapNotNull { it.get() }
|
val gamesWithImages = executor.invokeAll(imageDownloadTasks).mapNotNull { it.get() }
|
||||||
|
|
||||||
// 3. Persist new games
|
// 3. Calculate game file sizes
|
||||||
|
val calculateFileSizeTask = matchedGames.map { game ->
|
||||||
|
Callable {
|
||||||
|
game.path.let { path ->
|
||||||
|
val fileSize = filesystemService.calculateFileSize(path)
|
||||||
|
game.fileSize = fileSize
|
||||||
|
val progress = calculatedFileSize.incrementAndGet()
|
||||||
|
log.debug { "${progress}/${totalPaths} file sizes calculated" }
|
||||||
|
game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val gamesWithFileSizes = executor.invokeAll(calculateFileSizeTask).map { it.get() }
|
||||||
|
|
||||||
|
|
||||||
|
// 4. Persist new games
|
||||||
val persistedGames = gameService.create(gamesWithImages)
|
val persistedGames = gameService.create(gamesWithImages)
|
||||||
log.debug { "${persistedGames.size}/${totalPaths} saved to database" }
|
log.debug { "${persistedGames.size}/${totalPaths} saved to database" }
|
||||||
|
|
||||||
// 4. Add new games to library
|
// 5. Add new games to library
|
||||||
addGamesToLibrary(persistedGames, library)
|
addGamesToLibrary(persistedGames, library)
|
||||||
|
|
||||||
// 5. Persist library
|
// 6. Persist library
|
||||||
libraryRepository.save(library)
|
libraryRepository.save(library)
|
||||||
|
|
||||||
return LibraryScanResult(
|
return LibraryScanResult(
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ spring:
|
|||||||
fs.filesystem-root: ./data/
|
fs.filesystem-root: ./data/
|
||||||
application:
|
application:
|
||||||
name: Gameyfin
|
name: Gameyfin
|
||||||
|
threads:
|
||||||
|
virtual.enabled: true
|
||||||
|
mvc:
|
||||||
|
async.request-timeout: 0
|
||||||
|
|
||||||
vaadin:
|
vaadin:
|
||||||
# To improve the performance during development.
|
# To improve the performance during development.
|
||||||
|
|||||||
+1
-1
@@ -5,5 +5,5 @@ import java.nio.file.Path
|
|||||||
|
|
||||||
interface DownloadProvider : ExtensionPoint {
|
interface DownloadProvider : ExtensionPoint {
|
||||||
|
|
||||||
fun getDownloadSources(path: Path): Download
|
fun download(path: Path): Download
|
||||||
}
|
}
|
||||||
+62
-41
@@ -10,13 +10,12 @@ import org.pf4j.Extension
|
|||||||
import org.pf4j.PluginWrapper
|
import org.pf4j.PluginWrapper
|
||||||
import org.slf4j.Logger
|
import org.slf4j.Logger
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.PipedInputStream
|
import java.io.PipedInputStream
|
||||||
import java.io.PipedOutputStream
|
import java.io.PipedOutputStream
|
||||||
import java.nio.file.*
|
import java.nio.file.*
|
||||||
import java.nio.file.attribute.BasicFileAttributes
|
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.Deflater
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipOutputStream
|
import java.util.zip.ZipOutputStream
|
||||||
@@ -26,6 +25,15 @@ import kotlin.io.path.fileSize
|
|||||||
import kotlin.io.path.isDirectory
|
import kotlin.io.path.isDirectory
|
||||||
|
|
||||||
class DirectDownloadPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable {
|
class DirectDownloadPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable {
|
||||||
|
companion object {
|
||||||
|
lateinit var plugin: DirectDownloadPlugin
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
plugin = this
|
||||||
|
}
|
||||||
|
|
||||||
val log: Logger = LoggerFactory.getLogger(javaClass)
|
val log: Logger = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
enum class CompressionMode {
|
enum class CompressionMode {
|
||||||
@@ -68,64 +76,77 @@ class DirectDownloadPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Co
|
|||||||
|
|
||||||
@Extension
|
@Extension
|
||||||
class DirectDownloadProvider : DownloadProvider {
|
class DirectDownloadProvider : DownloadProvider {
|
||||||
companion object {
|
override fun download(path: Path): Download {
|
||||||
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")
|
if (!path.exists()) throw IllegalArgumentException("Path $path does not exist")
|
||||||
|
|
||||||
return FileDownload(
|
return FileDownload(
|
||||||
data = readContentAsSingleFile(path),
|
data = streamContentAsSingleFile(path),
|
||||||
fileExtension = if (path.isDirectory()) "zip" else path.extension,
|
fileExtension = if (path.isDirectory()) "zip" else path.extension,
|
||||||
size = path.fileSize()
|
size = path.fileSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readContentAsSingleFile(path: Path): InputStream {
|
fun streamContentAsSingleFile(path: Path): InputStream {
|
||||||
if (path.isDirectory()) return zipFilesInPath(path)
|
if (path.isDirectory()) return streamFolderAsZip(path)
|
||||||
return Files.newInputStream(path, StandardOpenOption.READ)
|
return streamFile(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun zipFilesInPath(path: Path): InputStream {
|
fun streamFile(path: Path): InputStream {
|
||||||
val pipedIn = PipedInputStream(64 * 1024)
|
val pipeIn = PipedInputStream(512 * 1024)
|
||||||
val pipedOut = PipedOutputStream(pipedIn)
|
val pipeOut = PipedOutputStream(pipeIn)
|
||||||
val queue: BlockingQueue<Pair<ZipEntry, Path>?> = LinkedBlockingQueue()
|
|
||||||
|
|
||||||
// Producer: walks the file tree and enqueues files
|
Thread.ofVirtual().start {
|
||||||
Thread.startVirtualThread {
|
|
||||||
try {
|
try {
|
||||||
Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
|
Files.newInputStream(path, StandardOpenOption.READ).use { input ->
|
||||||
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
|
input.copyTo(pipeOut, 512 * 1024)
|
||||||
val entry = ZipEntry(path.relativize(file).toString())
|
}
|
||||||
queue.put(entry to file)
|
} catch (_: IOException) {
|
||||||
return FileVisitResult.CONTINUE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
queue.put(END_OF_QUEUE) // signal end
|
try {
|
||||||
|
pipeOut.close()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consumer: zips files in parallel, but writes entries in order
|
return pipeIn
|
||||||
Thread {
|
}
|
||||||
ZipOutputStream(pipedOut).use { zos ->
|
|
||||||
zos.setLevel(Deflater.NO_COMPRESSION)
|
fun streamFolderAsZip(path: Path): InputStream {
|
||||||
while (true) {
|
val pipeIn = PipedInputStream(512 * 1024) // 512 KB buffer
|
||||||
val item = queue.take()
|
val pipeOut = PipedOutputStream(pipeIn)
|
||||||
if (item === END_OF_QUEUE || item == null) break
|
|
||||||
val (entry, file) = item
|
Thread.ofVirtual().start {
|
||||||
zos.putNextEntry(entry)
|
try {
|
||||||
Files.newInputStream(file, StandardOpenOption.READ).use { input ->
|
ZipOutputStream(pipeOut).use { zos ->
|
||||||
input.copyTo(zos, 128 * 1024)
|
|
||||||
}
|
zos.setLevel(CompressionMode.toDeflaterLevel(plugin.config["compressionMode"]?.let {
|
||||||
zos.closeEntry()
|
CompressionMode.valueOf(it.uppercase())
|
||||||
|
} ?: CompressionMode.NONE))
|
||||||
|
|
||||||
|
Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
|
||||||
|
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
|
||||||
|
val entry = ZipEntry(path.relativize(file).toString())
|
||||||
|
zos.putNextEntry(entry)
|
||||||
|
Files.newInputStream(file, StandardOpenOption.READ).use { input ->
|
||||||
|
input.copyTo(zos, 512 * 1024)
|
||||||
|
}
|
||||||
|
zos.closeEntry()
|
||||||
|
return FileVisitResult.CONTINUE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pipeOut.close()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
pipeOut.close()
|
||||||
|
} catch (_: IOException) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pipedOut.close()
|
}
|
||||||
}.start()
|
|
||||||
|
|
||||||
return pipedIn
|
return pipeIn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user