using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using Kavita.API.Database; using Kavita.API.Services; using Kavita.API.Services.SignalR; using Kavita.Common; using Kavita.Models.Constants; using Kavita.Models.DTOs.Downloads; using Kavita.Models.DTOs.SignalR; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; using Kavita.Server.Attributes; using Kavita.Services.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Kavita.Server.Controllers; /// /// All APIs related to downloading entities from the system. Requires Download Role or Admin Role. /// [Authorize(PolicyGroups.DownloadPolicy)] public class DownloadController( IUnitOfWork unitOfWork, IArchiveService archiveService, IDownloadService downloadService, IEventHub eventHub, ILogger logger, IBookmarkService bookmarkService, ILocalizationService localizationService) : BaseApiController { private const string DefaultContentType = "application/octet-stream"; /// /// For a given volume, return the size in bytes /// /// /// [VolumeAccess] [HttpGet("volume-size")] public async Task> GetVolumeSize(int volumeId) { return Ok(await unitOfWork.VolumeRepository.GetFilesizeForVolumeAsync(volumeId)); } /// /// For a set of volumes, return the size in bytes /// /// /// [HttpPost("bulk-volume-size")] public async Task>> GetBulkVolumeSize([FromBody] IList volumeIds) { return Ok(await unitOfWork.VolumeRepository.GetFilesizeForVolumesAsync(volumeIds)); } /// /// For a given chapter, return the size in bytes /// /// /// [ChapterAccess] [HttpGet("chapter-size")] public async Task> GetChapterSize(int chapterId) { return Ok(await unitOfWork.ChapterRepository.GetFilesizeForChapterAsync(chapterId)); } /// /// For a set of chapters, return the size in bytes /// /// /// [HttpPost("bulk-chapter-size")] public async Task>> GetChapterSizeInBulk([FromBody] IList chapterIds) { // If there are more than 50 chapterIds, we need to break up into multiple calls return Ok(await unitOfWork.ChapterRepository.GetFilesizeForChaptersAsync(chapterIds)); } /// /// For a series, return the size in bytes /// /// /// [SeriesAccess] [HttpGet("series-size")] public async Task> GetSeriesSize(int seriesId) { return Ok(await unitOfWork.SeriesRepository.GetFilesizeForSeriesAsync(seriesId)); } /// /// For a set of series, return the size in bytes /// /// /// [HttpPost("bulk-series-size")] public async Task>> GetBulkSeriesSize([FromBody] IList seriesIds) { return Ok(await unitOfWork.SeriesRepository.GetFilesizeForMultipleSeriesAsync(seriesIds)); } /// /// Downloads all chapters within a volume. If the chapters are multiple zips, they will all be zipped up. /// /// /// [VolumeAccess] [HttpGet("volume")] [Authorize(PolicyGroups.DownloadPolicy)] public async Task DownloadVolume(int volumeId, [FromQuery] string? correlationId = null) { var volume = await unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId); if (volume == null) return BadRequest(await localizationService.Translate(UserId, "volume-doesnt-exist")); var files = await unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); try { return await DownloadFiles(files, $"download_{Username!}_v{volumeId}", $"{series!.Name} - Volume {volume.Name}.zip", correlationId); } catch (KavitaException ex) { return BadRequest(ex.Message); } } private PhysicalFileResult GetFirstFileDownload(IEnumerable files) { var (zipFile, contentType, fileDownloadName) = downloadService.GetFirstFileDownload(files); return PhysicalFile(zipFile, contentType, fileDownloadName, true); } /// /// Returns the zip for a single chapter. If the chapter contains multiple files, they will be zipped. /// /// /// [ChapterAccess] [HttpGet("chapter")] public async Task DownloadChapter(int chapterId, [FromQuery] string? correlationId = null) { var files = await unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId); if (chapter == null) return BadRequest(await localizationService.Translate(UserId, "chapter-doesnt-exist")); var volume = await unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId); var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume!.SeriesId); try { return await DownloadFiles(files, $"download_{Username!}_c{chapterId}", $"{series!.Name} - Chapter {chapter.GetNumberTitle()}.zip", correlationId); } catch (KavitaException ex) { return BadRequest(ex.Message); } } private async Task DownloadFiles(ICollection files, string tempFolder, string downloadName, string? correlationId = null) { var username = Username!; var filename = Path.GetFileNameWithoutExtension(downloadName); try { await eventHub.SendMessageAsync(MessageFactory.DownloadProgress, MessageFactory.DownloadProgressEvent(username, filename, $"Downloading {filename}", 0F, "started", correlationId)); if (files.Count == 1 && files.First().Format != MangaFormat.Image) { // Emit "ended" after the response is fully sent to the client HttpContext.Response.OnCompleted(async () => { await eventHub.SendMessageAsync(MessageFactory.DownloadProgress, MessageFactory.DownloadProgressEvent(username, filename, "Download Complete", 1F, "ended", correlationId)); }); return GetFirstFileDownload(files); } var filePath = archiveService.CreateZipFromFoldersForDownload(files.Select(c => c.FilePath).ToList(), tempFolder, ProgressCallback); await eventHub.SendMessageAsync(MessageFactory.DownloadProgress, MessageFactory.DownloadProgressEvent(username, filename, "Download Complete", 1F, "ended", correlationId)); return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(downloadName), true); async Task ProgressCallback(Tuple progressInfo) { await eventHub.SendMessageAsync(MessageFactory.DownloadProgress, MessageFactory.DownloadProgressEvent(username, filename, $"Processing {Path.GetFileNameWithoutExtension(progressInfo.Item1)}", Math.Clamp(progressInfo.Item2, 0F, 1F), correlationId)); } } catch (Exception ex) { logger.LogError(ex, "There was an exception when trying to download files"); await eventHub.SendMessageAsync(MessageFactory.DownloadProgress, MessageFactory.DownloadProgressEvent(Username!, filename, "Download Complete", 1F, "ended", correlationId)); throw; } } [SeriesAccess] [HttpGet("series")] [Authorize(PolicyGroups.DownloadPolicy)] public async Task DownloadSeries(int seriesId, [FromQuery] string? correlationId = null) { var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); if (series == null) return BadRequest("Invalid Series"); var files = await unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); try { return await DownloadFiles(files, $"download_{Username!}_s{seriesId}", $"{series.Name}.zip", correlationId); } catch (KavitaException ex) { return BadRequest(ex.Message); } } /// /// Downloads all bookmarks in a zip for /// /// /// [HttpPost("bookmarks")] [Authorize(PolicyGroups.DownloadPolicy)] public async Task DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto) { if (downloadBookmarkDto.Bookmarks.DistinctBy(b => b.SeriesId).Count() > 1) return BadRequest(); var seriesId = downloadBookmarkDto.Bookmarks.First().SeriesId; if (!await unitOfWork.UserRepository.HasAccessToSeries(UserId, seriesId, HttpContext.RequestAborted)) return NotFound(); if (!downloadBookmarkDto.Bookmarks.Any()) return BadRequest(await localizationService.Translate(UserId, "bookmarks-empty")); var userId = UserId; var username = Username!; var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); var files = await bookmarkService.GetBookmarkFilesById(downloadBookmarkDto.Bookmarks.Select(b => b.Id)); var filename = $"{series!.Name} - Bookmarks.zip"; await eventHub.SendMessageAsync(MessageFactory.DownloadProgress, MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), $"Downloading {filename}",0F)); var filePath = archiveService.CreateZipForDownload(files,$"download_{userId}_{seriesId}_bookmarks"); await eventHub.SendMessageAsync(MessageFactory.DownloadProgress, MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), $"Downloading {filename}", 1F)); return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(filename), true); } }