diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index b983a2d5c..2fffc47da 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -118,7 +118,7 @@ public class DownloadController : BaseApiController return await _accountService.HasDownloadPermission(user); } - private ActionResult GetFirstFileDownload(IEnumerable files) + private PhysicalFileResult GetFirstFileDownload(IEnumerable files) { var (zipFile, contentType, fileDownloadName) = _downloadService.GetFirstFileDownload(files); return PhysicalFile(zipFile, contentType, Uri.EscapeDataString(fileDownloadName), true); @@ -150,31 +150,40 @@ public class DownloadController : BaseApiController private async Task DownloadFiles(ICollection files, string tempFolder, string downloadName) { + var username = User.GetUsername(); + var filename = Path.GetFileNameWithoutExtension(downloadName); try { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), - Path.GetFileNameWithoutExtension(downloadName), 0F, "started")); + MessageFactory.DownloadProgressEvent(username, + filename, $"Downloading {filename}", 0F, "started")); if (files.Count == 1) { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), - Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); + MessageFactory.DownloadProgressEvent(username, + filename, $"Downloading {filename}",1F, "ended")); return GetFirstFileDownload(files); } - var filePath = _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), tempFolder); + var filePath = _archiveService.CreateZipFromFoldersForDownload(files.Select(c => c.FilePath).ToList(), tempFolder, ProgressCallback); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), - Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); + MessageFactory.DownloadProgressEvent(username, + filename, "Download Complete", 1F, "ended")); return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(downloadName), true); + + async Task ProgressCallback(Tuple progressInfo) + { + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.DownloadProgressEvent(username, filename, $"Extracting {Path.GetFileNameWithoutExtension(progressInfo.Item1)}", + Math.Clamp(progressInfo.Item2, 0F, 1F))); + } } catch (Exception ex) { _logger.LogError(ex, "There was an exception when trying to download files"); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), - Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); + filename, "Download Complete", 1F, "ended")); throw; } } @@ -216,12 +225,12 @@ public class DownloadController : BaseApiController var filename = $"{series!.Name} - Bookmarks.zip"; await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), 0F)); + MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), $"Downloading {filename}",0F)); var seriesIds = string.Join("_", downloadBookmarkDto.Bookmarks.Select(b => b.SeriesId).Distinct()); var filePath = _archiveService.CreateZipForDownload(files, $"download_{userId}_{seriesIds}_bookmarks"); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), 1F)); + MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), $"Downloading {filename}", 1F)); return PhysicalFile(filePath, DefaultContentType, System.Web.HttpUtility.UrlEncode(filename), true); diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 8fe6207a4..f33091d76 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; +using System.Threading.Tasks; using System.Xml.Linq; using System.Xml.Serialization; using API.Archive; @@ -30,13 +31,21 @@ public interface IArchiveService ArchiveLibrary CanOpen(string archivePath); bool ArchiveNeedsFlattening(ZipArchive archive); /// - /// Creates a zip file form the listed files and outputs to the temp folder. + /// Creates a zip file form the listed files and outputs to the temp folder. This will combine into one zip of multiple zips. /// /// List of files to be zipped up. Should be full file paths. /// Temp folder name to use for preparing the files. Will be created and deleted /// Path to the temp zip /// string CreateZipForDownload(IEnumerable files, string tempFolder); + /// + /// Creates a zip file form the listed files and outputs to the temp folder. This will extract each archive and combine them into one zip. + /// + /// List of files to be zipped up. Should be full file paths. + /// Temp folder name to use for preparing the files. Will be created and deleted + /// Path to the temp zip + /// + string CreateZipFromFoldersForDownload(IList files, string tempFolder, Func, Task> progressCallback); } /// @@ -322,6 +331,54 @@ public class ArchiveService : IArchiveService return zipPath; } + public string CreateZipFromFoldersForDownload(IList files, string tempFolder, Func, Task> progressCallback) + { + var dateString = DateTime.UtcNow.ToShortDateString().Replace("/", "_"); + + var potentialExistingFile = _directoryService.FileSystem.FileInfo.New(Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip")); + if (potentialExistingFile.Exists) + { + // A previous download exists, just return it immediately + return potentialExistingFile.FullName; + } + + // Extract all the files to a temp directory and create zip on that + var tempLocation = Path.Join(_directoryService.TempDirectory, $"{tempFolder}_{dateString}"); + var totalFiles = files.Count + 1; + var count = 1f; + try + { + _directoryService.ExistOrCreate(tempLocation); + foreach (var path in files) + { + var tempPath = Path.Join(tempLocation, _directoryService.FileSystem.Path.GetFileNameWithoutExtension(_directoryService.FileSystem.FileInfo.New(path).Name)); + _directoryService.ExistOrCreate(tempPath); + progressCallback(Tuple.Create(_directoryService.FileSystem.FileInfo.New(path).Name, (1.0f * totalFiles) / count)); + ExtractArchive(path, tempPath); + count++; + } + } + catch + { + throw new KavitaException("bad-copy-files-for-download"); + } + + var zipPath = Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip"); + try + { + ZipFile.CreateFromDirectory(tempLocation, zipPath); + // Remove the folder as we have the zip + _directoryService.ClearAndDeleteDirectory(tempLocation); + } + catch (AggregateException ex) + { + _logger.LogError(ex, "There was an issue creating temp archive"); + throw new KavitaException("generic-create-temp-archive"); + } + + return zipPath; + } + /// /// Test if the archive path exists and an archive @@ -477,7 +534,7 @@ public class ArchiveService : IArchiveService { if (!IsValidArchive(archivePath)) return; - if (Directory.Exists(extractPath)) return; + if (_directoryService.FileSystem.Directory.Exists(extractPath)) return; if (!_directoryService.FileSystem.File.Exists(archivePath)) { diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 76fcae5fc..44767bd8a 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -376,13 +376,13 @@ public static class MessageFactory }; } - public static SignalRMessage DownloadProgressEvent(string username, string downloadName, float progress, string eventType = "updated") + public static SignalRMessage DownloadProgressEvent(string username, string downloadName, string subtitle, float progress, string eventType = "updated") { return new SignalRMessage() { Name = DownloadProgress, - Title = $"Downloading {downloadName}", - SubTitle = $"Preparing {username.SentenceCase()} the download of {downloadName}", + Title = $"Preparing {username.SentenceCase()} the download of {downloadName}", + SubTitle = subtitle, EventType = eventType, Progress = ProgressType.Determinate, Body = new diff --git a/UI/Web/angular.json b/UI/Web/angular.json index 41eb7a9c1..60fc909a9 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -68,6 +68,7 @@ "optimization": true, "outputHashing": "all", "namedChunks": false, + "aot": true, "extractLicenses": true, "budgets": [ { diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 03304d32a..072a7e128 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -994,6 +994,7 @@ "version": "17.0.8", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.0.8.tgz", "integrity": "sha512-ny2SMVgl+icjMuU5ZM57yFGUrhjR0hNxfCn0otAD3jUFliz/Onu9l6EPRKA5Cr8MZx3mg3rTLSBMD17YT8rsOg==", + "dev": true, "dependencies": { "@babel/core": "7.23.2", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -5858,6 +5859,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -6114,6 +6116,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, "engines": { "node": ">=8" } @@ -6721,6 +6724,7 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, "funding": [ { "type": "individual", @@ -7008,7 +7012,8 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true }, "node_modules/cookie": { "version": "0.4.2", @@ -8005,6 +8010,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -8014,6 +8020,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -9295,6 +9302,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -10078,6 +10086,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -11990,6 +11999,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -13416,6 +13426,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -13426,7 +13437,8 @@ "node_modules/reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", - "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==", + "dev": true }, "node_modules/regenerate": { "version": "1.4.2", @@ -13896,7 +13908,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true + "dev": true }, "node_modules/sass": { "version": "1.69.5", @@ -14012,6 +14024,7 @@ "version": "7.5.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -14026,6 +14039,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -14036,7 +14050,8 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/send": { "version": "0.16.2", @@ -15287,6 +15302,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/package.json b/UI/Web/package.json index 911fe6143..d758b1ae0 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -1,12 +1,12 @@ { "name": "kavita-webui", - "version": "0.4.2", + "version": "0.7.12.1", "scripts": { "ng": "ng", "start": "ng serve", "build": "ng build", "minify-langs": "node minify-json.js", - "prod": "ng build --configuration production --aot --output-hashing=all && npm run minify-langs", + "prod": "ng build --configuration production && npm run minify-langs", "explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json", "lint": "ng lint", "e2e": "ng e2e" diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 8ed9d03e7..53eaac7fd 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -40,7 +40,7 @@ export class NavService { this.renderer = rendererFactory.createRenderer(null, null); // To avoid flashing, let's check if we are authenticated before we show - this.accountService.currentUser$.subscribe(u => { + this.accountService.currentUser$.pipe(take(1)).subscribe(u => { if (u) { this.showNavBar(); } diff --git a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts index 5aa539421..6306ff3c0 100644 --- a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts +++ b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts @@ -19,7 +19,7 @@ export class ReviewSeriesModalComponent implements OnInit { protected readonly modal = inject(NgbActiveModal); private readonly seriesService = inject(SeriesService); private readonly cdRef = inject(ChangeDetectorRef); - protected readonly minLength = 20; + protected readonly minLength = 5; @Input({required: true}) review!: UserReview; reviewGroup!: FormGroup; diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index d9f6ddbb3..23d043109 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -277,11 +277,23 @@ export class CardItemComponent implements OnInit { }); this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => { - if(this.utilityService.isSeries(this.entity)) return events.find(e => e.entityType === 'series' && e.subTitle === this.downloadService.downloadSubtitle('series', (this.entity as Series))) || null; - if(this.utilityService.isVolume(this.entity)) return events.find(e => e.entityType === 'volume' && e.subTitle === this.downloadService.downloadSubtitle('volume', (this.entity as Volume))) || null; - if(this.utilityService.isChapter(this.entity)) return events.find(e => e.entityType === 'chapter' && e.subTitle === this.downloadService.downloadSubtitle('chapter', (this.entity as Chapter))) || null; + if(this.utilityService.isSeries(this.entity)) { + return events.find(e => e.entityType === 'series' && e.id == this.entity.id + && e.subTitle === this.downloadService.downloadSubtitle('series', (this.entity as Series))) || null; + } + if(this.utilityService.isVolume(this.entity)) { + return events.find(e => e.entityType === 'volume' && e.id == this.entity.id + && e.subTitle === this.downloadService.downloadSubtitle('volume', (this.entity as Volume))) || null; + } + if(this.utilityService.isChapter(this.entity)) { + return events.find(e => e.entityType === 'chapter' && e.id == this.entity.id + && e.subTitle === this.downloadService.downloadSubtitle('chapter', (this.entity as Chapter))) || null; + } // Is PageBookmark[] - if(this.entity.hasOwnProperty('length')) return events.find(e => e.entityType === 'bookmark' && e.subTitle === this.downloadService.downloadSubtitle('bookmark', [(this.entity as PageBookmark)])) || null; + if(this.entity.hasOwnProperty('length')) { + return events.find(e => e.entityType === 'bookmark' + && e.subTitle === this.downloadService.downloadSubtitle('bookmark', [(this.entity as PageBookmark)])) || null; + } return null; })); diff --git a/UI/Web/src/app/cards/download-indicator/download-indicator.component.ts b/UI/Web/src/app/cards/download-indicator/download-indicator.component.ts index 418655d6a..aeb541a67 100644 --- a/UI/Web/src/app/cards/download-indicator/download-indicator.component.ts +++ b/UI/Web/src/app/cards/download-indicator/download-indicator.component.ts @@ -20,6 +20,4 @@ export class DownloadIndicatorComponent { * Observable that represents when the download completes */ @Input({required: true}) download$!: Observable | null; - - constructor() { } } diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index 52af401e7..362e9b9b7 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -48,7 +48,7 @@
@@ -63,7 +63,7 @@