mirror of
https://github.com/gotson/komga.git
synced 2025-12-22 00:13:30 +01:00
feat: series download
This commit is contained in:
parent
65132ccb97
commit
e44bc7b491
4 changed files with 98 additions and 0 deletions
|
|
@ -22,6 +22,9 @@
|
|||
<v-list-item @click="markUnread" v-if="!isUnread">
|
||||
<v-list-item-title>{{ $t('menu.mark_unread') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="canDownload" :href="fileUrl">
|
||||
<v-list-item-title>Download series</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
|
@ -30,6 +33,7 @@
|
|||
import {SERIES_CHANGED, seriesToEventSeriesChanged} from '@/types/events'
|
||||
import Vue from 'vue'
|
||||
import {SeriesDto} from "@/types/komga-series";
|
||||
import {seriesFileUrl} from "@/functions/urls";
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'SeriesActionsMenu',
|
||||
|
|
@ -57,6 +61,12 @@ export default Vue.extend({
|
|||
isAdmin (): boolean {
|
||||
return this.$store.getters.meAdmin
|
||||
},
|
||||
canDownload (): boolean {
|
||||
return this.$store.getters.meFileDownload
|
||||
},
|
||||
fileUrl (): string {
|
||||
return seriesFileUrl(this.series.id)
|
||||
},
|
||||
isRead (): boolean {
|
||||
return this.series.booksReadCount === this.series.booksCount
|
||||
},
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ export function bookPageThumbnailUrl (bookId: string, page: number): string {
|
|||
return `${urls.originNoSlash}/api/v1/books/${bookId}/pages/${page}/thumbnail`
|
||||
}
|
||||
|
||||
export function seriesFileUrl (seriesId: string): string {
|
||||
return `${urls.originNoSlash}/api/v1/series/${seriesId}/file`
|
||||
}
|
||||
|
||||
export function seriesThumbnailUrl (seriesId: string): string {
|
||||
return `${urls.originNoSlash}/api/v1/series/${seriesId}/thumbnail`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ import io.swagger.v3.oas.annotations.media.Content
|
|||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import mu.KotlinLogging
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.gotson.komga.application.tasks.TaskReceiver
|
||||
import org.gotson.komga.domain.model.BookSearchWithReadProgress
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.ROLE_ADMIN
|
||||
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
|
||||
import org.gotson.komga.domain.model.ReadStatus
|
||||
import org.gotson.komga.domain.model.SeriesMetadata
|
||||
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
|
||||
|
|
@ -31,12 +33,16 @@ import org.gotson.komga.interfaces.rest.dto.restrictUrl
|
|||
import org.gotson.komga.interfaces.rest.dto.toDto
|
||||
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
|
||||
import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository
|
||||
import org.springframework.core.io.FileSystemResource
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.http.ContentDisposition
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
|
|
@ -50,6 +56,10 @@ import org.springframework.web.bind.annotation.RequestParam
|
|||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||
import java.io.OutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.validation.Valid
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
|
@ -356,4 +366,46 @@ class SeriesController(
|
|||
bookLifecycle.deleteReadProgress(it, principal.user)
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("{seriesId}/file", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
|
||||
@PreAuthorize("hasRole('$ROLE_FILE_DOWNLOAD')")
|
||||
fun getSeriesFile(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@PathVariable seriesId: String
|
||||
): ResponseEntity<StreamingResponseBody> {
|
||||
seriesRepository.getLibraryId(seriesId)?.let {
|
||||
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
val books = bookRepository.findBySeriesId(seriesId)
|
||||
|
||||
val streamingResponse = StreamingResponseBody { responseStream: OutputStream ->
|
||||
ZipOutputStream(responseStream).use { zipStream ->
|
||||
zipStream.setLevel(0)
|
||||
books.forEach { book ->
|
||||
val file = FileSystemResource(book.path())
|
||||
if (!file.exists()) {
|
||||
logger.warn { "Book file not found, skipping archive entry: ${file.path}" }
|
||||
return@forEach
|
||||
}
|
||||
|
||||
logger.debug { "Adding file to zip archive: ${file.path}" }
|
||||
file.inputStream.use {
|
||||
zipStream.putNextEntry(ZipEntry(file.filename))
|
||||
IOUtils.copyLarge(it, zipStream, ByteArray(8192))
|
||||
zipStream.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.headers(HttpHeaders().apply {
|
||||
contentDisposition = ContentDisposition.builder("attachment")
|
||||
.filename(seriesMetadataRepository.findById(seriesId).title + ".zip")
|
||||
.build()
|
||||
})
|
||||
.contentType(MediaType.parseMediaType("application/zip"))
|
||||
.body(streamingResponse)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -253,6 +253,38 @@ class SeriesControllerTest(
|
|||
mockMvc.get("/api/v1/series/${createdSeries.id}/books")
|
||||
.andExpect { status { isForbidden() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [])
|
||||
fun `given user with no access to any library when getting specific series file then returns forbidden`() {
|
||||
val createdSeries = makeSeries(name = "series", libraryId = library.id).let { series ->
|
||||
seriesLifecycle.createSeries(series).also { created ->
|
||||
val books = listOf(makeBook("1", libraryId = library.id))
|
||||
seriesLifecycle.addBooks(created, books)
|
||||
}
|
||||
}
|
||||
|
||||
mockMvc.get("/api/v1/series/${createdSeries.id}/file")
|
||||
.andExpect { status { isForbidden() } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Nested
|
||||
inner class RestrictedUserByRole {
|
||||
@Test
|
||||
@WithMockCustomUser(roles = [])
|
||||
fun `given user without file download role when getting specific series file then returns forbidden`() {
|
||||
val createdSeries = makeSeries(name = "series", libraryId = library.id).let { series ->
|
||||
seriesLifecycle.createSeries(series).also { created ->
|
||||
val books = listOf(makeBook("1", libraryId = library.id))
|
||||
seriesLifecycle.addBooks(created, books)
|
||||
}
|
||||
}
|
||||
|
||||
mockMvc.get("/api/v1/series/${createdSeries.id}/file")
|
||||
.andExpect { status { isForbidden() } }
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
|
|
|||
Loading…
Reference in a new issue